diff --git a/CLAUDE.md b/CLAUDE.md index b495036c..3f055806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ The `cli/` directory is a Cargo workspace. - `crates/hm/` — the `hm` binary (today's CLI body). -- `crates/hm-exec/` — the `ExecutionBackend` trait + `LocalDockerBackend` (in-process Docker DAG scheduler) + `CloudBackend` (submit+watch over the SDK). The `hm` binary renders the emitted `BuildEvent` stream (via `hm-render`) and owns Ctrl-C; auth is injected (the crate takes a built `HarmontClient`). +- `crates/hm-core/` — the shared core: `config` (layered project/user/env config + credential storage), `exec` (the `ExecutionBackend` trait + `LocalBackend` in-process Docker DAG scheduler + `CloudBackend` submit+watch over the SDK), and `sys_runtime` (the process-wide git/python/dirs/cwd runtime). The `hm` binary renders the emitted `BuildEvent` stream (via `hm-render`) and owns Ctrl-C; auth is injected (the backends take a built `HarmontClient`). - `crates/hm-render/` — `drive_stream`: consumes an `EventStream` and writes terminal/JSON output. No I/O beyond stdout. - `crates/hm-pipeline-ir/` — pipeline IR schema (serde structs only, no runtime). - `crates/hm-common/` — shared utilities (OS/filesystem, formatting, and other cross-crate helpers). This is the source of truth for common code; prefer adding shared helpers here. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d2687a6..9859737e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -174,12 +174,11 @@ cargo insta review | Crate | What it is | |---|---| | `crates/hm` | The `hm` binary (package `harmont-cli`) — command-line client for the Harmont CI platform. | -| `crates/hm-exec` | Pluggable CI execution backends: local and cloud. | +| `crates/hm-core` | Shared core: layered configuration/credential storage, the pluggable CI execution backends (local and cloud), and the process-wide system runtime. | | `crates/hm-vm` | Local Docker backends that run pipeline steps on your machine. | | `crates/hm-render` | Build-event renderers (human, with progress bars, and JSON). | | `crates/hm-dsl-engine` | Evaluates Python pipeline definitions; contains the `harmont` Python package (`harmont-py/`). | | `crates/hm-pipeline-ir` | The pipeline IR wire-format schema. | -| `crates/hm-config` | Layered configuration and credential storage. | | `crates/hm-plugin-cloud` | Cloud client library. | | `crates/hm-plugin-protocol` | Wire types shared between `hm` crate internals. | | `crates/hm-util` | Shared OS and filesystem utilities. | @@ -201,7 +200,7 @@ from; a maintainer will regenerate the published docs during review. Every `hm` command accepts `-v`/`--verbose` for debug-level logging. For finer control, set `RUST_LOG` with standard tracing filter directives -(for example `RUST_LOG=hm_exec=trace`); when set, it takes precedence over +(for example `RUST_LOG=hm_core::exec=trace`); when set, it takes precedence over the flag. To see the raw `BuildEvent` stream that the renderers consume, run with `hm run --format json`. diff --git a/Cargo.lock b/Cargo.lock index 70d816c6..b74174a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1125,9 +1125,8 @@ dependencies = [ "harmont-cloud", "hex", "hm-common", - "hm-config", + "hm-core", "hm-dsl-engine", - "hm-exec", "hm-pipeline-ir", "hm-plugin-cloud", "hm-plugin-protocol", @@ -1141,6 +1140,7 @@ dependencies = [ "rand 0.8.6", "reqwest", "rstest", + "secrecy", "semver", "serde", "serde_json", @@ -1276,18 +1276,39 @@ dependencies = [ ] [[package]] -name = "hm-config" +name = "hm-core" version = "0.0.0-dev" dependencies = [ "anyhow", + "async-trait", + "chrono", + "daggy", "derive_more", "figment", + "flate2", + "futures", + "futures-util", + "harmont-cloud", "hm-common", + "hm-pipeline-ir", + "hm-plugin-protocol", + "hm-vm", + "human-units", + "ignore", "rstest", + "secrecy", "serde", + "serde_json", + "tar", "tempfile", + "thiserror 2.0.18", "tokio", + "tokio-stream", + "tokio-util", "toml", + "tracing", + "url", + "uuid", ] [[package]] @@ -1298,6 +1319,7 @@ dependencies = [ "async-trait", "derive_more", "hm-common", + "hm-core", "include_dir", "rstest", "serde", @@ -1307,37 +1329,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hm-exec" -version = "0.0.0-dev" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "daggy", - "flate2", - "futures", - "futures-util", - "harmont-cloud", - "hm-common", - "hm-pipeline-ir", - "hm-plugin-protocol", - "hm-vm", - "human-units", - "ignore", - "rstest", - "serde", - "serde_json", - "tar", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "uuid", -] - [[package]] name = "hm-pipeline-ir" version = "0.0.0-dev" @@ -1362,11 +1353,11 @@ dependencies = [ "dialoguer", "harmont-cloud", "harmont-cloud-raw", - "hm-config", - "hm-exec", + "hm-core", "hm-plugin-protocol", "hm-render", "rstest", + "secrecy", "serde_json", "tokio", "tracing", @@ -2895,6 +2886,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/Cargo.toml b/Cargo.toml index c57a8a2a..1b450bb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,25 +3,23 @@ resolver = "2" members = [ "crates/hm", "crates/hm-common", - "crates/hm-config", + "crates/hm-core", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", "crates/hm-plugin-cloud", "crates/hm-dsl-engine", "crates/hm-render", - "crates/hm-exec", "crates/hm-vm", ] default-members = [ "crates/hm", "crates/hm-common", - "crates/hm-config", + "crates/hm-core", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", "crates/hm-plugin-cloud", "crates/hm-dsl-engine", "crates/hm-render", - "crates/hm-exec", "crates/hm-vm", ] @@ -31,11 +29,10 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/harmont-dev/harmont-cli" [workspace.dependencies] -hm-exec = { path = "crates/hm-exec", version = "0.0.0-dev" } +hm-core = { path = "crates/hm-core", version = "0.0.0-dev" } hm-plugin-protocol = { path = "crates/hm-plugin-protocol", version = "0.0.0-dev" } hm-plugin-cloud = { path = "crates/hm-plugin-cloud", version = "0.0.0-dev" } hm-pipeline-ir = { path = "crates/hm-pipeline-ir", version = "0.0.0-dev" } -hm-config = { path = "crates/hm-config", version = "0.0.0-dev" } hm-dsl-engine = { path = "crates/hm-dsl-engine", version = "0.0.0-dev" } hm-render = { path = "crates/hm-render", version = "0.0.0-dev" } hm-vm = { path = "crates/hm-vm", version = "0.0.0-dev" } diff --git a/RELEASING.md b/RELEASING.md index ab33e891..faac8c2a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -45,8 +45,8 @@ index yet — which `cargo publish --dry-run` would), then publishes the crates to crates.io in dependency (topological) order: ``` -hm-util → hm-pipeline-ir → hm-config → hm-plugin-protocol → hm-render -→ hm-vm → hm-exec → hm-plugin-cloud → hm-dsl-engine → harmont-cli +hm-common → hm-pipeline-ir → hm-plugin-protocol → hm-render +→ hm-vm → hm-core → hm-plugin-cloud → hm-dsl-engine → harmont-cli ``` `harmont-cli` (the `hm` binary) depends on every other crate, so they diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml index e1900042..13eaf7f8 100644 --- a/crates/hm-common/Cargo.toml +++ b/crates/hm-common/Cargo.toml @@ -11,9 +11,6 @@ categories = ["command-line-utilities"] [lib] path = "src/lib.rs" -[features] -sys-runtime = [] - [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/crates/hm-common/src/dir_provider.rs b/crates/hm-common/src/dir_provider.rs index 477bf113..881e51e7 100644 --- a/crates/hm-common/src/dir_provider.rs +++ b/crates/hm-common/src/dir_provider.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; /// accessor instead of reconstructing one per call. #[derive(Debug, Clone)] pub struct DirProvider { + home: PathBuf, config: PathBuf, cache: PathBuf, } @@ -28,15 +29,23 @@ impl DirProvider { /// directory. #[must_use] pub fn new() -> Option { + let home = dirs::home_dir()?; #[cfg(windows)] let (config, cache) = (dirs::config_dir()?, dirs::cache_dir()?); #[cfg(not(windows))] - let (config, cache) = { - let home = dirs::home_dir()?; - (home.join(".config"), home.join(".cache")) - }; + let (config, cache) = (home.join(".config"), home.join(".cache")); - Some(Self { config, cache }) + Some(Self { + home, + config, + cache, + }) + } + + /// The user home directory. + #[must_use] + pub fn home(&self) -> &Path { + &self.home } /// The user configuration root (`~/.config` on non-Windows). @@ -63,7 +72,11 @@ mod tests { let dirs = DirProvider::new().unwrap(); assert!(dirs.config().is_absolute(), "got {:?}", dirs.config()); #[cfg(not(windows))] - assert!(dirs.config().ends_with(".config"), "got {:?}", dirs.config()); + assert!( + dirs.config().ends_with(".config"), + "got {:?}", + dirs.config() + ); } #[rstest] diff --git a/crates/hm-common/src/git.rs b/crates/hm-common/src/git.rs index 7149b765..a0033387 100644 --- a/crates/hm-common/src/git.rs +++ b/crates/hm-common/src/git.rs @@ -21,7 +21,7 @@ pub struct Git<'bin> { } impl<'bin> Git<'bin> { - /// Wrap a `git` executable, typically via `SysRuntime::git()`. + /// Wrap a `git` executable, typically via `AppCtx::git()`. #[must_use] pub const fn new(bin: &'bin Path) -> Self { Self { bin } @@ -206,7 +206,11 @@ mod tests { #[case::wrong_prefix("refs/heads/main", "origin", None)] #[case::trailing_slash("refs/remotes/origin/", "origin", None)] #[case::empty("", "origin", None)] - fn parses_default_branch(#[case] line: &str, #[case] remote: &str, #[case] expected: Option<&str>) { + fn parses_default_branch( + #[case] line: &str, + #[case] remote: &str, + #[case] expected: Option<&str>, + ) { let got = parse_default_branch(line.into(), remote); assert_eq!(got, expected.map(BString::from)); } @@ -263,7 +267,11 @@ mod tests { Command::new("git") .arg("-C") .arg(dir.path()) - .args(["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]) + .args([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ]) .captured() .unwrap() .success() diff --git a/crates/hm-common/src/lib.rs b/crates/hm-common/src/lib.rs index 556bfa89..6464db80 100644 --- a/crates/hm-common/src/lib.rs +++ b/crates/hm-common/src/lib.rs @@ -1,7 +1,5 @@ //! Harmont common utilities shared across the `hm` workspace. -#[cfg(feature = "sys-runtime")] -pub mod sys_runtime; pub mod dir_provider; pub mod format; pub mod fs; diff --git a/crates/hm-common/src/process/capture.rs b/crates/hm-common/src/process/capture.rs index abf0f496..5798f83d 100644 --- a/crates/hm-common/src/process/capture.rs +++ b/crates/hm-common/src/process/capture.rs @@ -74,7 +74,11 @@ fn render_capture_error(c: &Captured) -> String { if !stderr.is_empty() { const MAX: usize = 2000; let snippet: String = stderr.chars().take(MAX).collect(); - let ellipsis = if snippet.len() < stderr.len() { "…" } else { "" }; + let ellipsis = if snippet.len() < stderr.len() { + "…" + } else { + "" + }; let _ = write!(msg, ": {snippet}{ellipsis}"); } msg @@ -114,23 +118,35 @@ pub trait CapturedStreams: sealed::Sealed { } /// stdout as `&str`, **panicking** if not valid UTF-8. - #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + #[allow( + clippy::expect_used, + reason = "callers opt into panic-on-invalid by calling _unwrap" + )] fn stdout_str_unwrap(&self) -> &str { self.stdout_str().expect("stdout was not valid UTF-8") } /// stderr as `&str`, **panicking** if not valid UTF-8. - #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + #[allow( + clippy::expect_used, + reason = "callers opt into panic-on-invalid by calling _unwrap" + )] fn stderr_str_unwrap(&self) -> &str { self.stderr_str().expect("stderr was not valid UTF-8") } /// stdout as an owned `String`, **panicking** if not valid UTF-8. - #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + #[allow( + clippy::expect_used, + reason = "callers opt into panic-on-invalid by calling _unwrap" + )] fn stdout_string_unwrap(&self) -> String { self.stdout_string().expect("stdout was not valid UTF-8") } /// stderr as an owned `String`, **panicking** if not valid UTF-8. - #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + #[allow( + clippy::expect_used, + reason = "callers opt into panic-on-invalid by calling _unwrap" + )] fn stderr_string_unwrap(&self) -> String { self.stderr_string().expect("stderr was not valid UTF-8") } diff --git a/crates/hm-common/src/process/which.rs b/crates/hm-common/src/process/which.rs index 3f44d250..4efc9a2d 100644 --- a/crates/hm-common/src/process/which.rs +++ b/crates/hm-common/src/process/which.rs @@ -45,7 +45,10 @@ mod tests { fn error_names_the_missing_program() { let err = pathbin("hm-common-no-such-binary-xyz").unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("hm-common-no-such-binary-xyz"), "program: {msg}"); + assert!( + msg.contains("hm-common-no-such-binary-xyz"), + "program: {msg}" + ); assert!(msg.contains("not found on PATH"), "phrasing: {msg}"); } } diff --git a/crates/hm-common/src/python.rs b/crates/hm-common/src/python.rs index a79897ba..b8d50d37 100644 --- a/crates/hm-common/src/python.rs +++ b/crates/hm-common/src/python.rs @@ -15,7 +15,7 @@ pub struct Python<'bin> { } impl<'bin> Python<'bin> { - /// Wrap a `python3` executable, typically via `SysRuntime::python()`. + /// Wrap a `python3` executable, typically via `AppCtx::python()`. #[must_use] pub const fn new(bin: &'bin Path) -> Self { Self { bin } diff --git a/crates/hm-common/src/sys_runtime.rs b/crates/hm-common/src/sys_runtime.rs deleted file mode 100644 index 617f4261..00000000 --- a/crates/hm-common/src/sys_runtime.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! System runtime context. - -use std::path::{Path, PathBuf}; -use std::sync::OnceLock; - -use crate::dir_provider::DirProvider; -use crate::git::Git; -use crate::process::{ExecutableNotFound, pathbin}; -use crate::python::Python; - -/// Failure to initialize the [`SysRuntime`]. -#[derive(Debug, thiserror::Error)] -pub enum InitError { - /// A required executable was missing from `PATH`. - #[error(transparent)] - Executables(#[from] ExecutableNotFound), - /// The current directory could not be read. - #[error("reading the current directory")] - Cwd(#[source] std::io::Error), - /// The Harmont directory layout could not be resolved (no home directory?). - #[error("could not resolve the Harmont directory layout")] - Dirs, -} - -// TODO: This is a process-wide singleton (a global `OnceLock`), which is -// convenient but couples every caller to hidden global state. Consider -// threading an explicit `SysRuntime` handle through the application instead, -// so dependencies are visible in signatures and tests can inject their own. -/// -/// Install it with [`init`](Self::init), then read it from anywhere through the -/// associated accessors — no threading required. -#[derive(Debug)] -pub struct SysRuntime { - git: PathBuf, - python3: PathBuf, - cwd: PathBuf, - dirs: DirProvider, -} - -static RUNTIME: OnceLock = OnceLock::new(); - -impl SysRuntime { - /// Resolve the runtime and install it as the process-wide singleton. - /// - /// Call once, early in `main`, before any accessor. A later call is ignored. - /// - /// # Errors - /// [`InitError`] if a required executable is missing from `PATH` or the - /// current directory cannot be read. - pub fn init() -> Result<(), InitError> { - let runtime = Self::resolve()?; - let _ = RUNTIME.set(runtime); - Ok(()) - } - - fn resolve() -> Result { - Ok(Self { - git: pathbin("git")?, - python3: pathbin("python3")?, - cwd: std::env::current_dir().map_err(InitError::Cwd)?, - dirs: DirProvider::new().ok_or(InitError::Dirs)?, - }) - } - - #[allow( - clippy::expect_used, - reason = "accessing the runtime before init is a startup bug, not a runtime error" - )] - fn get() -> &'static Self { - RUNTIME - .get() - .expect("SysRuntime::init must be called before the runtime is accessed") - } - - /// The absolute working directory captured at initialization. - /// - /// # Panics - /// If [`init`](Self::init) has not been called. - #[must_use] - pub fn cwd() -> &'static Path { - &Self::get().cwd - } - - /// The platform user directory roots, resolved once at initialization. - /// - /// # Panics - /// If [`init`](Self::init) has not been called. - #[must_use] - pub fn dirs() -> &'static DirProvider { - &Self::get().dirs - } - - /// The system `git`, bound to a [`Git`] handle. - /// - /// # Panics - /// If [`init`](Self::init) has not been called. - #[must_use] - pub fn git() -> Git<'static> { - Git::new(&Self::get().git) - } - - /// The system `python3`, bound to a [`Python`] handle. - /// - /// # Panics - /// If [`init`](Self::init) has not been called. - #[must_use] - pub fn python() -> Python<'static> { - Python::new(&Self::get().python3) - } -} - -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::print_stderr, - reason = "test setup, assertions, and skip diagnostics" -)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - fn resolve_reports_toolchain_availability() { - assert_eq!( - SysRuntime::resolve().is_ok(), - pathbin("python3").is_ok() && pathbin("git").is_ok() - ); - } - - #[rstest] - fn resolve_captures_an_absolute_cwd() { - let Ok(runtime) = SysRuntime::resolve() else { - eprintln!("skipping: toolchain unavailable"); - return; - }; - assert!(runtime.cwd.is_absolute(), "cwd was {:?}", runtime.cwd); - } - - #[rstest] - fn init_installs_a_globally_accessible_runtime() { - if SysRuntime::resolve().is_err() { - eprintln!("skipping: toolchain unavailable"); - return; - } - SysRuntime::init().unwrap(); - assert!(SysRuntime::cwd().is_absolute()); - // git() binds the resolved git; a fresh temp dir is not a repo. - let dir = tempfile::tempdir().unwrap(); - assert!(SysRuntime::git().repo(dir.path()).is_err()); - } - - #[rstest] - fn init_error_wraps_a_missing_executable() { - let err: InitError = pathbin("hm-common-no-such-binary-xyz").unwrap_err().into(); - assert!(matches!(err, InitError::Executables(_))); - } -} diff --git a/crates/hm-common/src/time.rs b/crates/hm-common/src/time.rs index 7b7eb2cf..9b95039f 100644 --- a/crates/hm-common/src/time.rs +++ b/crates/hm-common/src/time.rs @@ -73,8 +73,14 @@ mod tests { fn now_is_after_2020_and_before_2100() { let now = Duration::now_epoch_secs_saturating::(); // 1_577_836_800 = 2020-01-01, 4_102_444_800 = 2100-01-01. - assert!(now > 1_577_836_800, "epoch secs {now} implausibly small (< 2020)"); - assert!(now < 4_102_444_800, "epoch secs {now} implausibly large (> 2100)"); + assert!( + now > 1_577_836_800, + "epoch secs {now} implausibly small (< 2020)" + ); + assert!( + now < 4_102_444_800, + "epoch secs {now} implausibly large (> 2100)" + ); } #[rstest] diff --git a/crates/hm-config/Cargo.toml b/crates/hm-config/Cargo.toml deleted file mode 100644 index 5f635577..00000000 --- a/crates/hm-config/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "hm-config" -version = "0.0.0-dev" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Layered (project/user/env) configuration + credential storage for the hm CLI." - -[dependencies] -hm-common = { workspace = true } -derive_more = { workspace = true } -figment = { workspace = true } -serde = { workspace = true } -toml = "0.8" -anyhow = "1" - -[dev-dependencies] -tempfile = "3" -tokio = { workspace = true } -rstest = { workspace = true } -# `test` brings in `figment::Jail` for isolated env-var/cwd testing. -figment = { workspace = true, features = ["test"] } - -[lints] -workspace = true diff --git a/crates/hm-config/src/creds.rs b/crates/hm-config/src/creds.rs deleted file mode 100644 index 5a1ab705..00000000 --- a/crates/hm-config/src/creds.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! File-backed credential store at `~/.config/hm/credentials.toml`. -//! -//! The file is written with [`Privacy::Private`] (0o600, parent dir 0o700) -//! via [`hm_common::fs::write_atomic`], keyed by `(service, account)`. - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::PathBuf; - -#[derive(Debug, Default, Serialize, Deserialize)] -struct CredentialFile { - #[serde(default)] - entries: BTreeMap>, -} - -fn path() -> Result { - let dirs = hm_common::dir_provider::DirProvider::new().context("could not determine config directory")?; - Ok(dirs.config().join("hm").join("credentials.toml")) -} - -fn load() -> CredentialFile { - let Ok(p) = path() else { - return CredentialFile::default(); - }; - let Ok(contents) = std::fs::read_to_string(&p) else { - return CredentialFile::default(); - }; - toml::from_str(&contents).unwrap_or_default() -} - -async fn save(file: &CredentialFile) -> Result<()> { - let p = path()?; - let serialized = toml::to_string_pretty(file).context("serializing credentials")?; - hm_common::fs::write_atomic(&p, serialized.as_bytes(), hm_common::fs::Privacy::Private) - .await - .with_context(|| format!("writing {}", p.display()))?; - Ok(()) -} - -/// Read a credential for `(service, account)`. Returns `None` when the -/// file is missing, unreadable, or the entry is absent. -#[must_use] -pub fn get(service: &str, account: &str) -> Option { - load().entries.get(service)?.get(account).cloned() -} - -/// Write a credential. Silently no-ops on I/O failure (best-effort). -pub async fn set(service: &str, account: &str, secret: &str) { - let mut f = load(); - f.entries - .entry(service.to_string()) - .or_default() - .insert(account.to_string(), secret.to_string()); - let _ = save(&f).await; -} - -/// Credential `service` name for the cloud bearer token (account = API base URL). -pub const CLOUD_SERVICE: &str = "harmont-cloud"; - -/// Resolve the cloud bearer token for `api_base`. -/// -/// Priority: `HM_API_TOKEN` env (non-empty) first, then the stored -/// credential keyed by `(CLOUD_SERVICE, api_base)`. Returns `None` when -/// neither is present, so the caller can produce a clear "not logged in" error. -#[must_use] -pub fn cloud_token(api_base: &str) -> Option { - if let Ok(t) = std::env::var("HM_API_TOKEN") - && !t.is_empty() - { - return Some(t); - } - get(CLOUD_SERVICE, api_base) -} - -/// Persist the cloud bearer token for `api_base`. -/// -/// Silently no-ops on I/O failure (matches the best-effort semantics of -/// the underlying [`set`] call). -pub async fn set_cloud_token(api_base: &str, token: &str) { - set(CLOUD_SERVICE, api_base, token).await; -} - -/// Remove any stored cloud bearer token for `api_base`. -/// -/// Silently no-ops if the entry is absent or the write fails. -pub async fn forget_cloud_token(api_base: &str) { - delete(CLOUD_SERVICE, api_base).await; -} - -/// Remove a credential. Silently no-ops if the entry is absent or the -/// underlying write fails. -pub async fn delete(service: &str, account: &str) { - let mut f = load(); - let now_empty = f.entries.get_mut(service).is_some_and(|svc| { - svc.remove(account); - svc.is_empty() - }); - if now_empty { - f.entries.remove(service); - } - let _ = save(&f).await; -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, unsafe_code)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - #[tokio::test] - async fn round_trip() { - let tmp = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("HOME"); - // SAFETY: tests are single-threaded for env mutation by Cargo. - unsafe { - std::env::set_var("HOME", tmp.path()); - } - - assert_eq!(get("svc", "acct"), None); - set("svc", "acct", "shh").await; - assert_eq!(get("svc", "acct").as_deref(), Some("shh")); - delete("svc", "acct").await; - assert_eq!(get("svc", "acct"), None); - - unsafe { - if let Some(v) = prev { - std::env::set_var("HOME", v); - } else { - std::env::remove_var("HOME"); - } - } - } -} diff --git a/crates/hm-config/src/lib.rs b/crates/hm-config/src/lib.rs deleted file mode 100644 index d9bd57e8..00000000 --- a/crates/hm-config/src/lib.rs +++ /dev/null @@ -1,456 +0,0 @@ -//! Layered (project/user/env) configuration and credential storage for the -//! `hm` CLI. Shared between the `hm` binary and `hm-plugin-cloud` so both sides -//! resolve config and credentials through one source of truth. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use figment::{ - Figment, - providers::{Env, Format, Serialized, Toml}, -}; -use serde::{Deserialize, Serialize}; - -pub mod creds; - -pub const DEFAULT_API_URL: &str = "https://api.harmont.dev"; - -/// Execution backend for `hm run`. -/// -/// Closed set parsed at the config boundary so invalid values are rejected at -/// deserialize time instead of mis-dispatching later, and every consumer match -/// is exhaustively checked by the compiler. -/// -/// The `#[display(...)]` strings are the stable lowercase wire/CLI names and -/// must match the `#[serde(rename_all = "lowercase")]` representation. -#[derive( - Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, derive_more::Display, -)] -#[serde(rename_all = "lowercase")] -pub enum Backend { - #[default] - #[display("docker")] - Docker, - #[display("cloud")] - Cloud, -} - -/// Derive the SPA (dashboard) base URL from the API base. -/// -/// The CLI talks to `api.harmont.dev`, but a human clicks through to the -/// dashboard at `app.harmont.dev`. A watch/login link built from the API host -/// lands on raw JSON, so every surface that emits a user-clickable URL must map -/// the host first. -/// -/// Priority: -/// 1. `override_url` (e.g. the `HM_APP_URL` env override) when non-empty, -/// 2. heuristic mapping of `api.` → `app.` on the API host, -/// 3. the API base itself (last-resort dev fallback for hosts like -/// `localhost` that have no `api.`/`app.` split). -/// -/// The returned URL never has a trailing slash. -#[must_use] -pub fn app_url(api: &str, override_url: Option<&str>) -> String { - if let Some(u) = override_url.map(str::trim).filter(|u| !u.is_empty()) { - return u.trim_end_matches('/').to_string(); - } - let api = api.trim_end_matches('/'); - if let Some(rest) = api.strip_prefix("https://api.") { - return format!("https://app.{rest}"); - } - if let Some(rest) = api.strip_prefix("http://api.") { - return format!("http://app.{rest}"); - } - api.to_string() -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct CloudConfig { - pub org: Option, - pub api_url: String, - /// Org-global pipeline slug to submit builds to directly (set by `hm run` - /// after registering a remoteless directory). When present, cloud runs - /// submit by this slug instead of resolving by git-repo identity. - pub pipeline: Option, -} - -impl Default for CloudConfig { - fn default() -> Self { - Self { - org: None, - api_url: DEFAULT_API_URL.to_owned(), - pipeline: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct Preferences { - pub format: String, - pub auto_watch: bool, -} - -impl Default for Preferences { - fn default() -> Self { - Self { - format: "human".to_owned(), - auto_watch: false, - } - } -} - -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct Config { - #[serde(default)] - pub backend: Backend, - #[serde(default)] - pub cloud: CloudConfig, - #[serde(default)] - pub preferences: Preferences, -} - -impl Config { - /// XDG-aware user config path (`~/.config/hm/config.toml`). - /// - /// # Errors - /// - /// Returns an error if the platform config directory cannot be determined. - pub fn user_config_path() -> Result { - let dirs = hm_common::dir_provider::DirProvider::new().context("could not determine config directory")?; - Ok(dirs.config().join("hm").join("config.toml")) - } - - /// Project-level config path: `/.hm/config.toml`. - #[must_use] - pub fn project_config_path(project_root: &Path) -> PathBuf { - project_root.join(".hm").join("config.toml") - } - - /// Load configuration with full layering: defaults -> user file -> project file -> env. - /// - /// # Errors - /// - /// Returns an error if the user config path cannot be determined or - /// figment extraction fails (malformed TOML, type mismatches). - pub fn load(project_root: Option<&Path>) -> Result { - let user_path = Self::user_config_path()?; - let project_path = project_root.map(Self::project_config_path); - Self::load_from_paths(Some(&user_path), project_path.as_deref()) - .context("loading configuration") - } - - /// Testable core: build a `Config` from explicit file paths. - /// - /// Layering, lowest to highest precedence: defaults -> user file -> - /// project file -> env. - /// - /// Env precedence (highest): both the `HM_`-prefixed split form - /// (`HM_CLOUD__ORG`, `HM_CLOUD__API_URL`) and the documented - /// `HM_ORG` / `HM_API_URL` are honored; the latter map onto - /// `cloud.org` / `cloud.api_url`. - /// - /// # Errors - /// - /// Returns an error if figment extraction fails (malformed TOML, type mismatches). - pub fn load_from_paths(user_path: Option<&Path>, project_path: Option<&Path>) -> Result { - let mut figment = Figment::new().merge(Serialized::defaults(Self::default())); - - if let Some(p) = user_path { - figment = figment.merge(Toml::file(p)); - } - if let Some(p) = project_path { - figment = figment.merge(Toml::file(p)); - } - - figment = figment - .merge(Env::prefixed("HM_").split("__")) - .merge(hm_alias_env()); - - Ok(figment.extract()?) - } - - /// Persist config to `path` atomically. - /// - /// # Errors - /// - /// Returns an error if TOML serialization fails or the atomic write fails. - pub async fn save_to(&self, path: &Path) -> Result<()> { - let serialized = toml::to_string_pretty(self).context("serializing config")?; - hm_common::fs::write_atomic(path, serialized.as_bytes(), hm_common::fs::Privacy::Public) - .await - .with_context(|| format!("writing {}", path.display())) - } - - /// Save to user-level config path (`~/.config/hm/config.toml`). - /// - /// # Errors - /// - /// Returns an error if the path cannot be determined or the write fails. - pub async fn save_user(&self) -> Result<()> { - self.save_to(&Self::user_config_path()?).await - } -} - -/// Figment env provider mapping the friendly `HM_ORG` / `HM_API_URL` -/// variables onto the nested `cloud` config keys. -/// -/// The cloud settings docs and `hm`'s error messages tell users to -/// `set HM_ORG=` / `HM_API_URL=`, so those flat names must feed -/// the config. This binds them to `cloud.org` / `cloud.api_url` alongside the -/// generic `HM_`-prefixed split layer (`HM_CLOUD__ORG`, …). -fn hm_alias_env() -> Env { - Env::raw() - .only(&["HM_ORG", "HM_API_URL"]) - .map(|key| match key.as_str() { - "HM_ORG" => "cloud.org".into(), - "HM_API_URL" => "cloud.api_url".into(), - other => other.into(), - }) - .split(".") -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, reason = "test setup and assertions")] -mod tests { - use super::*; - use rstest::rstest; - use std::io::Write as _; - use std::sync::{Mutex, MutexGuard}; - - /// Serializes every test that resolves config through `load_*`. - /// - /// All `load_*` paths merge the process environment as their top layer, so - /// a test that sets `HM_*` (via `figment::Jail`, which mutates the - /// real process env for the duration of its closure) would otherwise leak - /// into a concurrently-running file-layering test. Holding this lock for - /// the whole body of any env-or-load test makes them mutually exclusive. - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - fn env_guard() -> MutexGuard<'static, ()> { - ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[rstest] - #[case::prod_api_to_app(DEFAULT_API_URL, None, "https://app.harmont.dev")] - #[case::override_wins_trims_slash( - DEFAULT_API_URL, - Some("http://localhost:5173/"), - "http://localhost:5173" - )] - #[case::empty_override_ignored(DEFAULT_API_URL, Some(" "), "https://app.harmont.dev")] - #[case::unmapped_host_fallback("http://localhost:4000", None, "http://localhost:4000")] - #[case::http_api_to_app("http://api.dev.test/", None, "http://app.dev.test")] - fn app_url_derives_app_host( - #[case] api: &str, - #[case] override_url: Option<&str>, - #[case] expected: &str, - ) { - assert_eq!(app_url(api, override_url), expected); - } - - #[rstest] - fn default_config_values() { - let cfg = Config::default(); - assert_eq!(cfg.backend, Backend::Docker); - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert!(cfg.cloud.org.is_none()); - assert!(cfg.cloud.pipeline.is_none()); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } - - #[rstest] - fn deserialize_full_toml() { - let toml_str = r#" -[cloud] -org = "acme" -api_url = "https://custom.api" -pipeline = "acme/web" - -[preferences] -format = "json" -auto_watch = true -"#; - let cfg: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("acme")); - assert_eq!(cfg.cloud.api_url, "https://custom.api"); - assert_eq!(cfg.cloud.pipeline.as_deref(), Some("acme/web")); - assert_eq!(cfg.preferences.format, "json"); - assert!(cfg.preferences.auto_watch); - } - - #[rstest] - fn deserialize_sparse_toml() { - let _g = env_guard(); - let toml_str = r#" -[cloud] -org = "sparse-co" -"#; - let mut f = tempfile::NamedTempFile::new().unwrap(); - f.write_all(toml_str.as_bytes()).unwrap(); - - let cfg = Config::load_from_paths(Some(f.path()), None).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("sparse-co")); - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } - - #[rstest] - fn deserialize_empty_toml() { - let _g = env_guard(); - let mut f = tempfile::NamedTempFile::new().unwrap(); - f.write_all(b"").unwrap(); - - let cfg = Config::load_from_paths(Some(f.path()), None).unwrap(); - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert!(cfg.cloud.org.is_none()); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } - - #[rstest] - fn figment_project_overrides_user() { - let _g = env_guard(); - let user_toml = r#" -[cloud] -org = "user-org" -api_url = "https://user.api" - -[preferences] -format = "json" -"#; - let project_toml = r#" -[cloud] -org = "project-org" -"#; - - let mut user_file = tempfile::NamedTempFile::new().unwrap(); - user_file.write_all(user_toml.as_bytes()).unwrap(); - - let mut project_file = tempfile::NamedTempFile::new().unwrap(); - project_file.write_all(project_toml.as_bytes()).unwrap(); - - let cfg = - Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap(); - - assert_eq!(cfg.cloud.org.as_deref(), Some("project-org")); - assert_eq!(cfg.cloud.api_url, "https://user.api"); - assert_eq!(cfg.preferences.format, "json"); - } - - #[rstest] - fn backend_display_matches_wire_strings() { - assert_eq!(Backend::Docker.to_string(), "docker"); - assert_eq!(Backend::Cloud.to_string(), "cloud"); - } - - #[rstest] - fn backend_defaults_docker_and_parses_and_layers() { - let _g = env_guard(); - // default - assert_eq!(Config::default().backend, Backend::Docker); - - // user file sets cloud; project file sets docker -> project wins. - let mut user_file = tempfile::NamedTempFile::new().unwrap(); - user_file.write_all(br#"backend = "cloud""#).unwrap(); - - let mut project_file = tempfile::NamedTempFile::new().unwrap(); - project_file.write_all(br#"backend = "docker""#).unwrap(); - - let cfg = - Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap(); - assert_eq!(cfg.backend, Backend::Docker); - - // user file alone parses "cloud". - let cfg_user = Config::load_from_paths(Some(user_file.path()), None).unwrap(); - assert_eq!(cfg_user.backend, Backend::Cloud); - } - - #[rstest] - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn save_and_reload_roundtrip() { - let _g = env_guard(); - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("config.toml"); - let cfg = Config { - cloud: CloudConfig { - org: Some("saved-org".into()), - pipeline: Some("saved-org/web".into()), - ..CloudConfig::default() - }, - ..Config::default() - }; - cfg.save_to(&path).await.unwrap(); - - let loaded = Config::load_from_paths(Some(&path), None).unwrap(); - assert_eq!(loaded.cloud.org.as_deref(), Some("saved-org")); - assert_eq!(loaded.cloud.pipeline.as_deref(), Some("saved-org/web")); - assert_eq!(loaded.cloud.api_url, DEFAULT_API_URL); - assert_eq!(loaded.preferences.format, "human"); - } - - #[rstest] - #[allow( - clippy::result_large_err, - reason = "figment::Error is the Jail closure's error type" - )] - fn hm_env_overrides_cloud_keys() { - let _g = env_guard(); - // `Jail` isolates env mutation from concurrently-running tests. - figment::Jail::expect_with(|jail| { - jail.set_env("HM_ORG", "env-org"); - jail.set_env("HM_API_URL", "https://env.api"); - - let cfg = Config::load_from_paths(None, None).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("env-org")); - assert_eq!(cfg.cloud.api_url, "https://env.api"); - Ok(()) - }); - } - - #[rstest] - #[allow( - clippy::result_large_err, - reason = "figment::Error is the Jail closure's error type" - )] - fn hm_env_overrides_user_file() { - let _g = env_guard(); - // Env is the highest-precedence layer: it wins over a user file. - figment::Jail::expect_with(|jail| { - jail.set_env("HM_ORG", "env-org"); - - jail.create_file( - "config.toml", - "[cloud]\norg = \"file-org\"\napi_url = \"https://file.api\"\n", - )?; - let user = jail.directory().join("config.toml"); - - let cfg = Config::load_from_paths(Some(&user), None).unwrap(); - assert_eq!(cfg.cloud.org.as_deref(), Some("env-org")); - // Unset env keys still come from the file. - assert_eq!(cfg.cloud.api_url, "https://file.api"); - Ok(()) - }); - } - - #[rstest] - fn figment_missing_files_still_resolve() { - let _g = env_guard(); - let nonexistent_user = Path::new("/tmp/harmont-test-nonexistent-user/config.toml"); - let nonexistent_project = Path::new("/tmp/harmont-test-nonexistent-project/config.toml"); - - let cfg = - Config::load_from_paths(Some(nonexistent_user), Some(nonexistent_project)).unwrap(); - - assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL); - assert!(cfg.cloud.org.is_none()); - assert_eq!(cfg.preferences.format, "human"); - assert!(!cfg.preferences.auto_watch); - } -} diff --git a/crates/hm-exec/Cargo.toml b/crates/hm-core/Cargo.toml similarity index 70% rename from crates/hm-exec/Cargo.toml rename to crates/hm-core/Cargo.toml index 90056d24..82338dd8 100644 --- a/crates/hm-exec/Cargo.toml +++ b/crates/hm-core/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "hm-exec" +name = "hm-core" version = "0.0.0-dev" edition.workspace = true license.workspace = true repository.workspace = true -description = "Pluggable CI execution backends (local VM + cloud) for the hm CLI." +description = "Core of the hm CLI: layered config/credentials + pluggable CI execution backends (local VM + cloud)." [dependencies] hm-common = { workspace = true } @@ -22,6 +22,9 @@ thiserror = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = "0.8" +figment = { workspace = true } +derive_more = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } @@ -31,11 +34,15 @@ flate2 = "1" ignore = "0.4" tempfile = "3" daggy = { workspace = true } +url = "2" +secrecy = "0.10" [dev-dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +tokio = { workspace = true } tempfile = "3" rstest = { workspace = true } +# `test` brings in `figment::Jail` for isolated env-var/cwd testing. +figment = { workspace = true, features = ["test"] } [lints] workspace = true diff --git a/crates/hm-core/src/app_ctx.rs b/crates/hm-core/src/app_ctx.rs new file mode 100644 index 00000000..e2fca3eb --- /dev/null +++ b/crates/hm-core/src/app_ctx.rs @@ -0,0 +1,182 @@ +//! Process-wide application context: resolved toolchain, directories, and user +//! config. + +use std::path::{Path, PathBuf}; + +use hm_common::dir_provider::DirProvider; +use hm_common::git::Git; +use hm_common::process::{ExecutableNotFound, pathbin}; +use hm_common::python::Python; + +use crate::config::domain::ConfigLoadingError; +use crate::config::user::UserConfig; +use crate::creds::{CredsInitError, CredsProvider}; + +/// Failure to initialize the [`AppCtx`]. +#[derive(Debug, thiserror::Error)] +pub enum InitError { + /// A required executable was missing from `PATH`. + #[error(transparent)] + Executables(#[from] ExecutableNotFound), + /// The current directory could not be read. + #[error("reading the current directory")] + Cwd(#[source] std::io::Error), + /// The Harmont directory layout could not be resolved (no home directory?). + #[error("could not resolve the Harmont directory layout")] + Dirs, + /// The user config file exists but could not be read or parsed. + #[error("loading the user config")] + UserConfig(#[source] ConfigLoadingError), + /// The credentials store could not be opened. + #[error(transparent)] + Creds(#[from] CredsInitError), +} + +/// Resolved toolchain, platform directories, user config, and credentials. +#[derive(Debug)] +pub struct AppCtx { + git: PathBuf, + python3: PathBuf, + cwd: PathBuf, + dirs: DirProvider, + user_config: Option, + creds: CredsProvider, +} + +impl AppCtx { + /// Resolve the toolchain, directories, and user config. + /// + /// # Errors + /// [`InitError`] if a required executable is missing from `PATH`, the + /// current directory cannot be read, the home directory cannot be resolved, + /// or a present user config file is malformed. A missing user config file + /// is not an error. + pub async fn init() -> Result { + let git = pathbin("git")?; + let python3 = pathbin("python3")?; + let cwd = std::env::current_dir().map_err(InitError::Cwd)?; + let dirs = DirProvider::new().ok_or(InitError::Dirs)?; + let hm_dir = dirs.home().join(".hm"); + let config_path = hm_dir.join("config.toml"); + + // Independent I/O — resolve the user config and open the creds store + // concurrently. + let (user_config, creds) = tokio::join!( + Self::load_user_config(&config_path), + CredsProvider::new(&hm_dir), + ); + let user_config = user_config.map_err(InitError::UserConfig)?; + let creds = creds?; + + Ok(Self { + git, + python3, + cwd, + dirs, + user_config, + creds, + }) + } + + /// The user config path (`~/.hm/config.toml`). + #[must_use] + pub fn user_config_path(&self) -> PathBuf { + self.dirs.home().join(".hm").join("config.toml") + } + + /// Read the user config, treating a missing file as [`None`]. + /// + /// # Errors + /// [`ConfigLoadingError`] if the file is present but unreadable or malformed. + async fn load_user_config(path: &Path) -> Result, ConfigLoadingError> { + match tokio::fs::read_to_string(path).await { + Ok(contents) => Ok(Some(toml::from_str(&contents)?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// The absolute working directory captured at initialization. + #[must_use] + pub fn cwd(&self) -> &Path { + self.cwd.as_path() + } + + /// The platform user directory roots. + #[must_use] + pub const fn dirs(&self) -> &DirProvider { + &self.dirs + } + + /// The user config, or `None` when no user config file is present. + #[must_use] + pub const fn user_config(&self) -> Option<&UserConfig> { + self.user_config.as_ref() + } + + /// The credentials store under `~/.hm/creds/`. + #[must_use] + pub const fn creds(&self) -> &CredsProvider { + &self.creds + } + + /// The system `git`, bound to a [`Git`] handle. + #[must_use] + pub fn git(&self) -> Git<'_> { + Git::new(&self.git) + } + + /// The system `python3`, bound to a [`Python`] handle. + #[must_use] + pub fn python(&self) -> Python<'_> { + Python::new(&self.python3) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::print_stderr, + reason = "test setup, assertions, and skip diagnostics" +)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[tokio::test] + async fn init_resolves_absolute_cwd_when_toolchain_present() { + if pathbin("git").is_err() || pathbin("python3").is_err() { + eprintln!("skipping: toolchain unavailable"); + return; + } + let ctx = AppCtx::init().await.unwrap(); + assert!(ctx.cwd().is_absolute()); + // git() binds the resolved git; a fresh temp dir is not a repo. + let dir = tempfile::tempdir().unwrap(); + assert!(ctx.git().repo(dir.path()).is_err()); + } + + #[rstest] + #[tokio::test] + async fn missing_user_config_is_none() { + let path = Path::new("/nonexistent/harmont-test/.hm/config.toml"); + assert!(AppCtx::load_user_config(path).await.unwrap().is_none()); + } + + #[rstest] + #[tokio::test] + async fn present_user_config_is_parsed() { + use std::io::Write as _; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(b"[backend]\ntype = \"docker\"\n").unwrap(); + let loaded = AppCtx::load_user_config(f.path()).await.unwrap(); + assert!(loaded.is_some()); + } + + #[rstest] + fn init_error_wraps_a_missing_executable() { + let err: InitError = pathbin("hm-common-no-such-binary-xyz").unwrap_err().into(); + assert!(matches!(err, InitError::Executables(_))); + } +} diff --git a/crates/hm-core/src/config/domain.rs b/crates/hm-core/src/config/domain.rs new file mode 100644 index 00000000..410cc9ca --- /dev/null +++ b/crates/hm-core/src/config/domain.rs @@ -0,0 +1,97 @@ +//! Shared config types: the backend selector and the cloud domain model. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use url::Url; + +/// Default Harmont domain used when a cloud backend omits one. +pub const DEFAULT_DOMAIN: &str = "harmont.dev"; + +/// Failure to read or parse a config file. +#[derive(Debug, thiserror::Error)] +pub enum ConfigLoadingError { + /// The file could not be read. + #[error("reading config file")] + Io(#[from] std::io::Error), + /// The file was not valid config TOML. + #[error("parsing config TOML")] + Deser(#[from] toml::de::Error), +} + +/// Execution backend selected by a config file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum BackendConfig { + /// Run builds on the local Docker backend. + #[default] + Docker, + /// Run builds on Harmont Cloud. + Cloud(C), +} + +/// Base Harmont domain for a cloud backend. +/// +/// Parses from either a bare domain (`harmont.dev`) or a full URL (`https://harmont.dev`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendDomain(Url); + +impl BackendDomain { + /// Parse from a bare domain or a full URL; a missing scheme becomes `https`. + /// + /// # Errors + /// [`url::ParseError`] when the scheme-completed string is not a URL. + pub fn parse(s: &str) -> Result { + let s = s.trim(); + let normalized = if s.contains("://") { + s.to_owned() + } else { + format!("https://{s}") + }; + Ok(Self(Url::parse(&normalized)?)) + } + + /// The API base URL (`https://api.`), without a trailing slash. + #[must_use] + pub fn api_url(&self) -> String { + self.subdomain_url("api") + } + + /// The dashboard base URL (`https://app.`), without a trailing slash. + #[must_use] + pub fn app_url(&self) -> String { + self.subdomain_url("app") + } + + fn subdomain_url(&self, sub: &str) -> String { + match self.0.host_str() { + Some(host) if host.contains('.') && host.parse::().is_err() => { + let mut url = self.0.clone(); + let _ = url.set_host(Some(&format!("{sub}.{host}"))); + url.as_str().trim_end_matches('/').to_owned() + } + _ => self.0.as_str().trim_end_matches('/').to_owned(), + } + } +} + +impl Default for BackendDomain { + fn default() -> Self { + #[allow( + clippy::expect_used, + reason = "DEFAULT_DOMAIN is a compile-time constant known to parse" + )] + Self::parse(DEFAULT_DOMAIN).expect("DEFAULT_DOMAIN must be a valid domain") + } +} + +impl Serialize for BackendDomain { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.0.as_str().trim_end_matches('/')) + } +} + +impl<'de> Deserialize<'de> for BackendDomain { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} diff --git a/crates/hm-core/src/config/mod.rs b/crates/hm-core/src/config/mod.rs new file mode 100644 index 00000000..c59458dd --- /dev/null +++ b/crates/hm-core/src/config/mod.rs @@ -0,0 +1,71 @@ +//! Resolving the user and project config layers into one effective view. +//! +//! [`UserConfig`] and [`ProjectConfig`] are sparse file layers; +//! [`ResolvedProjectConfig`] is the two merged — project over user, defaults +//! applied. + +pub mod domain; +pub mod project; +pub mod user; + +use domain::{BackendConfig, BackendDomain}; +use project::ProjectConfig; +use user::UserConfig; + +/// Resolved cloud backend settings, with layer merging and defaults applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedCloudConfig { + /// Base Harmont domain the `api`/`app` hosts derive from. + pub domain: BackendDomain, + /// Organization for cloud runs, if set by either layer. + pub org: Option, + /// Repository (`owner/repo`) this project builds, if set. + pub repo: Option, + /// Pipeline slug to submit to without re-prompting, if set. + pub default_pipeline: Option, +} + +/// The effective configuration for a run: the user and project layers merged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedProjectConfig { + /// The resolved execution backend. + pub backend: BackendConfig, +} + +impl ResolvedProjectConfig { + /// Merge the user and project layers into the effective config. + #[must_use] + pub fn from_user_project(user: &UserConfig, project: &ProjectConfig) -> Self { + let user_cloud = match &user.backend { + Some(BackendConfig::Cloud(cloud)) => Some(cloud), + _ => None, + }; + + let backend = match &project.backend { + Some(BackendConfig::Docker) => BackendConfig::Docker, + Some(BackendConfig::Cloud(proj)) => BackendConfig::Cloud(ResolvedCloudConfig { + domain: proj + .domain + .clone() + .or_else(|| user_cloud.and_then(|u| u.domain.clone())) + .unwrap_or_default(), + org: proj + .org + .clone() + .or_else(|| user_cloud.and_then(|u| u.org.clone())), + repo: proj.repo.clone(), + default_pipeline: proj.default_pipeline.clone(), + }), + None => user_cloud.map_or(BackendConfig::Docker, |cloud| { + BackendConfig::Cloud(ResolvedCloudConfig { + domain: cloud.domain.clone().unwrap_or_default(), + org: cloud.org.clone(), + repo: None, + default_pipeline: None, + }) + }), + }; + + Self { backend } + } +} diff --git a/crates/hm-core/src/config/project.rs b/crates/hm-core/src/config/project.rs new file mode 100644 index 00000000..ea6c177a --- /dev/null +++ b/crates/hm-core/src/config/project.rs @@ -0,0 +1,43 @@ +//! Configuration scoped to a project (`/.hm/config.toml`). + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::domain::{BackendConfig, BackendDomain, ConfigLoadingError}; + +/// Cloud settings in a project config: a superset of the user cloud settings, +/// adding project-scoped identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ProjectCloudConfig { + /// Base Harmont domain. + pub domain: Option, + /// Organization this project runs under. + pub org: Option, + /// Repository (`owner/repo`) this project builds. + pub repo: Option, + /// Resolved pipeline slug to submit to without re-prompting. + pub default_pipeline: Option, +} + +/// The execution backend selected in a project config. +pub type ProjectBackendConfig = BackendConfig; + +/// Configuration stored in `/.hm/config.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ProjectConfig { + /// Which execution backend to use. + pub backend: Option, +} + +impl ProjectConfig { + /// Load and parse a project config from `path`. + /// + /// # Errors + /// [`ConfigLoadingError`] if the file cannot be read or is not valid config + /// TOML. + pub async fn try_load(path: impl AsRef) -> Result { + let contents = tokio::fs::read_to_string(path).await?; + Ok(toml::from_str(&contents)?) + } +} diff --git a/crates/hm-core/src/config/user.rs b/crates/hm-core/src/config/user.rs new file mode 100644 index 00000000..18ef0756 --- /dev/null +++ b/crates/hm-core/src/config/user.rs @@ -0,0 +1,51 @@ +//! Configuration scoped to the user (`~/.hm/config.toml`). + +use std::path::Path; + +use anyhow::Context as _; +use serde::{Deserialize, Serialize}; + +use super::domain::{BackendConfig, BackendDomain, ConfigLoadingError}; + +/// Cloud settings in a user config. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct UserCloudConfig { + /// Base Harmont domain. + #[serde(default)] + pub domain: Option, + /// Default organization for cloud runs. + pub org: Option, +} + +/// The execution backend selected in a user config. +pub type UserBackendConfig = BackendConfig; + +/// Configuration stored in `~/.hm/config.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct UserConfig { + /// Which execution backend to use. + pub backend: Option, +} + +impl UserConfig { + /// Load and parse a user config from `path`. + /// + /// # Errors + /// [`ConfigLoadingError`] if the file cannot be read or is not valid config + /// TOML. + pub async fn try_load(path: impl AsRef) -> Result { + let contents = tokio::fs::read_to_string(path).await?; + Ok(toml::from_str(&contents)?) + } + + /// Serialize to `path`, creating parent directories as needed. + /// + /// # Errors + /// [`anyhow::Error`] if serialization or the write fails. + pub async fn save(&self, path: &Path) -> anyhow::Result<()> { + let serialized = toml::to_string_pretty(self).context("serializing user config")?; + hm_common::fs::write_create_all(path, serialized) + .await + .with_context(|| format!("writing {}", path.display())) + } +} diff --git a/crates/hm-core/src/creds/mod.rs b/crates/hm-core/src/creds/mod.rs new file mode 100644 index 00000000..e83380d0 --- /dev/null +++ b/crates/hm-core/src/creds/mod.rs @@ -0,0 +1,141 @@ +//! Storage for the Harmont cloud bearer token, under `~/.hm/creds/`. + +use std::path::{Path, PathBuf}; + +use secrecy::SecretString; + +// Credential files are hardened to owner-only via Unix mode bits in +// `CredsProvider::new`. Windows needs an equivalent ACL-based path (restrict the +// DACL to the current user) before this is safe to ship there. Fail the build +// until that lands, rather than silently storing tokens with inherited ACLs. +#[cfg(windows)] +compile_error!( + "CredsProvider does not yet harden credential permissions on Windows; \ + implement an ACL-based owner-only path in CredsProvider::new first" +); + +/// The `HM_API_TOKEN` env var, which overrides the stored token. +const TOKEN_ENV: &str = "HM_API_TOKEN"; + +/// Failure to open the credentials store. +#[derive(Debug, thiserror::Error)] +#[error("initializing the credentials store at {path}")] +pub struct CredsInitError { + path: PathBuf, + #[source] + source: std::io::Error, +} + +/// The active Harmont cloud bearer token, stored under `/creds/`. +#[derive(Debug, Clone)] +pub struct CredsProvider { + creds_path: PathBuf, +} + +impl CredsProvider { + /// Open the store under `hm_dir`, creating `creds/` (owner-only) if absent + /// and tightening it and any existing entries to owner-only perms. + /// + /// # Errors + /// [`CredsInitError`] if the directory cannot be created or its permissions + /// cannot be secured. + pub async fn new(hm_dir: &Path) -> Result { + let creds_path = hm_dir.join("creds"); + let err = |source| CredsInitError { + path: creds_path.clone(), + source, + }; + + tokio::fs::create_dir_all(&creds_path).await.map_err(err)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + tokio::fs::set_permissions(&creds_path, std::fs::Permissions::from_mode(0o700)) + .await + .map_err(err)?; + let mut entries = tokio::fs::read_dir(&creds_path).await.map_err(err)?; + while let Some(entry) = entries.next_entry().await.map_err(err)? { + if entry.path().is_file() { + tokio::fs::set_permissions( + entry.path(), + std::fs::Permissions::from_mode(0o600), + ) + .await + .map_err(err)?; + } + } + } + + Ok(Self { creds_path }) + } + + /// The token file path. + fn token_file(&self) -> PathBuf { + self.creds_path.join("token") + } + + /// Persist `token` as the active bearer token. + /// + /// Best-effort: a write failure is logged, not returned. + pub async fn set(&self, token: &str) { + if let Err(e) = hm_common::fs::write_atomic( + self.token_file(), + token.as_bytes(), + hm_common::fs::Privacy::Private, + ) + .await + { + tracing::warn!(error = %e, "could not persist the cloud token"); + } + } + + /// The active bearer token: `HM_API_TOKEN` if set and non-empty, otherwise + /// the stored token. `None` when neither is present. + pub async fn get(&self) -> Option { + if let Ok(token) = std::env::var(TOKEN_ENV) + && !token.is_empty() + { + return Some(SecretString::from(token)); + } + let stored = tokio::fs::read_to_string(self.token_file()).await.ok()?; + let stored = stored.trim(); + (!stored.is_empty()).then(|| SecretString::from(stored.to_owned())) + } + + /// Remove the stored token. A missing token is a no-op. + /// + /// # Errors + /// The underlying [`std::io::Error`] if the token file exists but cannot be + /// removed. + pub async fn clear(&self) -> std::io::Result<()> { + match tokio::fs::remove_file(self.token_file()).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test setup and assertions")] +mod tests { + use super::*; + use rstest::rstest; + use secrecy::ExposeSecret as _; + + #[rstest] + #[tokio::test] + async fn set_get_clear_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let creds = CredsProvider::new(tmp.path()).await.unwrap(); + + assert!(creds.get().await.is_none()); + creds.set("hunter2").await; + assert_eq!(creds.get().await.unwrap().expose_secret(), "hunter2"); + creds.clear().await.unwrap(); + assert!(creds.get().await.is_none()); + // Clearing an already-absent token is a no-op, not an error. + creds.clear().await.unwrap(); + } +} diff --git a/crates/hm-exec/src/capabilities.rs b/crates/hm-core/src/exec/capabilities.rs similarity index 100% rename from crates/hm-exec/src/capabilities.rs rename to crates/hm-core/src/exec/capabilities.rs diff --git a/crates/hm-exec/src/cloud/backend.rs b/crates/hm-core/src/exec/cloud/backend.rs similarity index 96% rename from crates/hm-exec/src/cloud/backend.rs rename to crates/hm-core/src/exec/cloud/backend.rs index 792323b7..f998489b 100644 --- a/crates/hm-exec/src/cloud/backend.rs +++ b/crates/hm-core/src/exec/cloud/backend.rs @@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken; use std::path::Path; -use crate::{ +use crate::exec::{ BackendError, BackendHandle, BuildOutcome, BuildStatus, Capabilities, ExecutionBackend, Result, RunRequest, }; @@ -37,7 +37,7 @@ pub struct CloudBackend { api_base: String, /// Dashboard (SPA) base used to build the human-clickable watch URL. This /// is the `app.` host, NOT `api.` — a link built from `api_base` lands on - /// raw JSON. Resolved via [`hm_config::app_url`] at the call site. + /// raw JSON. Derived from the configured domain's `app_url` at the call site. app_base: String, org: String, } @@ -84,7 +84,7 @@ impl ExecutionBackend for CloudBackend { #[allow(clippy::too_many_lines)] async fn start(&self, req: RunRequest) -> Result { // Archive the worktree (fail fast as a setup error). - let source_tgz = crate::local::build_archive_bytes(&req.repo_root) + let source_tgz = crate::exec::local::build_archive_bytes(&req.repo_root) .map_err(|e| BackendError::Local(format!("archiving worktree: {e}")))?; // Guard the upload size BEFORE the POST: warn when large, fail fast over @@ -208,7 +208,7 @@ impl ExecutionBackend for CloudBackend { watch_url, }); } - r = crate::cloud::watch::watch_build(&client, &api_base, &org, &pipeline, number, tx) => { + r = crate::exec::cloud::watch::watch_build(&client, &api_base, &org, &pipeline, number, tx) => { r.map_err(|e| BackendError::LogStream(e.to_string()))? } }; @@ -277,7 +277,7 @@ fn guard_archive_size(archive_len: usize, repo_root: &Path) -> Result<()> { return Ok(()); } - let largest = crate::local::top_level_sizes(repo_root); + let largest = crate::exec::local::top_level_sizes(repo_root); let offenders: Vec<(String, u64)> = largest.into_iter().take(3).collect(); if bytes > ARCHIVE_CAP_BYTES { @@ -319,7 +319,7 @@ mod tests { #[rstest] fn watch_url_uses_app_host_and_pipelines_path() { - // Mirrors hm_config::app_url(DEFAULT_API_URL) -> https://app.harmont.dev. + // Mirrors BackendDomain::default().app_url() -> https://app.harmont.dev. assert_eq!( dashboard_build_url("https://app.harmont.dev", "acme", "web", 42), "https://app.harmont.dev/acme/pipelines/web/builds/42" diff --git a/crates/hm-exec/src/cloud/mod.rs b/crates/hm-core/src/exec/cloud/mod.rs similarity index 100% rename from crates/hm-exec/src/cloud/mod.rs rename to crates/hm-core/src/exec/cloud/mod.rs diff --git a/crates/hm-exec/src/cloud/watch.rs b/crates/hm-core/src/exec/cloud/watch.rs similarity index 99% rename from crates/hm-exec/src/cloud/watch.rs rename to crates/hm-core/src/exec/cloud/watch.rs index 3f2e71e7..31aea3c5 100644 --- a/crates/hm-exec/src/cloud/watch.rs +++ b/crates/hm-core/src/exec/cloud/watch.rs @@ -100,7 +100,7 @@ const fn job_logs_available(state: &OpenJobState) -> bool { /// Map a terminal build state to the process exit code the renderer and the /// `hm run` driver use. `passed` → 0, `canceled` → 130 (SIGINT-cancel, mirrors -/// [`crate::BuildStatus::Canceled`]), everything else (`failed`, and any +/// [`crate::exec::BuildStatus::Canceled`]), everything else (`failed`, and any /// unexpected state) → 1. Kept in lockstep with the backend's state→status map /// so a server-side cancel is never reported as a failure. pub(crate) fn exit_code_for_state(state: &str) -> i32 { diff --git a/crates/hm-exec/src/error.rs b/crates/hm-core/src/exec/error.rs similarity index 100% rename from crates/hm-exec/src/error.rs rename to crates/hm-core/src/exec/error.rs diff --git a/crates/hm-exec/src/local/archive.rs b/crates/hm-core/src/exec/local/archive.rs similarity index 98% rename from crates/hm-exec/src/local/archive.rs rename to crates/hm-core/src/exec/local/archive.rs index 7b7a90d6..628019fe 100644 --- a/crates/hm-exec/src/local/archive.rs +++ b/crates/hm-core/src/exec/local/archive.rs @@ -1,7 +1,7 @@ //! Per-run source archive store. //! //! On build start the orchestrator tar.gzs the user's working -//! directory once (via [`crate::local::build_archive_bytes`]) +//! directory once (via [`crate::exec::local::build_archive_bytes`]) //! and registers the bytes under an opaque `ArchiveId`. Runners receive //! that ID in their `ExecutorInput` and read the bytes back from this //! store. Archives are cached in memory keyed by ID for the duration of diff --git a/crates/hm-exec/src/local/backend.rs b/crates/hm-core/src/exec/local/backend.rs similarity index 88% rename from crates/hm-exec/src/local/backend.rs rename to crates/hm-core/src/exec/local/backend.rs index 0f3b3b9d..5d859eb5 100644 --- a/crates/hm-exec/src/local/backend.rs +++ b/crates/hm-core/src/exec/local/backend.rs @@ -13,8 +13,10 @@ use tokio_util::sync::CancellationToken; use hm_vm::{HmVm, ImageRegistry, VmBackend, VmConfig}; -use crate::local::{RunnerRegistry, VmRunner}; -use crate::{BackendError, BackendHandle, Capabilities, ExecutionBackend, Result, RunRequest}; +use crate::exec::local::{RunnerRegistry, VmRunner}; +use crate::exec::{ + BackendError, BackendHandle, Capabilities, ExecutionBackend, Result, RunRequest, +}; /// Number of cached snapshots the image registry retains before evicting /// least-recently-used entries. @@ -53,9 +55,11 @@ impl LocalBackend { let dirs = hm_common::dir_provider::DirProvider::new().ok_or_else(|| { BackendError::Local("cannot resolve the Harmont cache directory".into()) })?; - let registry = - ImageRegistry::open(&dirs.cache().join("hm").join("registry.db"), REGISTRY_CAPACITY) - .map_err(|e| BackendError::Local(format!("opening snapshot registry: {e:#}")))?; + let registry = ImageRegistry::open( + &dirs.cache().join("hm").join("registry.db"), + REGISTRY_CAPACITY, + ) + .map_err(|e| BackendError::Local(format!("opening snapshot registry: {e:#}")))?; let config = VmConfig { memory_mib: Some(8192), @@ -89,7 +93,7 @@ impl ExecutionBackend for LocalBackend { let keep_going = req.options.keep_going; let token = cancel.clone(); let join = tokio::spawn(async move { - crate::local::run( + crate::exec::local::run( req.plan.graph, req.repo_root, req.pipeline_slug, diff --git a/crates/hm-exec/src/local/cache.rs b/crates/hm-core/src/exec/local/cache.rs similarity index 95% rename from crates/hm-exec/src/local/cache.rs rename to crates/hm-core/src/exec/local/cache.rs index beb4de18..849506bd 100644 --- a/crates/hm-exec/src/local/cache.rs +++ b/crates/hm-core/src/exec/local/cache.rs @@ -80,10 +80,7 @@ mod tests { Some(Cache { policy: "none".into(), key: Some("abc".into()) }), None )] - fn stable_cache_tag_derivation( - #[case] cache: Option, - #[case] expected: Option, - ) { + fn stable_cache_tag_derivation(#[case] cache: Option, #[case] expected: Option) { let s = step(cache); assert_eq!(stable_cache_tag(&s), expected); } diff --git a/crates/hm-exec/src/local/events.rs b/crates/hm-core/src/exec/local/events.rs similarity index 100% rename from crates/hm-exec/src/local/events.rs rename to crates/hm-core/src/exec/local/events.rs diff --git a/crates/hm-exec/src/local/mod.rs b/crates/hm-core/src/exec/local/mod.rs similarity index 85% rename from crates/hm-exec/src/local/mod.rs rename to crates/hm-core/src/exec/local/mod.rs index a9286543..7b3e9ee8 100644 --- a/crates/hm-exec/src/local/mod.rs +++ b/crates/hm-core/src/exec/local/mod.rs @@ -13,8 +13,8 @@ mod scheduler; mod source; pub use backend::LocalBackend; -pub(crate) use runner::RunnerRegistry; // intra-crate: local/backend.rs via crate::local:: -pub(crate) use runner::vm::VmRunner; // intra-crate: local/backend.rs via crate::local:: +pub(crate) use runner::RunnerRegistry; // intra-crate: local/backend.rs via crate::exec::local:: +pub(crate) use runner::vm::VmRunner; // intra-crate: local/backend.rs via crate::exec::local:: pub(crate) use scheduler::chain_count; pub(crate) use scheduler::run; -pub(crate) use source::{build_archive_bytes, top_level_sizes}; // intra-crate: cloud/backend.rs via crate::local:: +pub(crate) use source::{build_archive_bytes, top_level_sizes}; // intra-crate: cloud/backend.rs via crate::exec::local:: diff --git a/crates/hm-exec/src/local/runner/mod.rs b/crates/hm-core/src/exec/local/runner/mod.rs similarity index 98% rename from crates/hm-exec/src/local/runner/mod.rs rename to crates/hm-core/src/exec/local/runner/mod.rs index 7df919a5..6153892b 100644 --- a/crates/hm-exec/src/local/runner/mod.rs +++ b/crates/hm-core/src/exec/local/runner/mod.rs @@ -13,8 +13,8 @@ use anyhow::Result; use hm_plugin_protocol::{ExecutorInput, SnapshotRef, StepResult}; use tokio_util::sync::CancellationToken; -use crate::local::archive::ArchiveStore; -use crate::local::events::EventBus; +use crate::exec::local::archive::ArchiveStore; +use crate::exec::local::events::EventBus; pub mod vm; diff --git a/crates/hm-exec/src/local/runner/vm.rs b/crates/hm-core/src/exec/local/runner/vm.rs similarity index 99% rename from crates/hm-exec/src/local/runner/vm.rs rename to crates/hm-core/src/exec/local/runner/vm.rs index 325634fc..72dd54e4 100644 --- a/crates/hm-exec/src/local/runner/vm.rs +++ b/crates/hm-core/src/exec/local/runner/vm.rs @@ -18,7 +18,7 @@ use hm_vm::{Action, CachingPolicy, HmVm, ImageSource, SnapshotId}; use uuid::Uuid; use super::{StepContext, StepRunner}; -use crate::local::events::EventBus; +use crate::exec::local::events::EventBus; /// Step runner that executes pipeline steps inside lightweight VMs /// via the [`HmVm`] orchestrator. diff --git a/crates/hm-exec/src/local/scheduler.rs b/crates/hm-core/src/exec/local/scheduler.rs similarity index 97% rename from crates/hm-exec/src/local/scheduler.rs rename to crates/hm-core/src/exec/local/scheduler.rs index 0b346cb6..f4f593dc 100644 --- a/crates/hm-exec/src/local/scheduler.rs +++ b/crates/hm-core/src/exec/local/scheduler.rs @@ -42,9 +42,9 @@ use uuid::Uuid; use hm_pipeline_ir::{DurationMs, EdgeKind, PipelineGraph, Transition}; -use crate::local::runner::{RunnerRegistry, StepContext}; -use crate::local::source::build_archive_bytes; -use crate::{BuildOutcome, BuildStatus, StepResultSummary, StepStatus}; +use crate::exec::local::runner::{RunnerRegistry, StepContext}; +use crate::exec::local::source::build_archive_bytes; +use crate::exec::{BuildOutcome, BuildStatus, StepResultSummary, StepStatus}; use super::archive::ArchiveStore; use super::cache; @@ -95,7 +95,7 @@ pub(crate) async fn run( tx: tokio::sync::mpsc::Sender, cancel: CancellationToken, keep_going: bool, -) -> crate::Result { +) -> crate::exec::Result { // Set up per-run state. let bus = EventBus::new(); let archives = Arc::new(ArchiveStore::new()); @@ -132,7 +132,7 @@ pub(crate) async fn run( // Build the source archive once. let archive_bytes = build_archive_bytes(&repo_root) .context("build source archive") - .map_err(|e| crate::BackendError::Local(format!("{e:#}")))?; + .map_err(|e| crate::exec::BackendError::Local(format!("{e:#}")))?; let archive_id = archives.register(archive_bytes); let run_ctx = StepContext { @@ -148,7 +148,7 @@ pub(crate) async fn run( let chain_info = compute_chain_info(dag); let order = toposort(dag.graph(), None).map_err(|c| { - crate::BackendError::Local(format!("pipeline graph has a cycle at {:?}", c.node_id())) + crate::exec::BackendError::Local(format!("pipeline graph has a cycle at {:?}", c.node_id())) })?; let started_at = chrono::Utc::now(); @@ -577,7 +577,7 @@ struct ChainInfo { /// Return the number of linear `BuildsIn` chains in the pipeline DAG. /// /// This is the authoritative implementation shared by the scheduler and the -/// [`crate::request`] plan-summarizer. See [`compute_chain_info`] for the +/// [`crate::exec::request`] plan-summarizer. See [`compute_chain_info`] for the /// full per-node mapping used during a live run. pub(crate) fn chain_count(dag: &Dag) -> usize { compute_chain_info(dag).chain_count diff --git a/crates/hm-exec/src/local/source.rs b/crates/hm-core/src/exec/local/source.rs similarity index 100% rename from crates/hm-exec/src/local/source.rs rename to crates/hm-core/src/exec/local/source.rs diff --git a/crates/hm-exec/src/lib.rs b/crates/hm-core/src/exec/mod.rs similarity index 97% rename from crates/hm-exec/src/lib.rs rename to crates/hm-core/src/exec/mod.rs index 4072613f..8352ffd2 100644 --- a/crates/hm-exec/src/lib.rs +++ b/crates/hm-core/src/exec/mod.rs @@ -22,9 +22,8 @@ //! //! # Auth //! -//! This crate never reads credentials from disk. The caller constructs a +//! These backends never read credentials from disk. The caller constructs a //! `HarmontClient` and injects it; `hm` owns credential loading. -#![forbid(unsafe_code)] mod error; pub use error::{BackendError, Result}; diff --git a/crates/hm-exec/src/outcome.rs b/crates/hm-core/src/exec/outcome.rs similarity index 100% rename from crates/hm-exec/src/outcome.rs rename to crates/hm-core/src/exec/outcome.rs diff --git a/crates/hm-exec/src/request.rs b/crates/hm-core/src/exec/request.rs similarity index 93% rename from crates/hm-exec/src/request.rs rename to crates/hm-core/src/exec/request.rs index a21a892b..1259151b 100644 --- a/crates/hm-exec/src/request.rs +++ b/crates/hm-core/src/exec/request.rs @@ -24,11 +24,11 @@ impl Plan { /// Parse verbatim IR JSON into a typed plan, retaining the original string. /// /// # Errors - /// Returns [`crate::BackendError::Rejected`] when `ir_json` is not valid + /// Returns [`crate::exec::BackendError::Rejected`] when `ir_json` is not valid /// pipeline IR JSON. - pub fn parse(ir_json: String) -> crate::Result { + pub fn parse(ir_json: String) -> crate::exec::Result { let graph: PipelineGraph = serde_json::from_slice(ir_json.as_bytes()).map_err(|e| { - crate::BackendError::Rejected { + crate::exec::BackendError::Rejected { code: "invalid_ir".into(), message: format!("could not parse pipeline IR: {e}"), } @@ -52,7 +52,7 @@ impl Plan { fn summarize(graph: &PipelineGraph) -> PlanSummary { PlanSummary { step_count: graph.node_count(), - chain_count: crate::local::chain_count(graph.dag()), + chain_count: crate::exec::local::chain_count(graph.dag()), default_runner: "docker".to_string(), } } @@ -82,7 +82,7 @@ pub struct RunOptions { pub keep_going: bool, } -/// All inputs needed to start a build on any [`crate::ExecutionBackend`]. +/// All inputs needed to start a build on any [`crate::exec::ExecutionBackend`]. #[derive(Debug, Clone)] pub struct RunRequest { pub plan: Plan, @@ -163,7 +163,7 @@ mod tests { #[rstest] fn invalid_ir_returns_rejected_error() { let err = Plan::parse("not json at all".to_string()).unwrap_err(); - assert!(matches!(err, crate::BackendError::Rejected { .. })); + assert!(matches!(err, crate::exec::BackendError::Rejected { .. })); let msg = err.to_string(); assert!(msg.contains("invalid_ir")); } diff --git a/crates/hm-core/src/lib.rs b/crates/hm-core/src/lib.rs new file mode 100644 index 00000000..746fedd0 --- /dev/null +++ b/crates/hm-core/src/lib.rs @@ -0,0 +1,13 @@ +//! Core of the `hm` CLI. +//! +//! - [`config`] — user/project config resolution. +//! - [`creds`] — Harmont cloud token storage. +//! - [`exec`] — the [`exec::ExecutionBackend`] trait and its backends. +//! - [`app_ctx`] — process-wide toolchain, directories, and user config. +//! - [`project_ctx`] — a workspace and its resolved config. + +pub mod app_ctx; +pub mod config; +pub mod creds; +pub mod exec; +pub mod project_ctx; diff --git a/crates/hm-core/src/project_ctx.rs b/crates/hm-core/src/project_ctx.rs new file mode 100644 index 00000000..68dc11e5 --- /dev/null +++ b/crates/hm-core/src/project_ctx.rs @@ -0,0 +1,161 @@ +//! A Harmont workspace and its resolved configuration. +//! +//! A project is a directory tree rooted at a directory containing `.hm/`. + +use std::path::{Path, PathBuf}; + +use crate::app_ctx::AppCtx; +use crate::config::ResolvedProjectConfig; +use crate::config::domain::ConfigLoadingError; +use crate::config::project::ProjectConfig; + +/// A workspace root and its config, resolved against an [`AppCtx`]. +#[derive(Debug, Clone)] +pub struct ProjectCtx<'app> { + app: &'app AppCtx, + path: PathBuf, + hm_dir: PathBuf, + config_path: PathBuf, + config: ResolvedProjectConfig, +} + +impl<'app> ProjectCtx<'app> { + /// Locate the project containing the app's working directory (walking up to + /// a directory with a `.hm/`) and resolve its config. + /// + /// Returns `None` when no ancestor contains a `.hm/` directory. + /// + /// # Errors + /// [`ConfigLoadingError`] if the project config file is present but + /// unreadable or malformed. + pub async fn discover(app: &'app AppCtx) -> Result, ConfigLoadingError> { + match Self::find_root(app.cwd()) { + Some(root) => Ok(Some(Self::at(app, root).await?)), + None => Ok(None), + } + } + + /// Wrap the workspace rooted at `path`, resolving its config against the + /// app's user config. A missing project config file resolves to defaults. + /// + /// # Errors + /// [`ConfigLoadingError`] if the project config file is present but + /// unreadable or malformed. + pub async fn at(app: &'app AppCtx, path: PathBuf) -> Result { + let hm_dir = path.join(".hm"); + let config_path = hm_dir.join("config.toml"); + let project = Self::load_config(&config_path).await?; + let user = app.user_config().cloned().unwrap_or_default(); + let config = ResolvedProjectConfig::from_user_project(&user, &project); + Ok(Self { + app, + path, + hm_dir, + config_path, + config, + }) + } + + /// The application context this workspace resolved against. + #[must_use] + pub const fn app(&self) -> &'app AppCtx { + self.app + } + + /// The workspace root (the directory containing `.hm/`). + #[must_use] + pub fn path(&self) -> &Path { + self.path.as_path() + } + + /// The `.hm/` directory path. + #[must_use] + pub fn hm_dir(&self) -> &Path { + self.hm_dir.as_path() + } + + /// The project config file path (`.hm/config.toml`). + #[must_use] + pub fn config_path(&self) -> &Path { + self.config_path.as_path() + } + + /// The resolved configuration for this workspace. + #[must_use] + pub const fn config(&self) -> &ResolvedProjectConfig { + &self.config + } + + /// Walk up from `start` to the first directory containing `.hm/`. + /// + /// Returns the directory *containing* `.hm/`, or `None` at the filesystem + /// root. + fn find_root(start: &Path) -> Option { + let mut current = start; + loop { + if current.join(".hm").is_dir() { + return Some(current.to_path_buf()); + } + current = current.parent()?; + } + } + + /// Read a project config, treating a missing file as defaults. + async fn load_config(path: &Path) -> Result { + match tokio::fs::read_to_string(path).await { + Ok(contents) => Ok(toml::from_str(&contents)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ProjectConfig::default()), + Err(e) => Err(e.into()), + } + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::print_stderr, + reason = "test setup, assertions, and skip diagnostics" +)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + fn find_project_root_walks_up() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(tmp.path().join(".hm")).unwrap(); + let nested = tmp.path().join("src").join("deep"); + std::fs::create_dir_all(&nested).unwrap(); + assert_eq!( + ProjectCtx::find_root(&nested), + Some(tmp.path().to_path_buf()) + ); + } + + #[rstest] + fn find_project_root_none_when_absent() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(ProjectCtx::find_root(tmp.path()), None); + } + + #[rstest] + #[tokio::test] + async fn at_exposes_paths_for_a_workspace() { + let Ok(app) = AppCtx::init().await else { + eprintln!("skipping: toolchain unavailable"); + return; + }; + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir(tmp.path().join(".hm")).unwrap(); + + let ctx = ProjectCtx::at(&app, tmp.path().to_path_buf()) + .await + .unwrap(); + assert_eq!(ctx.path(), tmp.path()); + assert_eq!(ctx.hm_dir(), tmp.path().join(".hm")); + assert_eq!( + ctx.config_path(), + tmp.path().join(".hm").join("config.toml") + ); + } +} diff --git a/crates/hm-exec/tests/backend_contract.rs b/crates/hm-core/tests/backend_contract.rs similarity index 97% rename from crates/hm-exec/tests/backend_contract.rs rename to crates/hm-core/tests/backend_contract.rs index 1234d60b..bd120b1c 100644 --- a/crates/hm-exec/tests/backend_contract.rs +++ b/crates/hm-core/tests/backend_contract.rs @@ -8,10 +8,10 @@ )] use futures::StreamExt; -use rstest::rstest; -use hm_exec::*; +use hm_core::exec::*; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; use hm_plugin_protocol::ir::DurationMs; +use rstest::rstest; use tokio_util::sync::CancellationToken; #[derive(Debug)] @@ -102,7 +102,7 @@ impl hm_vm::VmBackend for NoopVmBackend { #[rstest] #[tokio::test] async fn local_backend_reports_capabilities() { - let b = hm_exec::LocalBackend::new( + let b = hm_core::exec::LocalBackend::new( std::num::NonZeroUsize::new(4).unwrap(), std::sync::Arc::new(NoopVmBackend), ); @@ -115,7 +115,7 @@ async fn local_backend_reports_capabilities() { #[rstest] fn cloud_backend_capabilities() { // `with_base_url` does no network IO, so this is safe with a dummy token. - let c = hm_exec::CloudBackend::new( + let c = hm_core::exec::CloudBackend::new( harmont_cloud::HarmontClient::with_base_url("t", "http://localhost"), "http://localhost".into(), "http://localhost".into(), diff --git a/crates/hm-dsl-engine/Cargo.toml b/crates/hm-dsl-engine/Cargo.toml index 261b7651..e76ec71c 100644 --- a/crates/hm-dsl-engine/Cargo.toml +++ b/crates/hm-dsl-engine/Cargo.toml @@ -12,7 +12,8 @@ categories = ["command-line-utilities"] path = "src/lib.rs" [dependencies] -hm-common = { workspace = true, features = ["sys-runtime"] } +hm-common = { workspace = true } +hm-core = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } derive_more = { workspace = true } diff --git a/crates/hm-dsl-engine/src/python_engine.rs b/crates/hm-dsl-engine/src/python_engine.rs index b8e2dea9..d27c6204 100644 --- a/crates/hm-dsl-engine/src/python_engine.rs +++ b/crates/hm-dsl-engine/src/python_engine.rs @@ -2,8 +2,8 @@ use std::path::Path; use anyhow::{Context, Result}; use async_trait::async_trait; -use hm_common::sys_runtime::SysRuntime; use hm_common::process::CapturedStreams as _; +use hm_core::app_ctx::AppCtx; use tracing::debug; use crate::bundled_sources; @@ -59,15 +59,16 @@ if match is None: print(json.dumps(match['definition'])) "; -#[derive(Debug, Default)] -pub struct SubprocessPythonEngine; +#[derive(Debug)] +pub struct SubprocessPythonEngine<'app> { + app: &'app AppCtx, +} -impl SubprocessPythonEngine { - /// Create the engine. It runs `python3` through [`SysRuntime::python`], so - /// [`SysRuntime::init`] must have been called first. +impl<'app> SubprocessPythonEngine<'app> { + /// Create the engine bound to `app`, whose resolved `python3` it runs. #[must_use] - pub const fn new() -> Self { - Self + pub const fn new(app: &'app AppCtx) -> Self { + Self { app } } async fn run_script( @@ -80,7 +81,7 @@ impl SubprocessPythonEngine { let harmont_pkg = tmp.path().join("harmont"); bundled_sources::extract_to(&bundled_sources::HARMONT_PY, &harmont_pkg)?; - let mut py = SysRuntime::python().program(script); + let mut py = self.app.python().program(script); py.args(extra_args).current_dir(project_dir); py.pythonpath(tmp.path()); @@ -91,7 +92,7 @@ impl SubprocessPythonEngine { } #[async_trait] -impl DslEngine for SubprocessPythonEngine { +impl DslEngine for SubprocessPythonEngine<'_> { async fn list_pipelines(&self, project_dir: &Path) -> Result> { let stdout = self .run_script(project_dir, LIST_PIPELINES_SCRIPT, &[]) diff --git a/crates/hm-dsl-engine/tests/python_engine_test.rs b/crates/hm-dsl-engine/tests/python_engine_test.rs index ca8ba081..38a63e48 100644 --- a/crates/hm-dsl-engine/tests/python_engine_test.rs +++ b/crates/hm-dsl-engine/tests/python_engine_test.rs @@ -10,10 +10,10 @@ use hm_dsl_engine::DslEngine; #[tokio::test] async fn python_roundtrip() { // Skip if the toolchain (python3 + git) is unavailable. - if hm_common::sys_runtime::SysRuntime::init().is_err() { + let Ok(app) = hm_core::app_ctx::AppCtx::init().await else { eprintln!("skipping: build toolchain unavailable"); return; - } + }; let dir = tempfile::tempdir().unwrap(); let harmont = dir.path().join(".hm"); @@ -31,7 +31,7 @@ def ci() -> hm.Step: hm_dsl_engine::detect::check_python(dir.path()).unwrap(); - let engine = hm_dsl_engine::SubprocessPythonEngine::new(); + let engine = hm_dsl_engine::SubprocessPythonEngine::new(&app); let metas = engine.list_pipelines(dir.path()).await.unwrap(); assert_eq!(metas.len(), 1); assert_eq!(metas[0].slug, "ci"); @@ -43,10 +43,10 @@ def ci() -> hm.Step: #[tokio::test] async fn python_registry_json_carries_triggers_and_allow_manual() { - if hm_common::sys_runtime::SysRuntime::init().is_err() { + let Ok(app) = hm_core::app_ctx::AppCtx::init().await else { eprintln!("skipping: build toolchain unavailable"); return; - } + }; let dir = tempfile::tempdir().unwrap(); let harmont = dir.path().join(".hm"); @@ -62,7 +62,7 @@ def ci() -> hm.Step: ) .unwrap(); - let engine = hm_dsl_engine::SubprocessPythonEngine::new(); + let engine = hm_dsl_engine::SubprocessPythonEngine::new(&app); let json = engine.registry_json(dir.path()).await.unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); diff --git a/crates/hm-pipeline-ir/src/duration.rs b/crates/hm-pipeline-ir/src/duration.rs index 8af579ec..38b0401c 100644 --- a/crates/hm-pipeline-ir/src/duration.rs +++ b/crates/hm-pipeline-ir/src/duration.rs @@ -57,7 +57,10 @@ mod tests { #[case::one(1, 1)] #[case::typical(1_500, 1_500)] fn from_duration_preserves_millis(#[case] millis: u64, #[case] expected: u64) { - assert_eq!(DurationMs::from(Duration::from_millis(millis)).get(), expected); + assert_eq!( + DurationMs::from(Duration::from_millis(millis)).get(), + expected + ); } #[rstest] diff --git a/crates/hm-plugin-cloud/Cargo.toml b/crates/hm-plugin-cloud/Cargo.toml index 322166e9..1d700f94 100644 --- a/crates/hm-plugin-cloud/Cargo.toml +++ b/crates/hm-plugin-cloud/Cargo.toml @@ -12,11 +12,11 @@ categories = ["command-line-utilities"] path = "src/lib.rs" [dependencies] +secrecy = "0.10" harmont-cloud = { workspace = true } harmont-cloud-raw = { workspace = true } -hm-exec = { workspace = true } +hm-core = { workspace = true } hm-render = { workspace = true } -hm-config = { workspace = true } hm-plugin-protocol = { workspace = true } serde_json = { workspace = true } chrono = { workspace = true } diff --git a/crates/hm-plugin-cloud/src/auth/login.rs b/crates/hm-plugin-cloud/src/auth/login.rs index d5bc7b85..22a66d99 100644 --- a/crates/hm-plugin-cloud/src/auth/login.rs +++ b/crates/hm-plugin-cloud/src/auth/login.rs @@ -15,12 +15,18 @@ use std::time::Duration; use anyhow::{Result, bail}; use harmont_cloud::{HarmontClient, HarmontError}; +use hm_core::app_ctx::AppCtx; use crate::settings; -pub(crate) async fn run(env: &BTreeMap, paste: bool) -> Result<()> { - let (client, api) = settings::anon_client()?; - let app = app_url(&api, env); +pub(crate) async fn run( + env: &BTreeMap, + paste: bool, + app_ctx: &AppCtx, +) -> Result<()> { + let (client, domain) = settings::anon_client(app_ctx); + let api = domain.api_url(); + let app = domain.app_url(); let token = if paste { login_paste(env, &client, &app).await? @@ -28,7 +34,7 @@ pub(crate) async fn run(env: &BTreeMap, paste: bool) -> Result<( login_loopback(&client, &app).await? }; - hm_config::creds::set_cloud_token(&api, &token).await; + app_ctx.creds().set(&token).await; // Confirm by reading back the authenticated user. let authed = HarmontClient::with_base_url(token, &api); @@ -137,14 +143,6 @@ async fn login_paste( Ok(client.redeem_code(&code).await?) } -/// Derive the SPA (app) base URL from the API base. -/// -/// Thin wrapper over the shared [`hm_config::app_url`] helper, sourcing the -/// override from the `HM_APP_URL` env var. -fn app_url(api: &str, env: &BTreeMap) -> String { - hm_config::app_url(api, env.get("HM_APP_URL").map(String::as_str)) -} - /// A URL-safe random nonce for the loopback handoff. fn random_nonce() -> String { use base64::Engine; @@ -158,29 +156,6 @@ mod tests { use super::*; use rstest::rstest; - fn env(pairs: &[(&str, &str)]) -> BTreeMap { - pairs - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect() - } - - #[rstest] - #[case::prod_api_to_app("https://api.harmont.dev", &[], "https://app.harmont.dev")] - #[case::env_override_wins( - "https://api.harmont.dev", - &[("HM_APP_URL", "http://localhost:5173/")], - "http://localhost:5173" - )] - #[case::unmapped_fallback("http://localhost:4000", &[], "http://localhost:4000")] - fn app_url_derives_app_host( - #[case] api: &str, - #[case] env_pairs: &[(&str, &str)], - #[case] expected: &str, - ) { - assert_eq!(app_url(api, &env(env_pairs)), expected); - } - #[rstest] fn nonces_are_distinct() { assert_ne!(random_nonce(), random_nonce()); diff --git a/crates/hm-plugin-cloud/src/auth/logout.rs b/crates/hm-plugin-cloud/src/auth/logout.rs index 9461dc6b..033b4a92 100644 --- a/crates/hm-plugin-cloud/src/auth/logout.rs +++ b/crates/hm-plugin-cloud/src/auth/logout.rs @@ -3,12 +3,10 @@ use std::collections::BTreeMap; use anyhow::Result; +use hm_core::app_ctx::AppCtx; -use crate::settings; - -pub(crate) async fn run(_env: &BTreeMap) -> Result<()> { - let (_client, api) = settings::anon_client()?; - hm_config::creds::forget_cloud_token(&api).await; - tracing::info!("logged out of {api}"); +pub(crate) async fn run(_env: &BTreeMap, app: &AppCtx) -> Result<()> { + app.creds().clear().await?; + tracing::info!("logged out"); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/auth/whoami.rs b/crates/hm-plugin-cloud/src/auth/whoami.rs index 382539b8..1548e419 100644 --- a/crates/hm-plugin-cloud/src/auth/whoami.rs +++ b/crates/hm-plugin-cloud/src/auth/whoami.rs @@ -3,11 +3,12 @@ use std::collections::BTreeMap; use anyhow::Result; +use hm_core::app_ctx::AppCtx; use crate::settings; -pub(crate) async fn run(_env: &BTreeMap) -> Result<()> { - let (client, _ctx) = settings::client()?; +pub(crate) async fn run(_env: &BTreeMap, app: &AppCtx) -> Result<()> { + let (client, _ctx) = settings::client(app).await?; let me = client .raw() .get_current_user() diff --git a/crates/hm-plugin-cloud/src/cli.rs b/crates/hm-plugin-cloud/src/cli.rs index a6ccbf10..b7e11cb0 100644 --- a/crates/hm-plugin-cloud/src/cli.rs +++ b/crates/hm-plugin-cloud/src/cli.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use anyhow::Result; use clap::Subcommand; +use hm_core::app_ctx::AppCtx; use crate::{auth, verbs}; @@ -158,17 +159,21 @@ pub enum BillingCommand { /// /// Returns an error only if dispatch itself fails; a verb's own runtime /// failure is logged and mapped to a non-zero exit code. -pub async fn dispatch_command(command: CloudCommand, env: BTreeMap) -> Result { +pub async fn dispatch_command( + command: CloudCommand, + env: BTreeMap, + app: &AppCtx, +) -> Result { let result = match command { - CloudCommand::Login { paste } => auth::login::run(&env, paste).await, - CloudCommand::Logout => auth::logout::run(&env).await, - CloudCommand::Whoami => auth::whoami::run(&env).await, - CloudCommand::Org(cmd) => verbs::org::run(&env, cmd).await, - CloudCommand::Pipeline(cmd) => verbs::pipeline::run(&env, cmd).await, - CloudCommand::Build(cmd) => verbs::build::run(&env, cmd).await, - CloudCommand::Job(cmd) => verbs::job::run(&env, cmd).await, - CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd).await, - CloudCommand::Run(args) => verbs::run::run(&env, args).await, + CloudCommand::Login { paste } => auth::login::run(&env, paste, app).await, + CloudCommand::Logout => auth::logout::run(&env, app).await, + CloudCommand::Whoami => auth::whoami::run(&env, app).await, + CloudCommand::Org(cmd) => verbs::org::run(&env, cmd, app).await, + CloudCommand::Pipeline(cmd) => verbs::pipeline::run(&env, cmd, app).await, + CloudCommand::Build(cmd) => verbs::build::run(&env, cmd, app).await, + CloudCommand::Job(cmd) => verbs::job::run(&env, cmd, app).await, + CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd, app).await, + CloudCommand::Run(args) => verbs::run::run(&env, args, app).await, }; match result { Ok(()) => Ok(ExitCode::Success.into()), diff --git a/crates/hm-plugin-cloud/src/lib.rs b/crates/hm-plugin-cloud/src/lib.rs index 9af9d239..979e7f7e 100644 --- a/crates/hm-plugin-cloud/src/lib.rs +++ b/crates/hm-plugin-cloud/src/lib.rs @@ -17,7 +17,7 @@ mod verbs; /// /// Returns an error if the browser cannot be opened, the login times /// out, or the token cannot be persisted. -pub async fn login_interactive() -> anyhow::Result<()> { +pub async fn login_interactive(app: &hm_core::app_ctx::AppCtx) -> anyhow::Result<()> { let env: std::collections::BTreeMap = std::env::vars().collect(); - auth::login::run(&env, false).await + auth::login::run(&env, false, app).await } diff --git a/crates/hm-plugin-cloud/src/settings.rs b/crates/hm-plugin-cloud/src/settings.rs index 036a48ab..d7a36759 100644 --- a/crates/hm-plugin-cloud/src/settings.rs +++ b/crates/hm-plugin-cloud/src/settings.rs @@ -1,24 +1,22 @@ //! Cloud client builders for the `hm cloud` verbs. //! -//! Config and credentials are owned by the shared [`hm_config`] crate: -//! -//! - layered config (user `~/.config/hm/config.toml` + project -//! `.hm/config.toml` + `HM_*` env) supplies the API base -//! (`cloud.api_url`) and the active org (`cloud.org`); -//! - bearer tokens live in `hm_config::creds`, keyed by API base, with -//! `HM_API_TOKEN` taking precedence. -//! -//! This module only assembles an SDK client from that config; it does not own -//! any config or credential storage of its own. +//! Tokens come from [`hm_core::creds`] (`HM_API_TOKEN` wins); the domain and org +//! come from the user config. use anyhow::{Context, Result}; use harmont_cloud::HarmontClient; +use hm_core::app_ctx::AppCtx; +use hm_core::config::domain::{BackendConfig, BackendDomain}; +use hm_core::config::user::UserCloudConfig; +use secrecy::ExposeSecret as _; /// Resolved cloud context for the `hm cloud` verbs. #[derive(Debug, Clone)] pub struct ResolvedCtx { /// Effective API base URL. pub api: String, + /// Base Harmont domain the API/dashboard hosts derive from. + pub domain: BackendDomain, /// Configured organization slug, if set. pub org: Option, } @@ -31,41 +29,52 @@ impl ResolvedCtx { /// Returns an error if no organization is configured. pub fn org(&self) -> Result { self.org.clone().context( - "no organization — set `[cloud] org = \"…\"` in ~/.config/hm/config.toml (or .hm/config.toml), or run `hm cloud org switch `") + "no organization — run `hm cloud org switch `, or set one in ~/.hm/config.toml", + ) + } +} + +/// The user's cloud settings, when the user config selects a cloud backend. +fn user_cloud(app: &AppCtx) -> Option<&UserCloudConfig> { + match app.user_config().and_then(|u| u.backend.as_ref()) { + Some(BackendConfig::Cloud(cloud)) => Some(cloud), + _ => None, } } -/// An authenticated cloud client built from the layered config + stored token. +/// The cloud domain from the user config, or the default. +#[must_use] +pub fn domain(app: &AppCtx) -> BackendDomain { + user_cloud(app) + .and_then(|c| c.domain.clone()) + .unwrap_or_default() +} + +/// An authenticated cloud client built from the user config + stored token. /// /// Fails fast with a clear message when no token is present. /// /// # Errors /// -/// Returns an error if config can't be loaded or no token is available. -pub fn client() -> Result<(HarmontClient, ResolvedCtx)> { - let cfg = hm_config::Config::load(None).context("loading config")?; - let api = cfg.cloud.api_url.clone(); - let token = hm_config::creds::cloud_token(&api) +/// Returns an error if no token is available. +pub async fn client(app: &AppCtx) -> Result<(HarmontClient, ResolvedCtx)> { + let domain = domain(app); + let api = domain.api_url(); + let token = app + .creds() + .get() + .await .context("not logged in — run `hm cloud login` or set HM_API_TOKEN")?; - let client = HarmontClient::with_base_url(token, &api); - Ok(( - client, - ResolvedCtx { - api, - org: cfg.cloud.org, - }, - )) + let client = HarmontClient::with_base_url(token.expose_secret().to_owned(), &api); + let org = user_cloud(app).and_then(|c| c.org.clone()); + Ok((client, ResolvedCtx { api, domain, org })) } -/// An anonymous client (for the login flow) + the resolved API base. -/// -/// # Errors -/// -/// Returns an error if config can't be loaded. -pub fn anon_client() -> Result<(HarmontClient, String)> { - let cfg = hm_config::Config::load(None).context("loading config")?; - let api = cfg.cloud.api_url; - Ok((HarmontClient::anonymous(&api), api)) +/// An anonymous client (for the login flow) + the resolved cloud domain. +#[must_use] +pub fn anon_client(app: &AppCtx) -> (HarmontClient, BackendDomain) { + let domain = domain(app); + (HarmontClient::anonymous(domain.api_url()), domain) } /// Render preferences for cloud commands that stream through `hm-render`. diff --git a/crates/hm-plugin-cloud/src/verbs/billing.rs b/crates/hm-plugin-cloud/src/verbs/billing.rs index 2f1ba7e7..badea99b 100644 --- a/crates/hm-plugin-cloud/src/verbs/billing.rs +++ b/crates/hm-plugin-cloud/src/verbs/billing.rs @@ -7,6 +7,7 @@ use harmont_cloud::HarmontClient; use crate::cli::BillingCommand; use crate::settings; +use hm_core::app_ctx::AppCtx; /// Convert an integer cent amount to dollars for display. #[allow( @@ -17,8 +18,12 @@ fn cents_to_dollars(cents: i64) -> f64 { cents as f64 / 100.0 } -pub(crate) async fn run(_env: &BTreeMap, cmd: BillingCommand) -> Result<()> { - let (client, ctx) = settings::client()?; +pub(crate) async fn run( + _env: &BTreeMap, + cmd: BillingCommand, + app: &AppCtx, +) -> Result<()> { + let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; match cmd { diff --git a/crates/hm-plugin-cloud/src/verbs/build.rs b/crates/hm-plugin-cloud/src/verbs/build.rs index 80756ce7..856006e1 100644 --- a/crates/hm-plugin-cloud/src/verbs/build.rs +++ b/crates/hm-plugin-cloud/src/verbs/build.rs @@ -7,10 +7,15 @@ use harmont_cloud::HarmontClient; use crate::cli::BuildCommand; use crate::settings; -use hm_exec::cloud::watch::watch_build; +use hm_core::app_ctx::AppCtx; +use hm_core::exec::cloud::watch::watch_build; -pub(crate) async fn run(_env: &BTreeMap, cmd: BuildCommand) -> Result<()> { - let (client, ctx) = settings::client()?; +pub(crate) async fn run( + _env: &BTreeMap, + cmd: BuildCommand, + app: &AppCtx, +) -> Result<()> { + let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; match cmd { diff --git a/crates/hm-plugin-cloud/src/verbs/job.rs b/crates/hm-plugin-cloud/src/verbs/job.rs index a1a555de..379d1777 100644 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ b/crates/hm-plugin-cloud/src/verbs/job.rs @@ -11,10 +11,15 @@ use uuid::Uuid; use crate::cli::JobCommand; use crate::settings; -use hm_exec::cloud::watch::stream_job_logs_as_events; +use hm_core::app_ctx::AppCtx; +use hm_core::exec::cloud::watch::stream_job_logs_as_events; -pub(crate) async fn run(_env: &BTreeMap, cmd: JobCommand) -> Result<()> { - let (client, ctx) = settings::client()?; +pub(crate) async fn run( + _env: &BTreeMap, + cmd: JobCommand, + app: &AppCtx, +) -> Result<()> { + let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; match cmd { diff --git a/crates/hm-plugin-cloud/src/verbs/org.rs b/crates/hm-plugin-cloud/src/verbs/org.rs index 00aefbbf..67b090f2 100644 --- a/crates/hm-plugin-cloud/src/verbs/org.rs +++ b/crates/hm-plugin-cloud/src/verbs/org.rs @@ -3,19 +3,26 @@ use std::collections::BTreeMap; use anyhow::{Context, Result}; +use hm_core::app_ctx::AppCtx; +use hm_core::config::domain::BackendConfig; +use hm_core::config::user::UserCloudConfig; use crate::cli::OrgCommand; use crate::settings; -pub(crate) async fn run(_env: &BTreeMap, cmd: OrgCommand) -> Result<()> { - let (client, _ctx) = settings::client()?; +pub(crate) async fn run( + _env: &BTreeMap, + cmd: OrgCommand, + app: &AppCtx, +) -> Result<()> { + let (client, _ctx) = settings::client(app).await?; match cmd { - OrgCommand::Switch { slug } => switch(&client, &slug).await, + OrgCommand::Switch { slug } => switch(&client, &slug, app).await, } } -async fn switch(client: &harmont_cloud::HarmontClient, slug: &str) -> Result<()> { +async fn switch(client: &harmont_cloud::HarmontClient, slug: &str, app: &AppCtx) -> Result<()> { let orgs = client .raw() .list_organizations(None, None) @@ -27,9 +34,21 @@ async fn switch(client: &harmont_cloud::HarmontClient, slug: &str) -> Result<()> .iter() .find(|o| o.slug == slug) .ok_or_else(|| anyhow::anyhow!("no organization with slug '{slug}'"))?; - let mut cfg = hm_config::Config::load(None)?; - cfg.cloud.org = Some(found.slug.clone()); - cfg.save_user().await.context("saving config")?; + + // Set the org on the user config's cloud backend, preserving the domain. + let mut user = app.user_config().cloned().unwrap_or_default(); + let cloud = match user.backend { + Some(BackendConfig::Cloud(cloud)) => cloud, + _ => UserCloudConfig::default(), + }; + user.backend = Some(BackendConfig::Cloud(UserCloudConfig { + org: Some(found.slug.clone()), + ..cloud + })); + user.save(&app.user_config_path()) + .await + .context("saving config")?; + tracing::info!("active organization: {} ({})", found.name, found.slug); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/verbs/pipeline.rs b/crates/hm-plugin-cloud/src/verbs/pipeline.rs index bfa36423..7ce476a0 100644 --- a/crates/hm-plugin-cloud/src/verbs/pipeline.rs +++ b/crates/hm-plugin-cloud/src/verbs/pipeline.rs @@ -7,9 +7,14 @@ use harmont_cloud::HarmontClient; use crate::cli::PipelineCommand; use crate::settings; +use hm_core::app_ctx::AppCtx; -pub(crate) async fn run(_env: &BTreeMap, cmd: PipelineCommand) -> Result<()> { - let (client, ctx) = settings::client()?; +pub(crate) async fn run( + _env: &BTreeMap, + cmd: PipelineCommand, + app: &AppCtx, +) -> Result<()> { + let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; match cmd { diff --git a/crates/hm-plugin-cloud/src/verbs/run.rs b/crates/hm-plugin-cloud/src/verbs/run.rs index 1958d51c..82b92c3a 100644 --- a/crates/hm-plugin-cloud/src/verbs/run.rs +++ b/crates/hm-plugin-cloud/src/verbs/run.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use anyhow::Result; use clap::Parser; use harmont_cloud::builds::NewBuild; +use hm_core::app_ctx::AppCtx; use crate::settings; @@ -40,8 +41,8 @@ pub struct RunArgs { pub no_watch: bool, } -pub(crate) async fn run(env: &BTreeMap, args: RunArgs) -> Result<()> { - let (client, ctx) = settings::client()?; +pub(crate) async fn run(env: &BTreeMap, args: RunArgs, app: &AppCtx) -> Result<()> { + let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; let plan_path = args.plan_file.as_deref().unwrap_or("plan.json"); @@ -79,6 +80,7 @@ pub(crate) async fn run(env: &BTreeMap, args: RunArgs) -> Result pipeline: args.pipeline.clone(), number: build.number, }, + app, ) .await } diff --git a/crates/hm-plugin-protocol/src/events.rs b/crates/hm-plugin-protocol/src/events.rs index 4ab57cb4..71ba22b6 100644 --- a/crates/hm-plugin-protocol/src/events.rs +++ b/crates/hm-plugin-protocol/src/events.rs @@ -14,9 +14,7 @@ pub enum StdStream { Stderr, } -#[derive( - Debug, Clone, PartialEq, Eq, Serialize, Deserialize, derive_more::IsVariant, -)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, derive_more::IsVariant)] #[serde(tag = "kind", rename_all = "snake_case")] #[non_exhaustive] pub enum BuildEvent { @@ -82,7 +80,7 @@ pub enum BuildEvent { }, } -/// Stable identity for a build, shared by `BuildAccepted` and `hm_exec::BuildOutcome`. +/// Stable identity for a build, shared by `BuildAccepted` and `hm_core::exec::BuildOutcome`. /// Local builds have a `run_id` only; cloud builds also have `number`/`org`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BuildRef { diff --git a/crates/hm-render/src/lib.rs b/crates/hm-render/src/lib.rs index 6ae7a7b7..da22dada 100644 --- a/crates/hm-render/src/lib.rs +++ b/crates/hm-render/src/lib.rs @@ -152,7 +152,7 @@ pub async fn drive( /// Drive a renderer from a [`Stream`] of events until it ends or a /// `BuildEnd` is seen. /// -/// The `hm-exec` backend handle yields events as a +/// The `hm-core::exec` backend handle yields events as a /// `BoxStream<'static, BuildEvent>`; this function is the counterpart to /// [`drive`] for that case. /// diff --git a/crates/hm/CLAUDE.md b/crates/hm/CLAUDE.md index eb98dd46..935f6536 100644 --- a/crates/hm/CLAUDE.md +++ b/crates/hm/CLAUDE.md @@ -1,6 +1,6 @@ -## Execution backends (`hm-exec`) +## Execution backends (`hm-core::exec`) -Local and cloud execution both go through `crates/hm-exec/` — the +Local and cloud execution both go through `crates/hm-core/src/exec/` — the `ExecutionBackend` trait + two impls: - `LocalBackend` — runs the whole build in-process via a DAG scheduler, @@ -22,10 +22,10 @@ BackendHandle`, splits the handle via `into_parts()` into an (Ctrl-C + `wait()`). Auth is injected: this crate takes a pre-built `HarmontClient`; it never reads credentials from disk. -### Per-step mechanism (`hm-exec`'s `local` module + `hm-vm`) +### Per-step mechanism (`hm-core::exec`'s `local` module + `hm-vm`) The whole-build `ExecutionBackend` and the per-step `hm_vm::VmBackend` -are two separate traits. Inside `LocalBackend`, `crates/hm-exec/src/local/`: +are two separate traits. Inside `LocalBackend`, `crates/hm-core/src/exec/local/`: - Builds the source archive once into memory (`source.rs` + `archive.rs`). - Walks the DAG in `scheduler.rs`, resolving each step's `runner` field against a `RunnerRegistry` (default: `VmRunner`). diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index 4187392a..77510047 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -23,6 +23,7 @@ name = "hm" path = "src/main.rs" [dependencies] +secrecy = "0.10" clap = { version = "4", features = ["derive", "env", "color", "suggestions", "wrap_help"] } tokio = { version = "1", features = ["full"] } tokio-util = { workspace = true } @@ -59,14 +60,13 @@ futures = "0.3" futures-util = "0.3" hm-pipeline-ir = { workspace = true } hm-plugin-protocol = { workspace = true } -hm-common = { workspace = true, features = ["sys-runtime"] } -hm-config = { workspace = true } +hm-common = { workspace = true } +hm-core = { workspace = true } hm-plugin-cloud = { workspace = true } harmont-cloud = { workspace = true } hm-vm = { workspace = true, features = ["docker-backend"] } hm-dsl-engine = { workspace = true } hm-render = { workspace = true } -hm-exec = { workspace = true } async-trait = { workspace = true } semver = { workspace = true } smart-default = { workspace = true } diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index 7ea088ff..5932bd59 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -100,22 +100,23 @@ pub struct CacheRestoreArgs { /// # Errors /// /// Returns an error if the dispatched handler fails. -pub async fn dispatch(command: Command, ctx: RunContext) -> Result { +pub async fn dispatch(command: Command, ctx: RunContext<'_>) -> Result { + let app = ctx.app; match command { - Command::Init(args) => crate::commands::init::handle(args).await.map(|()| 0), + Command::Init(args) => crate::commands::init::handle(args, app).await.map(|()| 0), Command::Run(args) => crate::commands::run::handle(args, ctx).await, - Command::Pipelines(args) => crate::cli::pipelines::run(args).await.map(|()| 0), - Command::Render(args) => crate::cli::render::run(args).await.map(|()| 0), + Command::Pipelines(args) => crate::cli::pipelines::run(args, app).await.map(|()| 0), + Command::Render(args) => crate::cli::render::run(args, app).await.map(|()| 0), Command::Cache(cmd) => match cmd { CacheCommand::Save(args) => crate::commands::cache::handle_save(&args.dir).await, CacheCommand::Restore(args) => crate::commands::cache::handle_restore(&args.dir).await, - CacheCommand::Clean => crate::commands::cache::handle_clean().await, + CacheCommand::Clean => crate::commands::cache::handle_clean(app).await, }, Command::Version => version::run().await.map(|()| 0), Command::Plugin(cmd) => plugin::run(cmd).await.map(|()| 0), Command::Cloud(cmd) => { let env = std::env::vars().collect(); - hm_plugin_cloud::cli::dispatch_command(cmd, env).await + hm_plugin_cloud::cli::dispatch_command(cmd, env, app).await } } } diff --git a/crates/hm/src/cli/pipelines.rs b/crates/hm/src/cli/pipelines.rs index 3f94d018..fbe858a9 100644 --- a/crates/hm/src/cli/pipelines.rs +++ b/crates/hm/src/cli/pipelines.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::Parser; +use hm_core::app_ctx::AppCtx; use hm_dsl_engine::{DslEngine, SubprocessPythonEngine, detect}; #[derive(Debug, Clone, Parser)] @@ -27,7 +28,7 @@ const EMPTY_ENVELOPE: &str = r#"{"schema_version":"1","pipelines":[]}"#; /// /// Returns an error if the engine can't start or the DSL runtime fails to /// evaluate the pipelines. -pub async fn run(args: PipelinesArgs) -> Result<()> { +pub async fn run(args: PipelinesArgs, app: &AppCtx) -> Result<()> { let repo_root = match args.dir { Some(d) => d, None => std::env::current_dir().context("cannot determine current directory")?, @@ -39,7 +40,7 @@ pub async fn run(args: PipelinesArgs) -> Result<()> { } detect::check_python(&repo_root).context("detecting pipeline language")?; - let engine = SubprocessPythonEngine::new(); + let engine = SubprocessPythonEngine::new(app); let json = engine .registry_json(&repo_root) .await diff --git a/crates/hm/src/cli/render.rs b/crates/hm/src/cli/render.rs index ca907647..24e609f2 100644 --- a/crates/hm/src/cli/render.rs +++ b/crates/hm/src/cli/render.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::Parser; +use hm_core::app_ctx::AppCtx; use hm_dsl_engine::{DslEngine, SubprocessPythonEngine, detect}; #[derive(Debug, Clone, Parser)] @@ -25,14 +26,14 @@ pub struct RenderArgs { /// Returns an error if the language can't be detected, the engine can't start, /// or the slug is unknown / fails to render (the available slugs are written to /// stderr by the DSL runtime). -pub async fn run(args: RenderArgs) -> Result<()> { +pub async fn run(args: RenderArgs, app: &AppCtx) -> Result<()> { let repo_root = match args.dir { Some(d) => d, None => std::env::current_dir().context("cannot determine current directory")?, }; detect::check_python(&repo_root).context("detecting pipeline language")?; - let engine = SubprocessPythonEngine::new(); + let engine = SubprocessPythonEngine::new(app); let json = engine .render_pipeline_json(&repo_root, &args.slug) .await diff --git a/crates/hm/src/commands/cache/clean.rs b/crates/hm/src/commands/cache/clean.rs index 1fe68280..c5106899 100644 --- a/crates/hm/src/commands/cache/clean.rs +++ b/crates/hm/src/commands/cache/clean.rs @@ -1,12 +1,12 @@ use anyhow::Result; -use hm_common::sys_runtime::SysRuntime; +use hm_core::app_ctx::AppCtx; use hm_vm::VmBackend as _; use human_units::FormatSize as _; /// # Errors /// Returns an error if workspace cache removal fails. -pub async fn handle_clean() -> Result { - let hm_cache = SysRuntime::dirs().cache().join("hm"); +pub async fn handle_clean(app: &AppCtx) -> Result { + let hm_cache = app.dirs().cache().join("hm"); let ws_cache = hm_cache.join("workspaces"); let ws_cleaned = if ws_cache.exists() { @@ -112,4 +112,3 @@ fn dir_size(path: &std::path::Path) -> u64 { } walk(path) } - diff --git a/crates/hm/src/commands/init.rs b/crates/hm/src/commands/init.rs index 40e32b87..0bead8d5 100644 --- a/crates/hm/src/commands/init.rs +++ b/crates/hm/src/commands/init.rs @@ -2,6 +2,7 @@ use std::io::IsTerminal; use std::path::Path; use anyhow::{Context, Result, bail}; +use hm_core::config::domain::BackendConfig; use hm_dsl_engine::detect; use crate::cli::init::{InitArgs, TemplateKind}; @@ -96,10 +97,11 @@ fn prompt_skills() -> Result { /// - On org selection → write a sparse `.hm/config.toml` with `backend = "cloud"` and the org slug. /// /// Silently returns `Ok(())` on any user-cancellation (Esc, Ctrl-C on a prompt). -async fn prompt_cloud_registration(dir: &std::path::Path) -> Result<()> { - let cfg = hm_config::Config::load(None).unwrap_or_default(); - let api_url = &cfg.cloud.api_url; - let is_logged_in = hm_config::creds::cloud_token(api_url).is_some(); +async fn prompt_cloud_registration( + dir: &std::path::Path, + app: &hm_core::app_ctx::AppCtx, +) -> Result<()> { + let is_logged_in = app.creds().get().await.is_some(); if !is_logged_in { let want_login = dialoguer::Confirm::new() @@ -112,10 +114,11 @@ async fn prompt_cloud_registration(dir: &std::path::Path) -> Result<()> { return Ok(()); } - hm_plugin_cloud::login_interactive().await?; + hm_plugin_cloud::login_interactive(app).await?; } - let (client, _ctx) = hm_plugin_cloud::settings::client() + let (client, _ctx) = hm_plugin_cloud::settings::client(app) + .await .context("could not build authenticated cloud client")?; let orgs = client @@ -265,7 +268,7 @@ fn has_github_workflows(dir: &Path) -> bool { /// /// Returns an error if the target directory is unwritable, or if no template /// can be determined in a non-interactive context. -pub async fn handle(args: InitArgs) -> Result<()> { +pub async fn handle(args: InitArgs, app: &hm_core::app_ctx::AppCtx) -> Result<()> { let tty = std::io::stdin().is_terminal(); let has_pipeline = detect::has_pipeline_files(&args.dir); @@ -302,7 +305,7 @@ pub async fn handle(args: InitArgs) -> Result<()> { } } - if tty && let Err(e) = prompt_cloud_registration(&args.dir).await { + if tty && let Err(e) = prompt_cloud_registration(&args.dir, app).await { tracing::warn!("cloud registration skipped: {e:#}"); } @@ -319,20 +322,16 @@ pub async fn handle(args: InitArgs) -> Result<()> { write_skills(&args.dir, args.force).await?; } - let project_config = hm_config::Config::project_config_path(&args.dir); - if project_config.exists() { - let cfg = - hm_config::Config::load_from_paths(None, Some(&project_config)).unwrap_or_default(); - match cfg.backend { - hm_config::Backend::Cloud => { - tracing::info!("next step: run `hm run` to execute your pipeline on Harmont Cloud"); - } - hm_config::Backend::Docker => { - tracing::info!("next step: run `hm run` to execute your pipeline locally"); - } + let backend = hm_core::project_ctx::ProjectCtx::at(app, args.dir.clone()) + .await + .map_or(BackendConfig::Docker, |p| p.config().backend.clone()); + match backend { + BackendConfig::Cloud(_) => { + tracing::info!("next step: run `hm run` to execute your pipeline on Harmont Cloud"); + } + BackendConfig::Docker => { + tracing::info!("next step: run `hm run` to execute your pipeline locally"); } - } else { - tracing::info!("next step: run `hm run` to execute your pipeline locally"); } Ok(()) } diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 82510567..3445a731 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -3,10 +3,12 @@ use std::collections::HashMap; use anyhow::{Context, Result}; use bstr::ByteSlice as _; -use hm_common::sys_runtime::SysRuntime; use hm_common::git::{GitBranch, GitRemote, GitRepo}; +use hm_core::app_ctx::AppCtx; +use hm_core::config::domain::BackendConfig; use hm_dsl_engine::{DslEngine, detect}; use human_units::FormatSize as _; +use secrecy::ExposeSecret as _; use crate::cli::RunArgs; use crate::context::RunContext; @@ -23,8 +25,8 @@ use crate::error::{ErrorCategory, HmError}; /// - `--cloud` → `cloud` (deprecated alias) /// - neither → `ctx.config.backend` (figment-layered, default `docker`) /// -/// This is a THIN driver over the `hm-exec` backends: it builds an -/// [`hm_exec::ExecutionBackend`], renders the pipeline to v0 IR once, starts +/// This is a THIN driver over the `hm-core::exec` backends: it builds an +/// [`hm_core::exec::ExecutionBackend`], renders the pipeline to v0 IR once, starts /// the build, drives its event stream through an `hm_render` renderer, owns /// Ctrl-C, and returns the build's process exit code. Cloud authentication is /// resolved BEFORE the (local) render work so a missing token fails fast. @@ -35,38 +37,61 @@ use crate::error::{ErrorCategory, HmError}; /// the backend rejects the build, authentication fails, the network is /// unreachable, the local daemon is down, or the pipeline fails to render. #[allow(clippy::too_many_lines)] // thin top-level driver: linear, no good split point -pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { +#[allow( + clippy::similar_names, + reason = "api_url and app_url are distinct hosts" +)] +pub async fn handle(args: RunArgs, ctx: RunContext<'_>) -> Result { + let app = ctx.app; + + // The workspace root: explicit --dir or the cwd captured at startup. + let repo_root = args.dir.clone().unwrap_or_else(|| app.cwd().to_path_buf()); + + // Resolve the effective config (user + project layers merged). + let project = hm_core::project_ctx::ProjectCtx::at(app, repo_root.clone()).await?; + let resolved_backend = project.config().backend.clone(); + + // Project-persisted cloud pipeline slug, if any (consulted at submit time). + let cloud_default_pipeline = match &resolved_backend { + BackendConfig::Cloud(cloud) => cloud.default_pipeline.clone(), + BackendConfig::Docker => None, + }; + // 1. Resolve the backend name: explicit --backend > legacy --cloud alias > - // config.backend (figment-layered default "docker"). + // the resolved config's backend (default docker). let backend_name = args .backend .clone() - .or_else(|| { - if args.cloud { - Some("cloud".to_string()) - } else { - None - } - }) - .unwrap_or_else(|| ctx.config.backend.to_string()); + .or_else(|| args.cloud.then(|| "cloud".to_string())) + .unwrap_or_else(|| match &resolved_backend { + BackendConfig::Cloud(_) => "cloud".to_string(), + BackendConfig::Docker => "docker".to_string(), + }); // 2. Cloud needs auth + org resolution up front — fail fast on a missing - // token before any render work. We resolve the credentials here but - // defer *constructing* the backend (and, for local runs, *connecting* to - // Docker) until after the pipeline renders, so an unknown slug or a - // missing/ambiguous pipeline argument fails with a helpful message - // instead of a daemon-connection error. + // token before any render work. Both the API host and the dashboard + // (app.) host derive from the configured domain. let cloud_creds = if backend_name == "cloud" { - let api_url = ctx.config.cloud.api_url.clone(); - let token = hm_config::creds::cloud_token(&api_url).context( + let cloud = match &resolved_backend { + BackendConfig::Cloud(cloud) => cloud.clone(), + BackendConfig::Docker => hm_core::config::ResolvedCloudConfig { + domain: hm_core::config::domain::BackendDomain::default(), + org: None, + repo: None, + default_pipeline: None, + }, + }; + let api_url = cloud.domain.api_url(); + let app_url = cloud.domain.app_url(); + let token = app.creds().get().await.context( "`hm run --backend cloud` requires authentication — run `hm cloud login` or set HM_API_TOKEN", )?; let org = args .org .clone() - .or_else(|| ctx.config.cloud.org.clone()) - .context("no organization — pass --org or set `[cloud] org = \"…\"` in .hm/config.toml or ~/.config/hm/config.toml")?; - Some((api_url, token, org)) + .or_else(|| cloud.org.clone()) + .context("no organization — pass --org or run `hm cloud org switch `")?; + Some((api_url, app_url, token, org)) } else if backend_name != "docker" { anyhow::bail!("unknown --backend '{backend_name}'\n available: docker, cloud"); } else { @@ -76,8 +101,8 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // 3. Render + parse the plan once (shared by every backend). This validates // the pipeline argument — unknown slug, or zero/many declared pipelines // — before we connect to any daemon. - let (repo_root, slug, ir_json) = render_pipeline(&args, &ctx).await?; - let plan = hm_exec::Plan::parse(ir_json).map_err(|e| backend_anyhow(&e))?; + let (slug, ir_json) = render_pipeline(&args, app, &repo_root).await?; + let plan = hm_core::exec::Plan::parse(ir_json).map_err(|e| backend_anyhow(&e))?; // 4. Pick the renderer — this validates `--format` — before any daemon // connection, so an unknown format fails fast without a running Docker. @@ -90,20 +115,22 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // For cloud runs, keep a cloned client + org so a `pipeline_not_found` on // the first submit can create the pipeline and retry. `None` for local. let mut autocreate_client: Option<(harmont_cloud::HarmontClient, String)> = None; - let backend: Box = - if let Some((api_url, token, org)) = cloud_creds { - let client = harmont_cloud::HarmontClient::with_base_url(token, &api_url); + let backend: Box = + if let Some((api_url, app_url, token, org)) = cloud_creds { + let client = harmont_cloud::HarmontClient::with_base_url( + token.expose_secret().to_owned(), + &api_url, + ); autocreate_client = Some((client.clone(), org.clone())); - // The watch link must point at the dashboard (app.) host, not the - // API host — a link built from `api_url` lands on raw JSON. - let app_url = hm_config::app_url(&api_url, std::env::var("HM_APP_URL").ok().as_deref()); - Box::new(hm_exec::CloudBackend::new(client, api_url, app_url, org)) + Box::new(hm_core::exec::CloudBackend::new( + client, api_url, app_url, org, + )) } else { // Local execution on a hm-vm VmBackend (docker). let vm_backend: std::sync::Arc = std::sync::Arc::new( hm_vm::docker::DockerBackend::connect().map_err(|e| anyhow::anyhow!("{e:#}"))?, ); - Box::new(hm_exec::LocalBackend::new( + Box::new(hm_core::exec::LocalBackend::new( resolve_parallelism(&args), vm_backend, )) @@ -137,7 +164,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // strings so `repo_root` can move into the request, and the cloud paths // below reuse `remote_url` / `default_branch` instead of re-shelling to git. let (branch, commit, repo_name, remote_url, default_branch) = { - let git = SysRuntime::git(); + let git = app.git(); let repo = git.repo(&repo_root).ok(); let head = repo.as_ref().and_then(GitRepo::current_branch); let remote = repo.as_ref().and_then(|r| r.remote("origin")); @@ -170,18 +197,18 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { (branch, commit, repo_name, remote_url, default_branch) }; - let mut req = hm_exec::RunRequest { + let mut req = hm_core::exec::RunRequest { plan, repo_root, pipeline_slug: slug, env: parse_env(&args.env).into_iter().collect(), - source: hm_exec::SourceMeta { + source: hm_core::exec::SourceMeta { branch, commit, message: args.message.clone(), repo_name, }, - options: hm_exec::RunOptions { + options: hm_core::exec::RunOptions { no_cache: false, timeout: None, watch: !args.no_watch, @@ -195,7 +222,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { // interactively now and its slug persisted. A worktree WITH a remote falls // through to the repo-identity submit + get-or-create fallback below. if let Some((client, org)) = autocreate_client.as_ref() { - if let Some(slug) = ctx.config.cloud.pipeline.clone() { + if let Some(slug) = cloud_default_pipeline.clone() { req.cloud_pipeline_slug = Some(slug); } else if req.source.repo_name.is_none() { let default_branch = default_branch @@ -283,7 +310,7 @@ fn parse_env(pairs: &[String]) -> HashMap { /// /// Returns `(repo_root, slug, ir_json_string)`. The JSON is returned as a /// string so a backend (e.g. cloud) can ship it verbatim; the driver parses -/// it into an [`hm_exec::Plan`] once. +/// it into an [`hm_core::exec::Plan`] once. /// /// # Errors /// @@ -292,21 +319,17 @@ fn parse_env(pairs: &[String]) -> HashMap { /// the DSL detection / pipeline-render step fails. async fn render_pipeline( args: &RunArgs, - _ctx: &RunContext, -) -> Result<(std::path::PathBuf, String, String)> { - let repo_root = match args.dir.clone() { - Some(p) => p, - None => SysRuntime::cwd().to_path_buf(), - }; - - detect::check_python(&repo_root).map_err(|e| HmError::DslEngine(format!("{e:#}")))?; - let engine = hm_dsl_engine::SubprocessPythonEngine::new(); + app: &AppCtx, + repo_root: &std::path::Path, +) -> Result<(String, String)> { + detect::check_python(repo_root).map_err(|e| HmError::DslEngine(format!("{e:#}")))?; + let engine = hm_dsl_engine::SubprocessPythonEngine::new(app); let slug = if let Some(s) = &args.pipeline { s.clone() } else { let metas: Vec = engine - .list_pipelines(&repo_root) + .list_pipelines(repo_root) .await .map_err(|e| HmError::PipelineRender(format!("{e:#}")))?; let slugs: Vec = metas.into_iter().map(|m| m.slug).collect(); @@ -324,20 +347,20 @@ async fn render_pipeline( }; let json_str = engine - .render_pipeline_json(&repo_root, &slug) + .render_pipeline_json(repo_root, &slug) .await .map_err(|e| HmError::PipelineRender(format!("{e:#}")))?; - Ok((repo_root, slug, json_str)) + Ok((slug, json_str)) } -/// Convert an [`hm_exec::BackendError`] into an [`anyhow::Error`] that carries +/// Convert an [`hm_core::exec::BackendError`] into an [`anyhow::Error`] that carries /// BOTH the doctrine message ([`explain`]) AND the right process exit code. /// /// The exit code is preserved by wrapping in [`HmError::Backend`], whose /// [`HmError::category`] returns the embedded [`ErrorCategory`]; `main`'s /// `handle_error` downcasts to `HmError` and reads `exit_code()`. -fn backend_anyhow(err: &hm_exec::BackendError) -> anyhow::Error { +fn backend_anyhow(err: &hm_core::exec::BackendError) -> anyhow::Error { HmError::Backend(explain(err), exit_category(err)).into() } @@ -350,8 +373,8 @@ const PIPELINE_NOT_FOUND_CODE: &str = "pipeline_not_found"; /// submit path surfaces this as a structured `Rejected { code }` (current SDK); /// we also accept an opaque `NotFound` body carrying the code, for robustness /// against older servers that took the un-structured 404 path. -fn is_missing_pipeline(err: &hm_exec::BackendError) -> bool { - use hm_exec::BackendError as E; +fn is_missing_pipeline(err: &hm_core::exec::BackendError) -> bool { + use hm_core::exec::BackendError as E; match err { E::Rejected { code, .. } => code == PIPELINE_NOT_FOUND_CODE, E::NotFound(body) => body.contains(PIPELINE_NOT_FOUND_CODE), @@ -378,24 +401,28 @@ fn build_create_pipeline_request( } } -/// Merge `backend = "cloud"` and `[cloud] org/pipeline = …` into the project's -/// `.hm/config.toml`, preserving any other keys already in the file. Creates -/// the file (and `.hm/`) when absent. Used after registering a remoteless -/// directory so later runs submit by the persisted slug without prompting. +/// Persist a cloud backend with `org` + `default_pipeline` to the project's +/// `.hm/config.toml`, preserving an existing domain. Creates the file (and +/// `.hm/`) when absent. Used after registering a remoteless directory so later +/// runs submit by the persisted slug without prompting. async fn persist_project_pipeline(dir: &std::path::Path, org: &str, slug: &str) -> Result<()> { + use hm_core::config::project::{ProjectCloudConfig, ProjectConfig}; + let path = dir.join(".hm/config.toml"); - let mut doc: toml::Table = std::fs::read_to_string(&path) + let existing: ProjectConfig = std::fs::read_to_string(&path) .ok() .and_then(|s| toml::from_str(&s).ok()) .unwrap_or_default(); - doc.insert("backend".into(), toml::Value::String("cloud".into())); - let cloud = doc - .entry("cloud".to_string()) - .or_insert_with(|| toml::Value::Table(toml::Table::new())); - if let Some(t) = cloud.as_table_mut() { - t.insert("org".into(), toml::Value::String(org.to_string())); - t.insert("pipeline".into(), toml::Value::String(slug.to_string())); - } + let mut cloud = match existing.backend { + Some(BackendConfig::Cloud(cloud)) => cloud, + _ => ProjectCloudConfig::default(), + }; + cloud.org = Some(org.to_string()); + cloud.default_pipeline = Some(slug.to_string()); + + let doc = ProjectConfig { + backend: Some(BackendConfig::Cloud(cloud)), + }; let serialized = toml::to_string_pretty(&doc).context("serializing .hm/config.toml")?; hm_common::fs::write_create_all(&path, serialized) .await @@ -505,7 +532,7 @@ struct AutoCreate { /// identified, or the user declined (caller then surfaces the original error). /// Returns `Err` only when a lookup or create request itself failed. async fn resolve_or_create_cloud_pipeline( - err: &hm_exec::BackendError, + err: &hm_core::exec::BackendError, ac: Option<&AutoCreate>, ) -> Result> { use std::io::IsTerminal; @@ -566,15 +593,15 @@ async fn resolve_or_create_cloud_pipeline( Ok(Some(slug)) } -/// Map a [`hm_exec::BackendError`] to the process exit-code category. +/// Map a [`hm_core::exec::BackendError`] to the process exit-code category. /// /// Note: the old taxonomy distinguished a downed Docker daemon /// (`EXIT_NETWORK`) from an unknown-runner pipeline error /// (`EXIT_PIPELINE_INVALID`). Both now arrive as -/// [`hm_exec::BackendError::Local`], so they collapse to a single category +/// [`hm_core::exec::BackendError::Local`], so they collapse to a single category /// (`Network`) here — an acceptable loss of resolution. -const fn exit_category(err: &hm_exec::BackendError) -> ErrorCategory { - use hm_exec::BackendError as E; +const fn exit_category(err: &hm_core::exec::BackendError) -> ErrorCategory { + use hm_core::exec::BackendError as E; match err { // A plan/IR rejection is a pipeline-config problem. E::Rejected { .. } => ErrorCategory::PipelineInvalid, @@ -593,12 +620,12 @@ const fn exit_category(err: &hm_exec::BackendError) -> ErrorCategory { } } -/// Render a [`hm_exec::BackendError`] in the project's error doctrine: point +/// Render a [`hm_core::exec::BackendError`] in the project's error doctrine: point /// precisely, say what was observed, say the fix, give a stable code + doc URL. /// /// Adapted from the legacy `executor/cloud.rs::explain(&HarmontError)`. -fn explain(err: &hm_exec::BackendError) -> String { - use hm_exec::BackendError as E; +fn explain(err: &hm_core::exec::BackendError) -> String { + use hm_core::exec::BackendError as E; match err { E::Unauthorized => "\ error[auth_required]: not authenticated @@ -660,7 +687,7 @@ error[source_too_large]: worktree archive is {observed} (cap {cap}) reason = "test setup and assertions" )] mod tests { - use hm_exec::BackendError as E; + use hm_core::exec::BackendError as E; use rstest::rstest; use super::*; @@ -795,35 +822,39 @@ mod tests { } #[rstest] - #[case::absent(None, "my-app-2", None)] - #[case::existing_keys( - Some("backend = \"docker\"\n[cloud]\norg = \"old\"\napi_url = \"https://example.test\"\n"), + #[case::absent(None, "my-app-2", "https://api.harmont.dev")] + #[case::preserves_domain( + Some("[backend]\ntype = \"cloud\"\ndomain = \"example.test\"\norg = \"old\"\n"), "web", - Some("https://example.test") + "https://api.example.test" )] #[tokio::test] async fn persist_project_pipeline_writes_config( #[case] preexisting: Option<&str>, #[case] slug: &str, - #[case] expected_api_url: Option<&str>, + #[case] expected_api_url: &str, ) { + use hm_core::config::domain::BackendConfig; + use hm_core::config::project::ProjectConfig; + let dir = tempfile::tempdir().unwrap(); if let Some(content) = preexisting { std::fs::create_dir_all(dir.path().join(".hm")).unwrap(); std::fs::write(dir.path().join(".hm/config.toml"), content).unwrap(); } - persist_project_pipeline(dir.path(), "acme", slug).await.unwrap(); + persist_project_pipeline(dir.path(), "acme", slug) + .await + .unwrap(); let raw = std::fs::read_to_string(dir.path().join(".hm/config.toml")).unwrap(); - let doc: toml::Table = toml::from_str(&raw).unwrap(); - assert_eq!(doc["backend"].as_str(), Some("cloud")); - let cloud = doc["cloud"].as_table().unwrap(); - assert_eq!(cloud["org"].as_str(), Some("acme")); - assert_eq!(cloud["pipeline"].as_str(), Some(slug)); - // Any unrelated key present beforehand is preserved across the merge. - if let Some(api_url) = expected_api_url { - assert_eq!(cloud["api_url"].as_str(), Some(api_url)); - } + let cfg: ProjectConfig = toml::from_str(&raw).unwrap(); + let Some(BackendConfig::Cloud(cloud)) = cfg.backend else { + panic!("expected a cloud backend"); + }; + assert_eq!(cloud.org.as_deref(), Some("acme")); + assert_eq!(cloud.default_pipeline.as_deref(), Some(slug)); + // A pre-existing domain is preserved; an absent one falls back to default. + assert_eq!(cloud.domain.unwrap_or_default().api_url(), expected_api_url); } } diff --git a/crates/hm/src/context.rs b/crates/hm/src/context.rs index d64c7b27..2400aa7c 100644 --- a/crates/hm/src/context.rs +++ b/crates/hm/src/context.rs @@ -1,36 +1,22 @@ use std::io::IsTerminal; -use anyhow::{Context, Result}; +use hm_core::app_ctx::AppCtx; +use hm_render::OutputMode; use crate::cli::Cli; -use crate::config::Config; -use hm_render::OutputMode; -/// Runtime context that bundles resolved config and output preferences. -/// -/// Deliberately thin: the API client, credential store, and active-org -/// resolution live in `hm-plugin-cloud`. This context carries only the -/// resolved config and output mode. +/// The app context and output mode for a dispatched command. #[derive(Debug)] -pub struct RunContext { - pub config: Config, - /// Output mode for the residual built-in verbs (the legacy global - /// `--format` flag was retired in plan 3; per-subcommand `--format` - /// is the only currently-wired source, so this defaults to human). +pub struct RunContext<'app> { + pub app: &'app AppCtx, + /// Output mode for the built-in verbs. pub output: OutputMode, } -impl RunContext { - /// Build a [`RunContext`] from parsed CLI args. - /// - /// # Errors - /// - /// Returns an error if the config file is unreadable or malformed. - pub fn from_cli(cli: &Cli) -> Result { - let start_dir = std::env::current_dir().context("cannot determine current directory")?; - let project_root = crate::project::find_project_root(&start_dir); - let config = Config::load(project_root.as_deref())?; - +impl<'app> RunContext<'app> { + /// Build a [`RunContext`] from the app context and parsed CLI args. + #[must_use] + pub fn from_cli(app: &'app AppCtx, cli: &Cli) -> Self { let output = OutputMode::Human { // Single source of truth for the color/TTY rule (still honors --no-color). color: hm_render::color_enabled(cli.no_color), @@ -38,6 +24,6 @@ impl RunContext { interactive: std::io::stdout().is_terminal(), }; - Ok(Self { config, output }) + Self { app, output } } } diff --git a/crates/hm/src/error.rs b/crates/hm/src/error.rs index d83ab50c..415f7cf4 100644 --- a/crates/hm/src/error.rs +++ b/crates/hm/src/error.rs @@ -56,7 +56,7 @@ pub enum HmError { #[error("no default step executor is registered")] NoDefaultExecutor, - /// A failure surfaced across the `hm_exec::ExecutionBackend` boundary, + /// A failure surfaced across the `hm_core::exec::ExecutionBackend` boundary, /// already rendered in the project's error doctrine. Carries its own /// [`ErrorCategory`] so the process exit code is preserved (the doctrine /// message lives in `0`; the category drives `exit_code()`). diff --git a/crates/hm/src/lib.rs b/crates/hm/src/lib.rs index 2219d437..6108487d 100644 --- a/crates/hm/src/lib.rs +++ b/crates/hm/src/lib.rs @@ -14,15 +14,8 @@ )] pub mod cli; pub mod commands; -/// Re-export of the shared [`hm_config`] crate under the historical -/// `harmont_cli::config` path so existing consumers and integration tests -/// keep resolving. The layered config + credential store now live in -/// `hm-config` so `hm-plugin-cloud` can share them. -pub use hm_config as config; -/// Re-export the credential store under the historical -/// `harmont_cli::creds_store` path. -pub use hm_config::creds as creds_store; +/// Re-export of [`hm_core::config`] at `harmont_cli::config`. +pub use hm_core::config; pub mod context; -pub mod project; pub mod error; pub(crate) mod signal; diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index c33675f7..7086cc30 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -5,7 +5,7 @@ use anyhow::Context as _; use clap::Parser; -use hm_common::sys_runtime::SysRuntime; +use hm_core::app_ctx::AppCtx; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; use tracing_subscriber::filter::Targets; @@ -91,12 +91,16 @@ async fn main() { } async fn run(args: Cli) -> Result { - // Every command runs against the build toolchain (git + python3); resolve it - // once up front and fail fast with a clear error if anything is missing. - SysRuntime::init().context("resolving the build toolchain")?; + // Every command runs against the build toolchain (git + python3) and the + // user config; resolve them once up front and fail fast on a missing + // toolchain. Owned here for the duration of the command and threaded to the + // dispatch by reference. + let app = AppCtx::init() + .await + .context("resolving the build toolchain")?; let command = args.command.clone(); - let ctx = RunContext::from_cli(&args)?; + let ctx = RunContext::from_cli(&app, &args); cli::dispatch(command, ctx).await } diff --git a/crates/hm/src/project.rs b/crates/hm/src/project.rs deleted file mode 100644 index 8a580bf9..00000000 --- a/crates/hm/src/project.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! The current Harmont project on disk. -//! -//! A project is any directory tree rooted at a directory that contains `.hm/`; -//! this module locates that root. - -#![allow(clippy::must_use_candidate)] - -use std::path::PathBuf; - -/// Walk up from `start` looking for a directory containing `.hm/`. -/// Returns the project root (the directory *containing* `.hm/`), -/// or `None` if the filesystem root is reached without finding one. -pub fn find_project_root(start: &std::path::Path) -> Option { - let mut current = start; - loop { - if current.join(".hm").is_dir() { - return Some(current.to_path_buf()); - } - current = current.parent()?; - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, reason = "test setup and assertions")] -mod tests { - use rstest::rstest; - - use super::*; - - #[rstest] - fn find_project_root_at_current_dir() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir(tmp.path().join(".hm")).unwrap(); - let found = find_project_root(tmp.path()); - assert_eq!(found, Some(tmp.path().to_path_buf())); - } - - #[rstest] - fn find_project_root_walks_up() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir(tmp.path().join(".hm")).unwrap(); - let nested = tmp.path().join("src").join("deep"); - std::fs::create_dir_all(&nested).unwrap(); - let found = find_project_root(&nested); - assert_eq!(found, Some(tmp.path().to_path_buf())); - } - - #[rstest] - fn find_project_root_returns_none_when_missing() { - let tmp = tempfile::tempdir().unwrap(); - let found = find_project_root(tmp.path()); - assert_eq!(found, None); - } -} diff --git a/crates/hm/tests/cmd_init.rs b/crates/hm/tests/cmd_init.rs index daa89369..06ea91dd 100644 --- a/crates/hm/tests/cmd_init.rs +++ b/crates/hm/tests/cmd_init.rs @@ -221,8 +221,8 @@ fn init_python_templates_roundtrip_render(#[case] slug: &str) { .stdout .clone(); - let v: serde_json::Value = - serde_json::from_slice(&out).unwrap_or_else(|e| panic!("template {slug}: invalid JSON: {e}")); + let v: serde_json::Value = serde_json::from_slice(&out) + .unwrap_or_else(|e| panic!("template {slug}: invalid JSON: {e}")); assert_eq!(v["version"], "0", "template {slug}: expected v0 IR"); assert!( v["graph"].is_object(), @@ -278,10 +278,7 @@ fn skill_content_is_well_formed(#[case] content: &str, #[case] extras: &[&str]) "skill must have 'Procedure' section" ); for needle in extras { - assert!( - content.contains(needle), - "skill must reference `{needle}`" - ); + assert!(content.contains(needle), "skill must reference `{needle}`"); } } @@ -380,20 +377,3 @@ fn init_noninteractive_skips_cloud_registration() { "non-interactive init should not create .hm/config.toml" ); } - -#[rstest] -fn cloud_project_config_layers_correctly() { - let dir = tempfile::tempdir().unwrap(); - let hm_dir = dir.path().join(".hm"); - std::fs::create_dir(&hm_dir).unwrap(); - - let config_path = hm_dir.join("config.toml"); - let content = "backend = \"cloud\"\n\n[cloud]\norg = \"test-org\"\n"; - std::fs::write(&config_path, content).unwrap(); - - let cfg = hm_config::Config::load_from_paths(None, Some(&config_path)).unwrap(); - assert_eq!(cfg.backend, hm_config::Backend::Cloud); - assert_eq!(cfg.cloud.org.as_deref(), Some("test-org")); - // Unrelated defaults survive layering. - assert_eq!(cfg.preferences.format, "human"); -} diff --git a/crates/hm/tests/config_layered.rs b/crates/hm/tests/config_layered.rs deleted file mode 100644 index c4d7076a..00000000 --- a/crates/hm/tests/config_layered.rs +++ /dev/null @@ -1,93 +0,0 @@ -#![allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - reason = "integration test setup and assertions" -)] - -use rstest::rstest; -use std::fs; -use tempfile::tempdir; - -#[rstest] -fn project_overrides_user() { - let user_dir = tempdir().unwrap(); - let user_path = user_dir.path().join("config.toml"); - fs::write( - &user_path, - b"[cloud]\norg = \"user-org\"\napi_url = \"https://user.api\"\n\n[preferences]\nformat = \"json\"\n", - ) - .unwrap(); - - let project_dir = tempdir().unwrap(); - let project_path = project_dir.path().join("config.toml"); - fs::write(&project_path, b"[cloud]\norg = \"project-org\"\n").unwrap(); - - let config = - harmont_cli::config::Config::load_from_paths(Some(&user_path), Some(&project_path)) - .unwrap(); - - assert_eq!(config.cloud.org.as_deref(), Some("project-org")); - assert_eq!(config.cloud.api_url, "https://user.api"); - assert_eq!(config.preferences.format, "json"); -} - -#[rstest] -fn missing_files_resolve_to_defaults() { - let config = harmont_cli::config::Config::load_from_paths(None, None).unwrap(); - assert_eq!(config.cloud.api_url, harmont_cli::config::DEFAULT_API_URL); - assert_eq!(config.preferences.format, "human"); - assert!(!config.preferences.auto_watch); - assert!(config.cloud.org.is_none()); -} - -/// A single config file resolves its `cloud.org` while leaving `api_url` at the -/// default. Covers project-only loads, user-only loads, and files carrying -/// unknown keys/sections (figment ignores those by default). -#[rstest] -#[case::project_only("[cloud]\norg = \"proj\"\n", "proj")] -#[case::file_values("[cloud]\norg = \"file-org\"\n", "file-org")] -#[case::unknown_keys_ignored( - "[cloud]\norg = \"ok\"\nunknown_key = 42\n\n[unknown_section]\nfoo = true\n", - "ok" -)] -fn single_file_resolves_org(#[case] toml_body: &str, #[case] expected_org: &str) { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, toml_body).unwrap(); - - let config = harmont_cli::config::Config::load_from_paths(Some(&path), None).unwrap(); - - assert_eq!(config.cloud.org.as_deref(), Some(expected_org)); - assert_eq!(config.cloud.api_url, harmont_cli::config::DEFAULT_API_URL); -} - -#[rstest] -#[case::malformed("this is not [valid toml\n")] -#[case::type_mismatch("[preferences]\nauto_watch = \"not-a-bool\"\n")] -fn invalid_toml_returns_error(#[case] toml_body: &str) { - let dir = tempdir().unwrap(); - let path = dir.path().join("config.toml"); - fs::write(&path, toml_body).unwrap(); - - let result = harmont_cli::config::Config::load_from_paths(Some(&path), None); - assert!(result.is_err()); -} - -#[rstest] -fn load_resolves_project_root() { - let project_dir = tempdir().unwrap(); - let harmont_dir = project_dir.path().join(".hm"); - fs::create_dir_all(&harmont_dir).unwrap(); - fs::write( - harmont_dir.join("config.toml"), - b"[cloud]\norg = \"proj-root\"\n", - ) - .unwrap(); - - let found = harmont_cli::project::find_project_root(project_dir.path()); - assert_eq!(found, Some(project_dir.path().to_path_buf())); - - let config_path = harmont_cli::config::Config::project_config_path(project_dir.path()); - assert_eq!(config_path, harmont_dir.join("config.toml")); -}