From 85e007b146cd1b69f87cec64d8e735ada1cd67a6 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 26 Jul 2026 17:02:57 -0700 Subject: [PATCH 01/13] feat(hm-common): add GitSha newtype, validate commit sites Introduce a validated git object-id type (40/64 hex, lowercase, null-oid sentinel) with transparent-string serde, and thread it through every place a commit id is produced or carried: - git::head_commit() -> Option, parsed from rev-parse. - exec::SourceMeta.commit: GitSha, converted to String only at the harmont-cloud SDK boundary. - hm run derives the commit as GitSha::zero() when git has none. - cloud run's --commit parses to GitSha at clap time, rejecting a malformed id before submission. --- crates/hm-common/src/git.rs | 163 +++++++++++++++++++++- crates/hm-core/src/exec/cloud/backend.rs | 4 +- crates/hm-core/src/exec/request.rs | 3 +- crates/hm-core/tests/backend_contract.rs | 3 +- crates/hm/src/commands/cloud/verbs/run.rs | 11 +- crates/hm/src/commands/run/mod.rs | 6 +- 6 files changed, 170 insertions(+), 20 deletions(-) diff --git a/crates/hm-common/src/git.rs b/crates/hm-common/src/git.rs index a0033387..97310f96 100644 --- a/crates/hm-common/src/git.rs +++ b/crates/hm-common/src/git.rs @@ -1,4 +1,4 @@ -//! Running git. +//! Git integration: running the `git` CLI and git value types. use std::path::{Path, PathBuf}; use std::process::Command; @@ -7,6 +7,98 @@ use bstr::{BStr, BString, ByteSlice}; use crate::process::{CapturedStreams as _, CommandExt as _}; +/// A git object identifier: a full 40-char SHA-1 or 64-char SHA-256 hex +/// digest, normalized to lowercase. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GitSha(String); + +/// A string that is not a valid [`GitSha`]. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum GitShaError { + /// The digest is not 40 (SHA-1) or 64 (SHA-256) hex characters long. + #[error("git sha must be 40 or 64 hex chars, got {0}")] + BadLength(usize), + /// The digest contains a non-hex character. + #[error("git sha contains a non-hex character")] + NotHex, +} + +impl GitSha { + /// The all-zero SHA-1 null oid (`0000…`, 40 chars) git uses to mean + /// "no commit". + #[must_use] + pub fn zero() -> Self { + Self("0".repeat(40)) + } + + /// The digest as a lowercase hex string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Whether this is the all-zero null oid git uses to mean "no commit". + #[must_use] + pub fn is_zero(&self) -> bool { + self.0.bytes().all(|b| b == b'0') + } +} + +impl std::str::FromStr for GitSha { + type Err = GitShaError; + + fn from_str(s: &str) -> Result { + if s.len() != 40 && s.len() != 64 { + return Err(GitShaError::BadLength(s.len())); + } + if !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(GitShaError::NotHex); + } + Ok(Self(s.to_ascii_lowercase())) + } +} + +impl TryFrom<&str> for GitSha { + type Error = GitShaError; + + fn try_from(s: &str) -> Result { + s.parse() + } +} + +impl TryFrom for GitSha { + type Error = GitShaError; + + fn try_from(s: String) -> Result { + s.parse() + } +} + +impl std::fmt::Display for GitSha { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for GitSha { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl serde::Serialize for GitSha { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for GitSha { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + /// A path that is not a git repository. #[derive(Debug, thiserror::Error)] #[error("`{path}` is not a git repository")] @@ -95,11 +187,12 @@ impl<'r, 'g, 'bin> GitBranch<'r, 'g, 'bin> { self.name.as_bstr() } - /// The commit the branch points at, as a hex object id. `None` if git fails. + /// The commit the branch points at. `None` if git fails or its output is + /// not a valid object id. #[tracing::instrument(skip(self))] - pub fn head_commit(&self) -> Option { + pub fn head_commit(&self) -> Option { let name = self.name.to_str().ok()?; - self.repo.run(&["rev-parse", name]) + self.repo.run(&["rev-parse", name])?.to_str().ok()?.parse().ok() } } @@ -182,6 +275,66 @@ mod tests { use super::*; use rstest::rstest; + #[rstest] + #[case::sha1_lower( + "0123456789abcdef0123456789abcdef01234567", + "0123456789abcdef0123456789abcdef01234567" + )] + #[case::sha1_upper( + "0123456789ABCDEF0123456789ABCDEF01234567", + "0123456789abcdef0123456789abcdef01234567" + )] + #[case::sha256( + "ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789", + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + )] + fn parses_and_lowercases(#[case] input: &str, #[case] expected: &str) { + let sha: GitSha = input.parse().unwrap(); + assert_eq!(sha.as_str(), expected); + } + + #[rstest] + #[case::empty(0)] + #[case::short(39)] + #[case::between(41)] + #[case::over(65)] + fn rejects_wrong_length(#[case] len: usize) { + assert_eq!("a".repeat(len).parse::(), Err(GitShaError::BadLength(len))); + } + + #[rstest] + fn rejects_non_hex() { + let with_g = format!("{}g", "a".repeat(39)); + assert_eq!(with_g.parse::(), Err(GitShaError::NotHex)); + } + + #[rstest] + #[case::sha1(40)] + #[case::sha256(64)] + fn zeros_are_the_null_oid(#[case] len: usize) { + let sha: GitSha = "0".repeat(len).parse().unwrap(); + assert!(sha.is_zero()); + } + + #[rstest] + fn non_zero_is_not_the_null_oid() { + let sha: GitSha = "0123456789abcdef0123456789abcdef01234567".parse().unwrap(); + assert!(!sha.is_zero()); + } + + #[rstest] + fn serde_round_trips_as_a_bare_string() { + let sha: GitSha = "0123456789abcdef0123456789abcdef01234567".parse().unwrap(); + let json = serde_json::to_string(&sha).unwrap(); + assert_eq!(json, "\"0123456789abcdef0123456789abcdef01234567\""); + assert_eq!(serde_json::from_str::(&json).unwrap(), sha); + } + + #[rstest] + fn deserialize_rejects_an_invalid_digest() { + assert!(serde_json::from_str::("\"not-a-sha\"").is_err()); + } + #[rstest] #[case::https("https://github.com/acme/web.git", Some("acme/web"))] #[case::https_no_suffix("https://github.com/acme/web", Some("acme/web"))] @@ -251,7 +404,7 @@ mod tests { let branch = repo.current_branch().unwrap(); assert_eq!(branch.name(), "main"); - assert_eq!(branch.head_commit().unwrap().len(), 40); + assert_eq!(branch.head_commit().unwrap().as_str().len(), 40); let remote = repo.remote("origin").unwrap(); assert_eq!(remote.name(), "origin"); diff --git a/crates/hm-core/src/exec/cloud/backend.rs b/crates/hm-core/src/exec/cloud/backend.rs index f998489b..ff97ad52 100644 --- a/crates/hm-core/src/exec/cloud/backend.rs +++ b/crates/hm-core/src/exec/cloud/backend.rs @@ -104,7 +104,7 @@ impl ExecutionBackend for CloudBackend { org: self.org.clone(), pipeline: slug, branch: req.source.branch.clone(), - commit: req.source.commit.clone(), + commit: req.source.commit.to_string(), message: req.source.message.clone(), pipeline_ir: req.plan.ir_json.clone(), // verbatim source_tgz, @@ -126,7 +126,7 @@ impl ExecutionBackend for CloudBackend { repo_name, source_slug: req.pipeline_slug.clone(), branch: req.source.branch.clone(), - commit: req.source.commit.clone(), + commit: req.source.commit.to_string(), message: req.source.message.clone(), pipeline_ir: req.plan.ir_json.clone(), // verbatim source_tgz, diff --git a/crates/hm-core/src/exec/request.rs b/crates/hm-core/src/exec/request.rs index 1259151b..67a7aa8a 100644 --- a/crates/hm-core/src/exec/request.rs +++ b/crates/hm-core/src/exec/request.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::time::Duration; +use hm_common::git::GitSha; use hm_pipeline_ir::PipelineGraph; use hm_plugin_protocol::events::PlanSummary; @@ -61,7 +62,7 @@ fn summarize(graph: &PipelineGraph) -> PlanSummary { #[derive(Debug, Clone)] pub struct SourceMeta { pub branch: String, - pub commit: String, + pub commit: GitSha, pub message: Option, /// `owner/repo` from the worktree's git remote, when one exists. `None` for /// a remoteless worktree; the cloud backend requires it to resolve the diff --git a/crates/hm-core/tests/backend_contract.rs b/crates/hm-core/tests/backend_contract.rs index bd120b1c..a592b376 100644 --- a/crates/hm-core/tests/backend_contract.rs +++ b/crates/hm-core/tests/backend_contract.rs @@ -8,6 +8,7 @@ )] use futures::StreamExt; +use hm_common::git::GitSha; use hm_core::exec::*; use hm_plugin_protocol::events::{BuildEvent, BuildRef}; use hm_plugin_protocol::ir::DurationMs; @@ -136,7 +137,7 @@ fn fake_request() -> RunRequest { env: Default::default(), source: SourceMeta { branch: "main".into(), - commit: "0".repeat(40), + commit: GitSha::zero(), message: None, repo_name: None, }, diff --git a/crates/hm/src/commands/cloud/verbs/run.rs b/crates/hm/src/commands/cloud/verbs/run.rs index 700a0496..06c5ae1c 100644 --- a/crates/hm/src/commands/cloud/verbs/run.rs +++ b/crates/hm/src/commands/cloud/verbs/run.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use anyhow::Result; use clap::Parser; use harmont_cloud::builds::NewBuild; +use hm_common::git::GitSha; use hm_core::app_ctx::AppCtx; use crate::commands::cloud::settings; @@ -24,12 +25,8 @@ pub struct RunArgs { #[arg(short, long, default_value = "main")] pub branch: String, /// Commit SHA to record on the build. - #[arg( - short, - long, - default_value = "0000000000000000000000000000000000000000" - )] - pub commit: String, + #[arg(short, long, default_value_t = GitSha::zero())] + pub commit: GitSha, /// Build message. #[arg(short, long)] pub message: Option, @@ -57,7 +54,7 @@ pub(crate) async fn run(env: &BTreeMap, args: RunArgs, app: &App org: org.clone(), pipeline: args.pipeline.clone(), branch: args.branch.clone(), - commit: args.commit.clone(), + commit: args.commit.to_string(), message: args.message.clone(), pipeline_ir, // Full worktree archiving lands in `hm run --cloud`. diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index 5aa6fb0d..fcfb0091 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use anyhow::{Context, Result}; use bstr::ByteSlice as _; -use hm_common::git::{GitBranch, GitRemote, GitRepo}; +use hm_common::git::{GitBranch, GitRemote, GitRepo, GitSha}; use hm_core::app_ctx::AppCtx; use hm_core::config::domain::BackendConfig; use hm_dsl_engine::{DslEngine, detect}; @@ -178,9 +178,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext<'_>) -> Result { let commit = head .as_ref() .and_then(GitBranch::head_commit) - .map(|c| c.to_str_lossy().into_owned()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "0".repeat(40)); + .unwrap_or_else(GitSha::zero); let repo_name = remote .as_ref() .and_then(GitRemote::gh_repo_name) From 1abd58717004dcff13610e8f343e4bbccf0be5a9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 26 Jul 2026 17:02:57 -0700 Subject: [PATCH 02/13] feat(hm-cloud): scaffold crate with the cloud CLI tree New crate that will own the whole cloud domain (executor + verbs). This first step lands the clap command tree only, no runtime: - CloudCommand { Auth{login,logout,whoami}, Org, Pipeline, Build, Job, Billing } with every arg documented. - Pipeline-identifying args are Option so an omitted --pipeline falls back to config.default_pipeline. - No submit verb: submission stays with hm run --cloud. --- Cargo.lock | 7 ++ Cargo.toml | 3 + crates/hm-cloud/Cargo.toml | 18 +++++ crates/hm-cloud/src/cli.rs | 162 +++++++++++++++++++++++++++++++++++++ crates/hm-cloud/src/lib.rs | 3 + 5 files changed, 193 insertions(+) create mode 100644 crates/hm-cloud/Cargo.toml create mode 100644 crates/hm-cloud/src/cli.rs create mode 100644 crates/hm-cloud/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index df60f48c..866229c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1249,6 +1249,13 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hm-cloud" +version = "0.0.0-dev" +dependencies = [ + "clap", +] + [[package]] name = "hm-common" version = "0.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index 89451b21..51ecd63a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/hm", + "crates/hm-cloud", "crates/hm-common", "crates/hm-core", "crates/hm-plugin-protocol", @@ -12,6 +13,7 @@ members = [ ] default-members = [ "crates/hm", + "crates/hm-cloud", "crates/hm-common", "crates/hm-core", "crates/hm-plugin-protocol", @@ -28,6 +30,7 @@ repository = "https://github.com/harmont-dev/harmont-cli" [workspace.dependencies] hm-core = { path = "crates/hm-core", version = "0.0.0-dev" } +hm-cloud = { path = "crates/hm-cloud", version = "0.0.0-dev" } hm-plugin-protocol = { path = "crates/hm-plugin-protocol", version = "0.0.0-dev" } hm-pipeline-ir = { path = "crates/hm-pipeline-ir", version = "0.0.0-dev" } hm-dsl-engine = { path = "crates/hm-dsl-engine", version = "0.0.0-dev" } diff --git a/crates/hm-cloud/Cargo.toml b/crates/hm-cloud/Cargo.toml new file mode 100644 index 00000000..21d0c910 --- /dev/null +++ b/crates/hm-cloud/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "hm-cloud" +version = "0.0.0-dev" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Harmont cloud execution backend and `hm cloud` command surface." +keywords = ["ci", "harmont", "cloud"] +categories = ["command-line-utilities"] + +[lib] +path = "src/lib.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } + +[lints] +workspace = true diff --git a/crates/hm-cloud/src/cli.rs b/crates/hm-cloud/src/cli.rs new file mode 100644 index 00000000..dc9015d0 --- /dev/null +++ b/crates/hm-cloud/src/cli.rs @@ -0,0 +1,162 @@ +//! The `hm cloud` command tree. + +use clap::Subcommand; + +#[derive(Debug, Clone, Subcommand)] +pub enum CloudCommand { + /// Authenticate and inspect the active session. + #[command(subcommand)] + Auth(AuthCommand), + /// Manage organizations. + #[command(subcommand)] + Org(OrgCommand), + /// Manage pipelines. + #[command(subcommand)] + Pipeline(PipelineCommand), + /// Manage builds. + #[command(subcommand)] + Build(BuildCommand), + /// Manage jobs. + #[command(subcommand)] + Job(JobCommand), + /// Manage credits, top-ups, and usage. + #[command(subcommand)] + Billing(BillingCommand), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum AuthCommand { + /// Authenticate this CLI against the Harmont API. + Login { + /// Skip the loopback flow and prompt for a paste-in code. + #[arg(long)] + paste: bool, + }, + /// Remove stored credentials. + Logout, + /// Show the authenticated user. + Whoami, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrgCommand { + /// Set the active organization. + Switch { + /// Organization slug. + slug: String, + }, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum PipelineCommand { + /// List pipelines for the active organization. + List, + /// Show pipeline details by slug. Defaults to the configured pipeline. + Show { + /// Pipeline slug; defaults to the configured pipeline. + slug: Option, + }, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum BuildCommand { + /// List builds for a pipeline. + List { + /// Pipeline slug; defaults to the configured pipeline. + #[arg(short, long)] + pipeline: Option, + }, + /// Show a build by number. + Show { + /// Pipeline slug; defaults to the configured pipeline. + #[arg(short, long)] + pipeline: Option, + /// Build number. + number: i64, + }, + /// Cancel a build. + Cancel { + /// Pipeline slug; defaults to the configured pipeline. + #[arg(short, long)] + pipeline: Option, + /// Build number. + number: i64, + }, + /// Watch a build until it reaches a terminal state. + Watch { + /// Pipeline slug; defaults to the configured pipeline. + #[arg(short, long)] + pipeline: Option, + /// Build number. + number: i64, + }, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum JobCommand { + /// List jobs in a build. + List { + /// Pipeline slug; defaults to the configured pipeline. + #[arg(short, long)] + pipeline: Option, + /// Build number. + #[arg(short, long)] + build: i64, + }, + /// Show a job by id. + Show { + /// Pipeline slug; defaults to the configured pipeline. + #[arg(short, long)] + pipeline: Option, + /// Build number. + #[arg(short, long)] + build: i64, + /// Job id. + job_id: String, + }, + /// Print the job log. + Log { + /// Pipeline slug; defaults to the configured pipeline. + #[arg(short, long)] + pipeline: Option, + /// Build number. + #[arg(short, long)] + build: i64, + /// Job id. + job_id: String, + }, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum BillingCommand { + /// Print the current credit balance. + Balance, + /// List billing transactions. + Transactions { + /// Maximum number of transactions to return. + #[arg(long, default_value = "100")] + limit: u32, + }, + /// Show usage over a time window. + Usage { + /// Start of the usage window. + #[arg(long)] + from: Option, + /// End of the usage window. + #[arg(long)] + to: Option, + }, + /// Top up credits via Stripe checkout. + Topup { + /// Amount to add, in whole US dollars. + amount_usd: u32, + /// Print the checkout URL instead of opening a browser. + #[arg(long)] + no_browser: bool, + }, + /// Redeem a coupon code. + Redeem { + /// Coupon code. + code: String, + }, +} diff --git a/crates/hm-cloud/src/lib.rs b/crates/hm-cloud/src/lib.rs new file mode 100644 index 00000000..90907bfe --- /dev/null +++ b/crates/hm-cloud/src/lib.rs @@ -0,0 +1,3 @@ +//! Harmont cloud: the cloud execution backend and `hm cloud` command surface. + +pub mod cli; From 3c0f0141c95479cdef7fa2c1cf022ca8ca8c332a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 26 Jul 2026 17:19:51 -0700 Subject: [PATCH 03/13] refactor(hm-common): store GitSha as raw SHA-1 bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Represent a commit id as its 20 raw bytes ([u8; 20], Copy) instead of a heap String, with hex only at the edges: decode via the hex crate on parse, encode on Display/serde. Drops the speculative SHA-256 width — git and Harmont's repos are SHA-1; promote to an enum if one ever appears. --- Cargo.lock | 1 + crates/hm-common/Cargo.toml | 1 + crates/hm-common/src/git.rs | 91 ++++++++++++++++--------------------- 3 files changed, 42 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 866229c5..65dabdb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1266,6 +1266,7 @@ dependencies = [ "chrono", "derive_more", "dirs", + "hex", "include_dir", "num-traits", "proptest", diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml index 13eaf7f8..25f19fca 100644 --- a/crates/hm-common/Cargo.toml +++ b/crates/hm-common/Cargo.toml @@ -21,6 +21,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } bstr = "1" +hex = "0.4" include_dir = "0.7" num-traits = { workspace = true } tempfile = "3" diff --git a/crates/hm-common/src/git.rs b/crates/hm-common/src/git.rs index 97310f96..33adffa3 100644 --- a/crates/hm-common/src/git.rs +++ b/crates/hm-common/src/git.rs @@ -7,16 +7,16 @@ use bstr::{BStr, BString, ByteSlice}; use crate::process::{CapturedStreams as _, CommandExt as _}; -/// A git object identifier: a full 40-char SHA-1 or 64-char SHA-256 hex -/// digest, normalized to lowercase. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct GitSha(String); +/// A git object identifier: a SHA-1 digest, stored as its 20 raw bytes and +/// rendered as 40 lowercase hex characters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GitSha([u8; 20]); /// A string that is not a valid [`GitSha`]. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum GitShaError { - /// The digest is not 40 (SHA-1) or 64 (SHA-256) hex characters long. - #[error("git sha must be 40 or 64 hex chars, got {0}")] + /// The digest is not 40 hex characters long. + #[error("git sha must be 40 hex chars, got {0}")] BadLength(usize), /// The digest contains a non-hex character. #[error("git sha contains a non-hex character")] @@ -24,23 +24,16 @@ pub enum GitShaError { } impl GitSha { - /// The all-zero SHA-1 null oid (`0000…`, 40 chars) git uses to mean - /// "no commit". + /// The all-zero null oid git uses to mean "no commit". #[must_use] - pub fn zero() -> Self { - Self("0".repeat(40)) - } - - /// The digest as a lowercase hex string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 + pub const fn zero() -> Self { + Self([0; 20]) } /// Whether this is the all-zero null oid git uses to mean "no commit". #[must_use] pub fn is_zero(&self) -> bool { - self.0.bytes().all(|b| b == b'0') + self.0 == [0; 20] } } @@ -48,13 +41,12 @@ impl std::str::FromStr for GitSha { type Err = GitShaError; fn from_str(s: &str) -> Result { - if s.len() != 40 && s.len() != 64 { + if s.len() != 40 { return Err(GitShaError::BadLength(s.len())); } - if !s.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(GitShaError::NotHex); - } - Ok(Self(s.to_ascii_lowercase())) + let mut bytes = [0; 20]; + hex::decode_to_slice(s, &mut bytes).map_err(|_| GitShaError::NotHex)?; + Ok(Self(bytes)) } } @@ -76,19 +68,16 @@ impl TryFrom for GitSha { impl std::fmt::Display for GitSha { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl AsRef for GitSha { - fn as_ref(&self) -> &str { - &self.0 + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) } } impl serde::Serialize for GitSha { fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.0) + serializer.collect_str(self) } } @@ -273,31 +262,22 @@ fn parse_default_branch(line: &BStr, remote: &str) -> Option { #[allow(clippy::unwrap_used, reason = "test setup and assertions")] mod tests { use super::*; + use proptest::prelude::*; use rstest::rstest; #[rstest] - #[case::sha1_lower( - "0123456789abcdef0123456789abcdef01234567", - "0123456789abcdef0123456789abcdef01234567" - )] - #[case::sha1_upper( - "0123456789ABCDEF0123456789ABCDEF01234567", - "0123456789abcdef0123456789abcdef01234567" - )] - #[case::sha256( - "ABCDEF0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789", - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" - )] - fn parses_and_lowercases(#[case] input: &str, #[case] expected: &str) { + #[case::lower("0123456789abcdef0123456789abcdef01234567")] + #[case::upper("0123456789ABCDEF0123456789ABCDEF01234567")] + fn parses_and_renders_lowercase(#[case] input: &str) { let sha: GitSha = input.parse().unwrap(); - assert_eq!(sha.as_str(), expected); + assert_eq!(sha.to_string(), input.to_ascii_lowercase()); } #[rstest] #[case::empty(0)] #[case::short(39)] - #[case::between(41)] - #[case::over(65)] + #[case::just_over(41)] + #[case::sha256_width(64)] fn rejects_wrong_length(#[case] len: usize) { assert_eq!("a".repeat(len).parse::(), Err(GitShaError::BadLength(len))); } @@ -309,11 +289,10 @@ mod tests { } #[rstest] - #[case::sha1(40)] - #[case::sha256(64)] - fn zeros_are_the_null_oid(#[case] len: usize) { - let sha: GitSha = "0".repeat(len).parse().unwrap(); + fn zero_is_the_null_oid() { + let sha: GitSha = "0".repeat(40).parse().unwrap(); assert!(sha.is_zero()); + assert_eq!(sha, GitSha::zero()); } #[rstest] @@ -335,6 +314,16 @@ mod tests { assert!(serde_json::from_str::("\"not-a-sha\"").is_err()); } + proptest! { + #[test] + fn every_digest_round_trips_through_hex(bytes in any::<[u8; 20]>()) { + let sha = GitSha(bytes); + let hex = sha.to_string(); + prop_assert_eq!(hex.len(), 40); + prop_assert_eq!(hex.parse::().unwrap(), sha); + } + } + #[rstest] #[case::https("https://github.com/acme/web.git", Some("acme/web"))] #[case::https_no_suffix("https://github.com/acme/web", Some("acme/web"))] @@ -404,7 +393,7 @@ mod tests { let branch = repo.current_branch().unwrap(); assert_eq!(branch.name(), "main"); - assert_eq!(branch.head_commit().unwrap().as_str().len(), 40); + assert!(!branch.head_commit().unwrap().is_zero()); let remote = repo.remote("origin").unwrap(); assert_eq!(remote.name(), "origin"); From 435f6d1ba360c6f3b29e7e02465e075e021856d9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 26 Jul 2026 17:29:07 -0700 Subject: [PATCH 04/13] feat(hm): drive hm cloud from the hm-cloud crate's CLI The binary now consumes hm_cloud::cli::CloudCommand instead of a duplicate enum tree, so hm-cloud's cli.rs is the single definition. Adopting it applies the shape the scaffold settled on: - login/logout/whoami move under `hm cloud auth`. - `hm cloud run` is dropped; submission stays with `hm run --cloud`. - --pipeline is optional everywhere, resolving to the project's default_pipeline (via ProjectCtx) with a clear error when neither is set. Dispatch and the verbs adapt to the new enums; verbs/run.rs is deleted. --- Cargo.lock | 1 + crates/hm/Cargo.toml | 1 + crates/hm/src/cli/mod.rs | 2 +- crates/hm/src/commands/cloud/cli.rs | 141 +----------------- crates/hm/src/commands/cloud/settings.rs | 21 +++ crates/hm/src/commands/cloud/verbs/billing.rs | 2 +- crates/hm/src/commands/cloud/verbs/build.rs | 22 ++- crates/hm/src/commands/cloud/verbs/job.rs | 17 ++- crates/hm/src/commands/cloud/verbs/mod.rs | 1 - crates/hm/src/commands/cloud/verbs/org.rs | 2 +- .../hm/src/commands/cloud/verbs/pipeline.rs | 7 +- crates/hm/src/commands/cloud/verbs/run.rs | 83 ----------- crates/hm/tests/cmd_cloud_gate.rs | 16 +- 13 files changed, 82 insertions(+), 234 deletions(-) delete mode 100644 crates/hm/src/commands/cloud/verbs/run.rs diff --git a/Cargo.lock b/Cargo.lock index 65dabdb4..c84630da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1125,6 +1125,7 @@ dependencies = [ "harmont-cloud", "harmont-cloud-raw", "hex", + "hm-cloud", "hm-common", "hm-core", "hm-dsl-engine", diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index 2301d04c..73f8bffa 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -62,6 +62,7 @@ hm-pipeline-ir = { workspace = true } hm-plugin-protocol = { workspace = true } hm-common = { workspace = true } hm-core = { workspace = true } +hm-cloud = { workspace = true } harmont-cloud = { workspace = true } harmont-cloud-raw = { workspace = true } hm-vm = { workspace = true, features = ["docker-backend"] } diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index 60fe3b8a..ddc247c9 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -70,7 +70,7 @@ pub enum Command { /// Interact with the Harmont cloud API. #[command(subcommand)] - Cloud(crate::commands::cloud::cli::CloudCommand), + Cloud(hm_cloud::cli::CloudCommand), } #[derive(Debug, Clone, Subcommand)] diff --git a/crates/hm/src/commands/cloud/cli.rs b/crates/hm/src/commands/cloud/cli.rs index 915fa3aa..1920e5ee 100644 --- a/crates/hm/src/commands/cloud/cli.rs +++ b/crates/hm/src/commands/cloud/cli.rs @@ -1,9 +1,9 @@ -//! CLI parsing for cloud subcommands. +//! Dispatch for `hm cloud` subcommands. use std::collections::BTreeMap; use anyhow::Result; -use clap::Subcommand; +use hm_cloud::cli::{AuthCommand, CloudCommand}; use hm_core::app_ctx::AppCtx; use crate::commands::cloud::{auth, verbs}; @@ -25,134 +25,6 @@ impl From for i32 { } } -#[derive(Debug, Clone, Subcommand)] -pub enum CloudCommand { - /// Authenticate this CLI against the Harmont API. - Login { - /// Skip the loopback flow and prompt for a paste-in code. - #[arg(long)] - paste: bool, - }, - /// Remove stored credentials. - Logout, - /// Show the authenticated user. - Whoami, - /// Manage organizations. - #[command(subcommand)] - Org(OrgCommand), - /// Manage pipelines. - #[command(subcommand)] - Pipeline(PipelineCommand), - /// Manage builds. - #[command(subcommand)] - Build(BuildCommand), - /// Manage jobs. - #[command(subcommand)] - Job(JobCommand), - /// Manage credits, top-ups, and usage. - #[command(subcommand)] - Billing(BillingCommand), - /// Submit the local pipeline to the cloud and watch its build. - Run(verbs::run::RunArgs), -} - -#[derive(Debug, Clone, Subcommand)] -pub enum OrgCommand { - /// Set the active organization. - Switch { - /// Organization slug. - slug: String, - }, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum PipelineCommand { - /// List pipelines for the active organization. - List, - /// Show pipeline details by slug. - Show { slug: String }, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum BuildCommand { - /// List builds for a pipeline. - List { - #[arg(short, long)] - pipeline: String, - }, - /// Show a build by number. - Show { - #[arg(short, long)] - pipeline: String, - number: i64, - }, - /// Cancel a build. - Cancel { - #[arg(short, long)] - pipeline: String, - number: i64, - }, - /// Watch a build until it reaches a terminal state. - Watch { - #[arg(short, long)] - pipeline: String, - number: i64, - }, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum JobCommand { - /// List jobs in a build. - List { - #[arg(short, long)] - pipeline: String, - #[arg(short, long)] - build: i64, - }, - /// Show a job by id. - Show { - #[arg(short, long)] - pipeline: String, - #[arg(short, long)] - build: i64, - job_id: String, - }, - /// Print the job log. - Log { - #[arg(short, long)] - pipeline: String, - #[arg(short, long)] - build: i64, - job_id: String, - }, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum BillingCommand { - /// Print the current credit balance. - Balance, - /// List billing transactions. - Transactions { - #[arg(long, default_value = "100")] - limit: u32, - }, - /// Show usage over a time window. - Usage { - #[arg(long)] - from: Option, - #[arg(long)] - to: Option, - }, - /// Top up credits via Stripe checkout. - Topup { - amount_usd: u32, - #[arg(long)] - no_browser: bool, - }, - /// Redeem a coupon code. - Redeem { code: String }, -} - /// Dispatch a parsed `CloudCommand`, returning its process exit code. /// /// # Errors @@ -165,15 +37,16 @@ pub async fn dispatch_command( app: &AppCtx, ) -> Result { let result = match command { - 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::Auth(cmd) => match cmd { + AuthCommand::Login { paste } => auth::login::run(&env, paste, app).await, + AuthCommand::Logout => auth::logout::run(&env, app).await, + AuthCommand::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/src/commands/cloud/settings.rs b/crates/hm/src/commands/cloud/settings.rs index d7a36759..2e03011d 100644 --- a/crates/hm/src/commands/cloud/settings.rs +++ b/crates/hm/src/commands/cloud/settings.rs @@ -70,6 +70,27 @@ pub async fn client(app: &AppCtx) -> Result<(HarmontClient, ResolvedCtx)> { Ok((client, ResolvedCtx { api, domain, org })) } +/// Resolve the pipeline slug for a verb: the explicit `--pipeline`, else the +/// project's configured `default_pipeline`. +/// +/// # Errors +/// +/// Returns an error if neither is set, or the project config cannot be loaded. +pub async fn resolve_pipeline(app: &AppCtx, explicit: Option) -> Result { + if let Some(slug) = explicit { + return Ok(slug); + } + let project = hm_core::project_ctx::ProjectCtx::at(app, app.cwd().to_path_buf()).await?; + let default = match &project.config().backend { + BackendConfig::Cloud(cloud) => cloud.default_pipeline.clone(), + BackendConfig::Docker => None, + }; + default.context( + "no pipeline given and no default configured — pass --pipeline , or set a \ + default_pipeline in .hm/config.toml", + ) +} + /// An anonymous client (for the login flow) + the resolved cloud domain. #[must_use] pub fn anon_client(app: &AppCtx) -> (HarmontClient, BackendDomain) { diff --git a/crates/hm/src/commands/cloud/verbs/billing.rs b/crates/hm/src/commands/cloud/verbs/billing.rs index dff8bd4a..5592605b 100644 --- a/crates/hm/src/commands/cloud/verbs/billing.rs +++ b/crates/hm/src/commands/cloud/verbs/billing.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use anyhow::Result; use harmont_cloud::HarmontClient; -use crate::commands::cloud::cli::BillingCommand; +use hm_cloud::cli::BillingCommand; use crate::commands::cloud::settings; use hm_core::app_ctx::AppCtx; diff --git a/crates/hm/src/commands/cloud/verbs/build.rs b/crates/hm/src/commands/cloud/verbs/build.rs index fe0d3577..97ef7c16 100644 --- a/crates/hm/src/commands/cloud/verbs/build.rs +++ b/crates/hm/src/commands/cloud/verbs/build.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use anyhow::Result; use harmont_cloud::HarmontClient; -use crate::commands::cloud::cli::BuildCommand; +use hm_cloud::cli::BuildCommand; use crate::commands::cloud::settings; use hm_core::app_ctx::AppCtx; use hm_core::exec::cloud::watch::watch_build; @@ -19,10 +19,22 @@ pub(crate) async fn run( let org = ctx.org()?; match cmd { - BuildCommand::List { pipeline } => list(&client, &org, &pipeline).await, - BuildCommand::Show { pipeline, number } => show(&client, &org, &pipeline, number).await, - BuildCommand::Cancel { pipeline, number } => cancel(&client, &org, &pipeline, number).await, - BuildCommand::Watch { pipeline, number } => watch(&client, &org, &pipeline, number).await, + BuildCommand::List { pipeline } => { + let pipe = settings::resolve_pipeline(app, pipeline).await?; + list(&client, &org, &pipe).await + } + BuildCommand::Show { pipeline, number } => { + let pipe = settings::resolve_pipeline(app, pipeline).await?; + show(&client, &org, &pipe, number).await + } + BuildCommand::Cancel { pipeline, number } => { + let pipe = settings::resolve_pipeline(app, pipeline).await?; + cancel(&client, &org, &pipe, number).await + } + BuildCommand::Watch { pipeline, number } => { + let pipe = settings::resolve_pipeline(app, pipeline).await?; + watch(&client, &org, &pipe, number).await + } } } diff --git a/crates/hm/src/commands/cloud/verbs/job.rs b/crates/hm/src/commands/cloud/verbs/job.rs index 86649ae6..ae149790 100644 --- a/crates/hm/src/commands/cloud/verbs/job.rs +++ b/crates/hm/src/commands/cloud/verbs/job.rs @@ -9,7 +9,7 @@ use hm_plugin_protocol::events::{BuildEvent, PlanSummary}; use hm_plugin_protocol::ir::DurationMs; use uuid::Uuid; -use crate::commands::cloud::cli::JobCommand; +use hm_cloud::cli::JobCommand; use crate::commands::cloud::settings; use hm_core::app_ctx::AppCtx; use hm_core::exec::cloud::watch::stream_job_logs_as_events; @@ -23,17 +23,26 @@ pub(crate) async fn run( let org = ctx.org()?; match cmd { - JobCommand::List { pipeline, build } => list(&client, &org, &pipeline, build).await, + JobCommand::List { pipeline, build } => { + let pipe = settings::resolve_pipeline(app, pipeline).await?; + list(&client, &org, &pipe, build).await + } JobCommand::Show { pipeline, build, job_id, - } => show(&client, &org, &pipeline, build, &job_id).await, + } => { + let pipe = settings::resolve_pipeline(app, pipeline).await?; + show(&client, &org, &pipe, build, &job_id).await + } JobCommand::Log { pipeline, build, job_id, - } => log_cmd(&client, &org, &pipeline, build, &job_id).await, + } => { + let pipe = settings::resolve_pipeline(app, pipeline).await?; + log_cmd(&client, &org, &pipe, build, &job_id).await + } } } diff --git a/crates/hm/src/commands/cloud/verbs/mod.rs b/crates/hm/src/commands/cloud/verbs/mod.rs index cbfe40dc..6b0b1b6a 100644 --- a/crates/hm/src/commands/cloud/verbs/mod.rs +++ b/crates/hm/src/commands/cloud/verbs/mod.rs @@ -6,4 +6,3 @@ pub(crate) mod build; pub(crate) mod job; pub(crate) mod org; pub(crate) mod pipeline; -pub(crate) mod run; diff --git a/crates/hm/src/commands/cloud/verbs/org.rs b/crates/hm/src/commands/cloud/verbs/org.rs index 7ff04df4..a531cc35 100644 --- a/crates/hm/src/commands/cloud/verbs/org.rs +++ b/crates/hm/src/commands/cloud/verbs/org.rs @@ -7,7 +7,7 @@ use hm_core::app_ctx::AppCtx; use hm_core::config::domain::BackendConfig; use hm_core::config::user::UserCloudConfig; -use crate::commands::cloud::cli::OrgCommand; +use hm_cloud::cli::OrgCommand; use crate::commands::cloud::settings; pub(crate) async fn run( diff --git a/crates/hm/src/commands/cloud/verbs/pipeline.rs b/crates/hm/src/commands/cloud/verbs/pipeline.rs index ebd95556..39bf62a4 100644 --- a/crates/hm/src/commands/cloud/verbs/pipeline.rs +++ b/crates/hm/src/commands/cloud/verbs/pipeline.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use anyhow::Result; use harmont_cloud::HarmontClient; -use crate::commands::cloud::cli::PipelineCommand; +use hm_cloud::cli::PipelineCommand; use crate::commands::cloud::settings; use hm_core::app_ctx::AppCtx; @@ -19,7 +19,10 @@ pub(crate) async fn run( match cmd { PipelineCommand::List => list(&client, &org).await, - PipelineCommand::Show { slug } => show(&client, &org, &slug).await, + PipelineCommand::Show { slug } => { + let slug = settings::resolve_pipeline(app, slug).await?; + show(&client, &org, &slug).await + } } } diff --git a/crates/hm/src/commands/cloud/verbs/run.rs b/crates/hm/src/commands/cloud/verbs/run.rs deleted file mode 100644 index 06c5ae1c..00000000 --- a/crates/hm/src/commands/cloud/verbs/run.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! `hm cloud run ` — submit a pre-rendered pipeline plan to the -//! cloud and watch the resulting build. -//! -//! This is the minimal, file-based path: the caller supplies a pre-rendered v0 -//! IR plan via `--plan-file` (or `plan.json` by convention) and **no source -//! archive** is uploaded. The full local-worktree flow — rendering the DSL and -//! archiving the working tree — is implemented by `hm run --cloud`, which -//! lives in the `hm` crate where the renderer and archiver are. - -use std::collections::BTreeMap; - -use anyhow::Result; -use clap::Parser; -use harmont_cloud::builds::NewBuild; -use hm_common::git::GitSha; -use hm_core::app_ctx::AppCtx; - -use crate::commands::cloud::settings; - -#[derive(Debug, Clone, Parser)] -pub struct RunArgs { - /// Pipeline slug. Required. - pub pipeline: String, - /// Branch to record on the build. - #[arg(short, long, default_value = "main")] - pub branch: String, - /// Commit SHA to record on the build. - #[arg(short, long, default_value_t = GitSha::zero())] - pub commit: GitSha, - /// Build message. - #[arg(short, long)] - pub message: Option, - /// Path to a pre-rendered v0 IR plan file. Defaults to `plan.json`. - #[arg(long)] - pub plan_file: Option, - /// Don't watch; print the build number and exit. - #[arg(long)] - pub no_watch: bool, -} - -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"); - let pipeline_ir = std::fs::read_to_string(plan_path) - .map_err(|e| anyhow::anyhow!("could not read plan file '{plan_path}': {e}"))?; - // Validate it parses as JSON before we ship it. - serde_json::from_str::(&pipeline_ir) - .map_err(|e| anyhow::anyhow!("invalid JSON in plan file '{plan_path}': {e}"))?; - - let build = client - .submit_build(NewBuild { - org: org.clone(), - pipeline: args.pipeline.clone(), - branch: args.branch.clone(), - commit: args.commit.to_string(), - message: args.message.clone(), - pipeline_ir, - // Full worktree archiving lands in `hm run --cloud`. - source_tgz: Vec::new(), - env: env - .iter() - .filter(|(k, _)| k.starts_with("HM_RUN_ENV_")) - .map(|(k, v)| (k.trim_start_matches("HM_RUN_ENV_").to_string(), v.clone())) - .collect(), - }) - .await?; - - tracing::info!("submitted build #{}", build.number); - if args.no_watch { - return Ok(()); - } - crate::commands::cloud::verbs::build::run( - env, - crate::commands::cloud::cli::BuildCommand::Watch { - pipeline: args.pipeline.clone(), - number: build.number, - }, - app, - ) - .await -} diff --git a/crates/hm/tests/cmd_cloud_gate.rs b/crates/hm/tests/cmd_cloud_gate.rs index c50736dd..911bc2de 100644 --- a/crates/hm/tests/cmd_cloud_gate.rs +++ b/crates/hm/tests/cmd_cloud_gate.rs @@ -42,8 +42,20 @@ fn cloud_help_lists_real_subcommands_without_waitlist_text() { .args(["cloud", "--help"]) .assert() .success() - .stdout(contains("login")) - .stdout(contains("whoami")) + .stdout(contains("auth")) .stdout(contains("build")) .stdout(predicates::str::contains("not yet available").not()); } + +/// `hm cloud auth --help` lists the session verbs grouped under `auth`. +#[rstest] +fn cloud_auth_help_lists_session_verbs() { + Command::cargo_bin("hm") + .unwrap() + .args(["cloud", "auth", "--help"]) + .assert() + .success() + .stdout(contains("login")) + .stdout(contains("logout")) + .stdout(contains("whoami")); +} From 557915cc944049022b32b19d33a5566ad165af6e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 26 Jul 2026 21:54:45 -0700 Subject: [PATCH 05/13] feat(hm-core): add Term for terminal and environment detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the process's runtime environment once at startup — stdin/stdout/ stderr TTY state, CI, SSH, and display presence — behind AppCtx::term(). is_interactive() and has_gui() give the CLI honest signals for prompting and for choosing browser vs. terminal fallbacks (an SSH session is interactive but has no local browser). Consolidates the scattered TTY detection: hm-render's color_enabled now takes the stderr-tty bit from Term, and the run/cloud render paths read Term instead of calling std::io::IsTerminal ad hoc. --- Cargo.lock | 28 +++++++ crates/hm-core/Cargo.toml | 1 + crates/hm-core/src/app_ctx.rs | 9 +++ crates/hm-core/src/lib.rs | 1 + crates/hm-core/src/term.rs | 89 +++++++++++++++++++++ crates/hm-render/src/lib.rs | 25 ++---- crates/hm/src/commands/cloud/settings.rs | 9 ++- crates/hm/src/commands/cloud/verbs/build.rs | 7 +- crates/hm/src/commands/cloud/verbs/job.rs | 6 +- crates/hm/src/commands/run/mod.rs | 4 +- crates/hm/src/context.rs | 7 +- 11 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 crates/hm-core/src/term.rs diff --git a/Cargo.lock b/Cargo.lock index c84630da..fc0113ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1255,6 +1255,15 @@ name = "hm-cloud" version = "0.0.0-dev" dependencies = [ "clap", + "dialoguer", + "harmont-cloud", + "hm-common", + "hm-core", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "webbrowser", ] [[package]] @@ -1263,6 +1272,7 @@ version = "0.0.0-dev" dependencies = [ "anyhow", "async-trait", + "base64", "bstr", "chrono", "derive_more", @@ -1271,9 +1281,11 @@ dependencies = [ "include_dir", "num-traits", "proptest", + "rand 0.8.6", "rstest", "serde", "serde_json", + "subtle", "tempfile", "thiserror 2.0.18", "tokio", @@ -1282,6 +1294,7 @@ dependencies = [ "unicode-width", "which", "windows", + "zeroize", ] [[package]] @@ -1304,6 +1317,7 @@ dependencies = [ "hm-vm", "human-units", "ignore", + "is_ci", "rstest", "secrecy", "serde", @@ -4413,6 +4427,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/crates/hm-core/Cargo.toml b/crates/hm-core/Cargo.toml index 82338dd8..c9a4bc41 100644 --- a/crates/hm-core/Cargo.toml +++ b/crates/hm-core/Cargo.toml @@ -8,6 +8,7 @@ description = "Core of the hm CLI: layered config/credentials + pluggable CI exe [dependencies] hm-common = { workspace = true } +is_ci = "1.2.0" hm-plugin-protocol = { workspace = true } hm-pipeline-ir = { workspace = true } hm-vm = { workspace = true, features = ["docker-backend"] } diff --git a/crates/hm-core/src/app_ctx.rs b/crates/hm-core/src/app_ctx.rs index e2fca3eb..ba26ea9b 100644 --- a/crates/hm-core/src/app_ctx.rs +++ b/crates/hm-core/src/app_ctx.rs @@ -11,6 +11,7 @@ use hm_common::python::Python; use crate::config::domain::ConfigLoadingError; use crate::config::user::UserConfig; use crate::creds::{CredsInitError, CredsProvider}; +use crate::term::Term; /// Failure to initialize the [`AppCtx`]. #[derive(Debug, thiserror::Error)] @@ -41,6 +42,7 @@ pub struct AppCtx { dirs: DirProvider, user_config: Option, creds: CredsProvider, + term: Term, } impl AppCtx { @@ -75,6 +77,7 @@ impl AppCtx { dirs, user_config, creds, + term: Term::detect(), }) } @@ -108,6 +111,12 @@ impl AppCtx { &self.dirs } + /// The terminal environment captured at initialization. + #[must_use] + pub const fn term(&self) -> Term { + self.term + } + /// The user config, or `None` when no user config file is present. #[must_use] pub const fn user_config(&self) -> Option<&UserConfig> { diff --git a/crates/hm-core/src/lib.rs b/crates/hm-core/src/lib.rs index 746fedd0..0775a8f1 100644 --- a/crates/hm-core/src/lib.rs +++ b/crates/hm-core/src/lib.rs @@ -11,3 +11,4 @@ pub mod config; pub mod creds; pub mod exec; pub mod project_ctx; +pub mod term; diff --git a/crates/hm-core/src/term.rs b/crates/hm-core/src/term.rs new file mode 100644 index 00000000..0458781d --- /dev/null +++ b/crates/hm-core/src/term.rs @@ -0,0 +1,89 @@ +//! The process's runtime environment: terminal, session, and display facts +//! captured at startup. + +use std::io::IsTerminal as _; + +/// The runtime environment facts, captured once at startup. +#[derive(Debug, Clone, Copy)] +#[allow( + clippy::struct_excessive_bools, + reason = "independent environment facts, not a packed state machine" +)] +pub struct Term { + stdin: bool, + stdout: bool, + stderr: bool, + ci: bool, + ssh: bool, + display: bool, +} + +impl Term { + /// Capture the environment from the standard streams, CI signals, the SSH + /// session variables, and the display variables. + #[must_use] + pub fn detect() -> Self { + Self { + stdin: std::io::stdin().is_terminal(), + stdout: std::io::stdout().is_terminal(), + stderr: std::io::stderr().is_terminal(), + ci: is_ci::cached(), + ssh: env_present("SSH_CONNECTION") + || env_present("SSH_TTY") + || env_present("SSH_CLIENT"), + display: env_present("DISPLAY") || env_present("WAYLAND_DISPLAY"), + } + } + + /// Whether the app can drive an interactive session: stdin and stdout are + /// both terminals and no CI runner is present. + #[must_use] + pub const fn is_interactive(&self) -> bool { + self.stdin && self.stdout && !self.ci + } + + /// Whether stdin is a terminal. + #[must_use] + pub const fn stdin_is_tty(&self) -> bool { + self.stdin + } + + /// Whether stdout is a terminal. + #[must_use] + pub const fn stdout_is_tty(&self) -> bool { + self.stdout + } + + /// Whether stderr is a terminal. + #[must_use] + pub const fn stderr_is_tty(&self) -> bool { + self.stderr + } + + /// Whether a CI runner is detected. + #[must_use] + pub const fn is_ci(&self) -> bool { + self.ci + } + + /// Whether the process is running inside an SSH session. + #[must_use] + pub const fn is_ssh(&self) -> bool { + self.ssh + } + + /// Whether a graphical browser could plausibly be opened for the user. + #[must_use] + pub const fn has_gui(&self) -> bool { + if cfg!(any(target_os = "macos", target_os = "windows")) { + !self.ssh + } else { + self.display + } + } +} + +/// Whether an environment variable is set to a non-empty value. +fn env_present(name: &str) -> bool { + std::env::var_os(name).is_some_and(|v| !v.is_empty()) +} diff --git a/crates/hm-render/src/lib.rs b/crates/hm-render/src/lib.rs index 42d4599d..edd2db21 100644 --- a/crates/hm-render/src/lib.rs +++ b/crates/hm-render/src/lib.rs @@ -7,30 +7,15 @@ //! on `hm` internals (no `RunContext`, no Docker types). use std::fmt; -use std::io::IsTerminal; use hm_plugin_protocol::BuildEvent; -/// Whether ANSI color should be used: honors an explicit no-color flag, -/// the `NO_COLOR` env convention, and whether stderr is a TTY. -/// -/// Single source of truth for the color rule, shared by the `hm` run -/// context and the cloud commands' render preferences. -#[must_use] -pub fn color_enabled(no_color_flag: bool) -> bool { - !no_color_flag && std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() -} - -/// Whether stderr is an interactive terminal (drives the progress view). -#[must_use] -pub fn stderr_interactive() -> bool { - std::io::stderr().is_terminal() -} - -/// Whether stdout is NOT a TTY (i.e. piped) — used to force the streaming log view. +/// Whether ANSI color should be used, given an explicit no-color flag and +/// whether stderr is a TTY: enabled when the flag is unset, `NO_COLOR` is +/// unset, and stderr is a terminal. #[must_use] -pub fn stdout_piped() -> bool { - !std::io::stdout().is_terminal() +pub fn color_enabled(no_color_flag: bool, stderr_is_tty: bool) -> bool { + !no_color_flag && std::env::var_os("NO_COLOR").is_none() && stderr_is_tty } pub mod human; diff --git a/crates/hm/src/commands/cloud/settings.rs b/crates/hm/src/commands/cloud/settings.rs index 2e03011d..9460f247 100644 --- a/crates/hm/src/commands/cloud/settings.rs +++ b/crates/hm/src/commands/cloud/settings.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result}; use harmont_cloud::HarmontClient; use hm_core::app_ctx::AppCtx; use hm_core::config::domain::{BackendConfig, BackendDomain}; +use hm_core::term::Term; use hm_core::config::user::UserCloudConfig; use secrecy::ExposeSecret as _; @@ -114,12 +115,12 @@ pub struct RenderPrefs { } impl RenderPrefs { - /// Detect render preferences from the current `NO_COLOR` and TTY state. + /// Derive render preferences from the terminal state and `NO_COLOR`. #[must_use] - pub fn detect() -> Self { + pub fn detect(term: Term) -> Self { Self { - color: hm_render::color_enabled(false), - logs: hm_render::stdout_piped(), + color: hm_render::color_enabled(false, term.stderr_is_tty()), + logs: !term.stdout_is_tty(), } } } diff --git a/crates/hm/src/commands/cloud/verbs/build.rs b/crates/hm/src/commands/cloud/verbs/build.rs index 97ef7c16..7940e154 100644 --- a/crates/hm/src/commands/cloud/verbs/build.rs +++ b/crates/hm/src/commands/cloud/verbs/build.rs @@ -9,6 +9,7 @@ use hm_cloud::cli::BuildCommand; use crate::commands::cloud::settings; use hm_core::app_ctx::AppCtx; use hm_core::exec::cloud::watch::watch_build; +use hm_core::term::Term; pub(crate) async fn run( _env: &BTreeMap, @@ -33,7 +34,7 @@ pub(crate) async fn run( } BuildCommand::Watch { pipeline, number } => { let pipe = settings::resolve_pipeline(app, pipeline).await?; - watch(&client, &org, &pipe, number).await + watch(&client, &org, &pipe, number, app.term()).await } } } @@ -69,11 +70,11 @@ async fn cancel(client: &HarmontClient, org: &str, pipe: &str, number: i64) -> R Ok(()) } -async fn watch(client: &HarmontClient, org: &str, pipe: &str, number: i64) -> Result<()> { +async fn watch(client: &HarmontClient, org: &str, pipe: &str, number: i64, term: Term) -> Result<()> { // Render the live build through the shared `hm-render` renderers (the same // ones a local `hm run` uses), driven by the `BuildEvent`s `watch_build` // emits over an mpsc channel. - let prefs = crate::commands::cloud::settings::RenderPrefs::detect(); + let prefs = crate::commands::cloud::settings::RenderPrefs::detect(term); let renderer = hm_render::renderer_for("human", prefs.color, prefs.logs)?; let (tx, rx) = tokio::sync::mpsc::channel(1024); let driver = tokio::spawn(hm_render::drive(renderer, rx)); diff --git a/crates/hm/src/commands/cloud/verbs/job.rs b/crates/hm/src/commands/cloud/verbs/job.rs index ae149790..2a9403f8 100644 --- a/crates/hm/src/commands/cloud/verbs/job.rs +++ b/crates/hm/src/commands/cloud/verbs/job.rs @@ -13,6 +13,7 @@ use hm_cloud::cli::JobCommand; use crate::commands::cloud::settings; use hm_core::app_ctx::AppCtx; use hm_core::exec::cloud::watch::stream_job_logs_as_events; +use hm_core::term::Term; pub(crate) async fn run( _env: &BTreeMap, @@ -41,7 +42,7 @@ pub(crate) async fn run( job_id, } => { let pipe = settings::resolve_pipeline(app, pipeline).await?; - log_cmd(&client, &org, &pipe, build, &job_id).await + log_cmd(&client, &org, &pipe, build, &job_id, app.term()).await } } } @@ -76,6 +77,7 @@ async fn log_cmd( pipe: &str, build: i64, jid: &str, + term: Term, ) -> Result<()> { let job_id = Uuid::parse_str(jid) .map_err(|e| anyhow::anyhow!("job id '{jid}' is not a valid UUID: {e}"))?; @@ -84,7 +86,7 @@ async fn log_cmd( let token = client.log_token(org, pipe, build).await?; let log_base = client.base_url().to_string(); - let prefs = settings::RenderPrefs::detect(); + let prefs = settings::RenderPrefs::detect(term); // A single-job tail is always a flat log stream, so force the streaming // HumanRenderer (logs = true) regardless of TTY. let renderer = hm_render::renderer_for("human", prefs.color, true)?; diff --git a/crates/hm/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs index fcfb0091..17a51d17 100644 --- a/crates/hm/src/commands/run/mod.rs +++ b/crates/hm/src/commands/run/mod.rs @@ -106,9 +106,7 @@ pub async fn handle(args: RunArgs, ctx: RunContext<'_>) -> Result { // 4. Pick the renderer — this validates `--format` — before any daemon // connection, so an unknown format fails fast without a running Docker. - let use_logs = args.logs - || std::env::var_os("CI").is_some_and(|v| !v.is_empty()) - || !hm_render::stderr_interactive(); + let use_logs = args.logs || app.term().is_ci() || !app.term().stderr_is_tty(); let renderer = hm_render::renderer_for(&args.format, ctx.output.color_enabled(), use_logs)?; // 5. Build the backend. For local runs this is where we connect to Docker. diff --git a/crates/hm/src/context.rs b/crates/hm/src/context.rs index 2400aa7c..5a8bd928 100644 --- a/crates/hm/src/context.rs +++ b/crates/hm/src/context.rs @@ -1,5 +1,3 @@ -use std::io::IsTerminal; - use hm_core::app_ctx::AppCtx; use hm_render::OutputMode; @@ -17,11 +15,12 @@ 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 term = app.term(); 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), + color: hm_render::color_enabled(cli.no_color, term.stderr_is_tty()), // Interactive prompts/spinners key off stdout being a TTY. - interactive: std::io::stdout().is_terminal(), + interactive: term.stdout_is_tty(), }; Self { app, output } From d8c77233c3f721a07f9e56cc949f4b674b9ffda4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 26 Jul 2026 21:54:53 -0700 Subject: [PATCH 06/13] feat(hm-common): add UrlNonce A cryptographically random, URL-safe nonce stored as its raw 32 bytes: OS CSPRNG generation, constant-time comparison, redacted Debug, and zeroize-on-drop. Only constructor is random(), so a weak nonce can't be built by accident; base_64() renders it for URLs. --- crates/hm-common/Cargo.toml | 4 ++ crates/hm-common/src/lib.rs | 1 + crates/hm-common/src/url_nonce.rs | 83 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 crates/hm-common/src/url_nonce.rs diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml index 25f19fca..b63abe7f 100644 --- a/crates/hm-common/Cargo.toml +++ b/crates/hm-common/Cargo.toml @@ -20,13 +20,17 @@ dirs = "6" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +base64 = "0.22" bstr = "1" hex = "0.4" include_dir = "0.7" num-traits = { workspace = true } +rand = "0.8" +subtle = "2" tempfile = "3" tokio = { version = "1", features = ["process", "fs", "rt", "rt-multi-thread", "io-util"] } tracing = "0.1" +zeroize = { version = "1", features = ["derive"] } unicode-width = { workspace = true } unicode-segmentation = { workspace = true } which = "7" diff --git a/crates/hm-common/src/lib.rs b/crates/hm-common/src/lib.rs index 6464db80..62c5ece9 100644 --- a/crates/hm-common/src/lib.rs +++ b/crates/hm-common/src/lib.rs @@ -8,3 +8,4 @@ pub mod process; pub mod python; pub mod string; pub mod time; +pub mod url_nonce; diff --git a/crates/hm-common/src/url_nonce.rs b/crates/hm-common/src/url_nonce.rs new file mode 100644 index 00000000..232cf246 --- /dev/null +++ b/crates/hm-common/src/url_nonce.rs @@ -0,0 +1,83 @@ +//! Cryptographic nonces. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rand::RngCore as _; +use subtle::ConstantTimeEq as _; +use zeroize::ZeroizeOnDrop; + +/// A nonce carrying 256 bits of cryptographic entropy. +#[derive(Clone, ZeroizeOnDrop)] +pub struct UrlNonce([u8; 32]); + +impl UrlNonce { + /// Generate a fresh nonce from the operating system's CSPRNG. + #[must_use] + pub fn random() -> Self { + let mut bytes = [0u8; 32]; + let mut rng = rand::rngs::OsRng; + rng.fill_bytes(&mut bytes); + Self(bytes) + } + + /// The nonce as a URL-safe base64 string. + #[must_use] + pub fn base_64(&self) -> String { + URL_SAFE_NO_PAD.encode(self.0) + } + + /// Whether `candidate` equals this nonce's base64 form, compared in + /// constant time. + #[must_use] + pub fn verify(&self, candidate: &str) -> bool { + self.base_64().as_bytes().ct_eq(candidate.as_bytes()).into() + } +} + +impl std::fmt::Debug for UrlNonce { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("UrlNonce()") + } +} + +impl PartialEq for UrlNonce { + fn eq(&self, other: &Self) -> bool { + self.0.as_slice().ct_eq(other.0.as_slice()).into() + } +} + +impl Eq for UrlNonce {} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test setup and assertions")] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + fn random_nonces_are_distinct() { + assert_ne!(UrlNonce::random(), UrlNonce::random()); + } + + #[rstest] + fn base_64_decodes_to_32_bytes() { + let nonce = UrlNonce::random(); + let raw = URL_SAFE_NO_PAD.decode(nonce.base_64()).unwrap(); + assert_eq!(raw.len(), 32); + } + + #[rstest] + fn equals_its_clone_and_verifies_in_place() { + let nonce = UrlNonce::random(); + assert_eq!(nonce.clone(), nonce); + assert!(nonce.verify(&nonce.base_64())); + assert!(!nonce.verify("not-the-nonce")); + } + + #[rstest] + fn debug_does_not_leak_the_value() { + let nonce = UrlNonce::random(); + let rendered = format!("{nonce:?}"); + assert!(!rendered.contains(&nonce.base_64())); + } +} From d3db78d35350187c72a6447beb87d050a0f57438 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 26 Jul 2026 21:55:03 -0700 Subject: [PATCH 07/13] feat(hm-cloud): implement cloud auth, route login through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthProvider drives the login flow, picking the path from Term: a GUI gets the browser-loopback flow (BrowserAuth binds the listener, opens the page, ClaimPoller claims the token by nonce); otherwise the paste flow (PasteTokenAuth). Each step carries a typed error, the dialoguer prompt runs on spawn_blocking, and URLs are built with url::Url — for which BackendDomain now exposes app() as a Url. hm cloud auth login loses --paste (detection replaces it) and hm's login.rs drops its ~160-line loopback/nonce/poll implementation for a thin adapter over AuthProvider that reads the user back. --- crates/hm-cloud/Cargo.toml | 9 + crates/hm-cloud/src/auth.rs | 265 +++++++++++++++++++++ crates/hm-cloud/src/cli.rs | 6 +- crates/hm-cloud/src/lib.rs | 1 + crates/hm-core/src/config/domain.rs | 22 +- crates/hm/src/commands/cloud/auth/login.rs | 156 ++---------- crates/hm/src/commands/cloud/cli.rs | 2 +- crates/hm/src/commands/cloud/mod.rs | 8 +- 8 files changed, 313 insertions(+), 156 deletions(-) create mode 100644 crates/hm-cloud/src/auth.rs diff --git a/crates/hm-cloud/Cargo.toml b/crates/hm-cloud/Cargo.toml index 21d0c910..9a37d211 100644 --- a/crates/hm-cloud/Cargo.toml +++ b/crates/hm-cloud/Cargo.toml @@ -13,6 +13,15 @@ path = "src/lib.rs" [dependencies] clap = { version = "4", features = ["derive"] } +dialoguer = "0.11" +harmont-cloud = { workspace = true } +hm-common = { workspace = true } +hm-core = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = "2" +webbrowser = "1" [lints] workspace = true diff --git a/crates/hm-cloud/src/auth.rs b/crates/hm-cloud/src/auth.rs new file mode 100644 index 00000000..4d9c0bff --- /dev/null +++ b/crates/hm-cloud/src/auth.rs @@ -0,0 +1,265 @@ +//! Authentication against the Harmont API. +use std::time::{Duration, Instant}; + +use harmont_cloud::{HarmontClient, HarmontError}; +use hm_common::url_nonce::UrlNonce; +use hm_core::{app_ctx::AppCtx, config::ResolvedCloudConfig}; +use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpListener, task::JoinHandle, time::error::Elapsed}; +use thiserror::Error; +use tracing::{info, instrument, warn}; +use url::Url; + +const LOGIN_TIMEOUT: Duration = Duration::from_mins(3); + +/// How long to poll for the token before giving up. +const CLAIM_TIMEOUT: Duration = Duration::from_mins(3); + +/// How long to wait between claim attempts. +const CLAIM_INTERVAL: Duration = Duration::from_millis(750); + +#[derive(Debug, Error)] +pub enum BrowserAuthError { + #[error("failed to create listener: {_0}")] + CouldNotCreateListener(std::io::Error), + + #[error("failed to deduce local address: {_0}")] + CouldNotDeduceAddress(std::io::Error), +} + +/// The code-path taken to open the browser and allow the user to click a button to log in. +#[derive(Debug)] +struct BrowserAuth { + accept: JoinHandle<()>, +} + +impl BrowserAuth { + /// Bind the loopback listener, open the browser to the login page, and + /// spawn the task that serves the redirect. + #[instrument] + async fn new(app: Url, nonce: &UrlNonce) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await + .map_err(BrowserAuthError::CouldNotCreateListener)?; + let port = listener.local_addr() + .map_err(BrowserAuthError::CouldNotDeduceAddress)?.port(); + + let mut url = app; + url.set_path("/cli-login"); + url.query_pairs_mut() + .append_pair("port", &port.to_string()) + .append_pair("nonce", &nonce.base_64()); + + info!("opening browser to {url}"); + if webbrowser::open(url.as_str()).is_err() { + warn!("couldn't open a browser automatically. open this URL manually:\n {url}"); + } + + let accept = tokio::spawn(Self::accept(listener)); + + Ok(Self { accept }) + } + + async fn accept(listener: TcpListener) { + let (stream, _src_addr) = match listener.accept().await { + Ok(conn) => conn, + Err(err) => { + warn!(err = ?err, "failed to connect to the client. try connecting again."); + return; + } + }; + + let (reader, mut writer) = stream.into_split(); + let mut buf_reader = BufReader::new(reader); + + let mut request_line = String::new(); + let _ = buf_reader.read_line(&mut request_line).await; + + let body = "Login received. You can close this tab."; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + + writer.write_all(response.as_bytes()).await.ok(); + writer.shutdown().await.ok(); + } + + /// Wait for the login from the user. + async fn login(&mut self) -> Result<(), Elapsed> { + tokio::time::timeout(LOGIN_TIMEOUT, &mut self.accept).await.map(|_| ()) + } +} + +/// A failure during the paste-in login flow. +#[derive(Debug, Error)] +pub enum PasteAuthError { + /// The code could not be read from the terminal. + #[error("failed to read the code: {0}")] + Prompt(String), + + /// The code could not be redeemed for a token. + #[error(transparent)] + Redeem(#[from] HarmontError), +} + +/// The code-path where the user pastes a code shown by the browser rather than +/// completing a loopback redirect. +#[derive(Debug)] +struct PasteTokenAuth; + +impl PasteTokenAuth { + /// Open the paste page, read a code, and redeem it for a token. + /// + /// # Errors + /// + /// [`PasteAuthError::Prompt`] when the terminal read fails; + /// [`PasteAuthError::Redeem`] when the API rejects the code. + async fn login(client: &HarmontClient, app: Url) -> Result { + let mut url = app; + url.set_path("/cli-login"); + url.query_pairs_mut().append_pair("paste", "true"); + + info!("open this URL in your browser, then paste the code:\n {url}"); + + let code = Self::read_code().await?; + Ok(client.redeem_code(&code).await?) + } + + /// Prompt for a login code, re-prompting until a non-empty code is entered. + async fn read_code() -> Result { + tokio::task::spawn_blocking(|| loop { + let raw = dialoguer::Input::::new() + .with_prompt("code") + .interact() + .map_err(|e| PasteAuthError::Prompt(e.to_string()))?; + let code = raw.trim().to_string(); + if !code.is_empty() { + return Ok(code); + } + }) + .await + .map_err(|e| PasteAuthError::Prompt(e.to_string()))? + } +} + +/// A failure while polling for the browser-parked token. +#[derive(Debug, Error)] +pub enum ClaimError { + /// The token was not parked within [`CLAIM_TIMEOUT`]. + #[error("timed out waiting for the browser to authorize this login")] + TimedOut, + /// The claim request itself failed. + #[error(transparent)] + Api(#[from] HarmontError), +} + +/// Polls the cloud until the browser parks a token under our login nonce. +struct ClaimPoller<'client> { + client: &'client HarmontClient, + nonce: UrlNonce, +} + +impl<'client> ClaimPoller<'client> { + const fn new(client: &'client HarmontClient, nonce: UrlNonce) -> Self { + Self { client, nonce } + } + + /// Poll until the token is parked, or the polling window elapses. + /// + /// # Errors + /// + /// [`ClaimError::TimedOut`] when no token is parked within + /// [`CLAIM_TIMEOUT`]; [`ClaimError::Api`] on any other request failure. + async fn poll(&self) -> Result { + let nonce = self.nonce.base_64(); + let deadline = Instant::now() + CLAIM_TIMEOUT; + loop { + match self.client.claim_token(&nonce).await { + Ok(token) => return Ok(token), + Err(HarmontError::Api { status: 400, code, .. }) if code == "cli_code_invalid" => { + if Instant::now() >= deadline { + return Err(ClaimError::TimedOut); + } + tokio::time::sleep(CLAIM_INTERVAL).await; + } + Err(err) => return Err(ClaimError::Api(err)), + } + } + } +} + +/// A failure during a login attempt. +#[derive(Debug, Error)] +pub enum LoginError { + /// The environment has no browser to drive an interactive login. + #[error("no browser is available for interactive login")] + Unsupported, + /// The browser flow could not be set up. + #[error(transparent)] + Browser(#[from] BrowserAuthError), + /// The token could not be claimed after the browser flow. + #[error(transparent)] + Claim(#[from] ClaimError), + /// The paste-in flow failed. + #[error(transparent)] + Paste(#[from] PasteAuthError), +} + +#[derive(Debug)] +pub struct AuthProvider<'app, 'client, 'config> { + app_ctx: &'app AppCtx, + harmont_client: &'client HarmontClient, + config: &'config ResolvedCloudConfig, +} + +impl<'app, 'client, 'config> AuthProvider<'app, 'client, 'config> { + /// Create a new authentication provider. + #[must_use] + pub const fn new( + app_ctx: &'app AppCtx, + client: &'client HarmontClient, + config: &'config ResolvedCloudConfig, + ) -> Self { + Self { app_ctx, harmont_client: client, config } + } + + /// Log in — browser-loopback when a GUI is available, otherwise the + /// paste-in flow — persisting and returning the resulting token. + /// + /// # Errors + /// + /// [`LoginError::Unsupported`] when there is neither a browser nor an + /// interactive terminal; [`LoginError::Browser`], [`LoginError::Claim`], + /// or [`LoginError::Paste`] when the chosen flow fails. + pub async fn try_login(&self) -> Result { + let token = if self.app_ctx.term().has_gui() { + self.login_browser().await? + } else if self.app_ctx.term().is_interactive() { + self.login_paste().await? + } else { + return Err(LoginError::Unsupported); + }; + + self.app_ctx.creds().set(&token).await; + Ok(token) + } + + /// Open the browser, wait for its redirect, then claim the parked token. + async fn login_browser(&self) -> Result { + let nonce = UrlNonce::random(); + let mut browser = BrowserAuth::new(self.config.domain.app(), &nonce).await?; + + // Wait for the redirect so the tab can show "done", but claim by nonce + // regardless — a lost or slow redirect doesn't mean the login failed. + if let Err(elapsed) = browser.login().await { + warn!(%elapsed, "no browser redirect yet; claiming the token anyway"); + } + + Ok(ClaimPoller::new(self.harmont_client, nonce).poll().await?) + } + + /// Show the paste page and redeem the code the user enters. + async fn login_paste(&self) -> Result { + Ok(PasteTokenAuth::login(self.harmont_client, self.config.domain.app()).await?) + } +} diff --git a/crates/hm-cloud/src/cli.rs b/crates/hm-cloud/src/cli.rs index dc9015d0..6623ebf2 100644 --- a/crates/hm-cloud/src/cli.rs +++ b/crates/hm-cloud/src/cli.rs @@ -27,11 +27,7 @@ pub enum CloudCommand { #[derive(Debug, Clone, Subcommand)] pub enum AuthCommand { /// Authenticate this CLI against the Harmont API. - Login { - /// Skip the loopback flow and prompt for a paste-in code. - #[arg(long)] - paste: bool, - }, + Login, /// Remove stored credentials. Logout, /// Show the authenticated user. diff --git a/crates/hm-cloud/src/lib.rs b/crates/hm-cloud/src/lib.rs index 90907bfe..8043fa10 100644 --- a/crates/hm-cloud/src/lib.rs +++ b/crates/hm-cloud/src/lib.rs @@ -1,3 +1,4 @@ //! Harmont cloud: the cloud execution backend and `hm cloud` command surface. +pub mod auth; pub mod cli; diff --git a/crates/hm-core/src/config/domain.rs b/crates/hm-core/src/config/domain.rs index 410cc9ca..bc55fa2c 100644 --- a/crates/hm-core/src/config/domain.rs +++ b/crates/hm-core/src/config/domain.rs @@ -52,27 +52,39 @@ impl BackendDomain { /// The API base URL (`https://api.`), without a trailing slash. #[must_use] pub fn api_url(&self) -> String { - self.subdomain_url("api") + trim_slash(&self.subdomain("api")) } /// The dashboard base URL (`https://app.`), without a trailing slash. #[must_use] pub fn app_url(&self) -> String { - self.subdomain_url("app") + trim_slash(&self.app()) } - fn subdomain_url(&self, sub: &str) -> String { + /// The dashboard base URL (`https://app.`), for extending with a + /// path and query pairs. + #[must_use] + pub fn app(&self) -> Url { + self.subdomain("app") + } + + fn subdomain(&self, sub: &str) -> Url { 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() + url } - _ => self.0.as_str().trim_end_matches('/').to_owned(), + _ => self.0.clone(), } } } +/// A URL rendered without its trailing slash. +fn trim_slash(url: &Url) -> String { + url.as_str().trim_end_matches('/').to_owned() +} + impl Default for BackendDomain { fn default() -> Self { #[allow( diff --git a/crates/hm/src/commands/cloud/auth/login.rs b/crates/hm/src/commands/cloud/auth/login.rs index f1200ddd..f136ff52 100644 --- a/crates/hm/src/commands/cloud/auth/login.rs +++ b/crates/hm/src/commands/cloud/auth/login.rs @@ -1,40 +1,26 @@ -//! `hm cloud login` — browser-loopback or paste-in flow, routed through the -//! SDK's anonymous auth endpoints. -//! -//! Two paths produce a bearer token: -//! -//! - **loopback** (default): the CLI generates a random nonce, binds a local -//! listener, opens the SPA's `/cli-login` page with that nonce + the loopback -//! port, then polls [`HarmontClient::claim_token`] until the SPA parks the -//! token under the nonce (or the 60s window closes). -//! - **paste** (`--paste`): the SPA shows a short code; the user pastes it and -//! the CLI exchanges it via [`HarmontClient::redeem_code`]. +//! `hm cloud auth login` — drive the shared browser/paste login flow via +//! [`hm_cloud::auth::AuthProvider`], then confirm by reading the user back. -use std::collections::BTreeMap; -use std::time::Duration; - -use anyhow::{Result, bail}; -use harmont_cloud::{HarmontClient, HarmontError}; +use anyhow::Result; +use harmont_cloud::HarmontClient; use hm_core::app_ctx::AppCtx; +use hm_core::config::ResolvedCloudConfig; use crate::commands::cloud::settings; -pub(crate) async fn run( - env: &BTreeMap, - paste: bool, - app_ctx: &AppCtx, -) -> Result<()> { - let (client, domain) = settings::anon_client(app_ctx); +pub(crate) async fn run(app: &AppCtx) -> Result<()> { + let (client, domain) = settings::anon_client(app); let api = domain.api_url(); - let app = domain.app_url(); - - let token = if paste { - login_paste(env, &client, &app).await? - } else { - login_loopback(&client, &app).await? + let config = ResolvedCloudConfig { + domain, + org: None, + repo: None, + default_pipeline: None, }; - app_ctx.creds().set(&token).await; + let token = hm_cloud::auth::AuthProvider::new(app, &client, &config) + .try_login() + .await?; // Confirm by reading back the authenticated user. let authed = HarmontClient::with_base_url(token, &api); @@ -47,117 +33,7 @@ pub(crate) async fn run( me.email, ); } - Err(e) => { - tracing::warn!("logged in, but could not read user profile: {e}"); - } + Err(e) => tracing::warn!("logged in, but could not read user profile: {e}"), } Ok(()) } - -async fn login_loopback(client: &HarmontClient, app: &str) -> Result { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - let nonce = random_nonce(); - - // Bind a loopback listener so the SPA can signal "browser handed off". - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; - let port = listener.local_addr()?.port(); - let auth_url = format!("{app}/cli-login?port={port}&nonce={nonce}"); - - tracing::info!("opening browser to {auth_url}"); - if webbrowser::open(&auth_url).is_err() { - tracing::warn!("couldn't auto-open the browser. Open this URL manually:\n {auth_url}"); - } - - // Accept the SPA's redirect to /callback (best-effort UX: it lets the - // browser tab show "done"). We don't depend on its query for the token — - // the token is claimed by nonce below. - let accept = async { - if let Ok((stream, _addr)) = listener.accept().await { - let (reader, mut writer) = stream.into_split(); - let mut buf_reader = BufReader::new(reader); - let mut request_line = String::new(); - let _ = buf_reader.read_line(&mut request_line).await; - let body = "Login received. You can close this tab."; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - writer.write_all(response.as_bytes()).await.ok(); - writer.shutdown().await.ok(); - } - }; - // Give the browser up to 3 minutes to complete sign-in and redirect. - let _ = tokio::time::timeout(Duration::from_mins(3), accept).await; - - // Poll the claim endpoint. The SPA parks the token under our nonce; until - // then the endpoint returns 400 `cli_code_invalid`, which we retry. - poll_claim(client, &nonce).await -} - -/// Poll `claim_token` until the token is parked or the ~60s window elapses. -async fn poll_claim(client: &HarmontClient, nonce: &str) -> Result { - let deadline = std::time::Instant::now() + Duration::from_mins(1); - loop { - match client.claim_token(nonce).await { - Ok(token) => return Ok(token), - Err(HarmontError::Api { - status: 400, code, .. - }) if code == "cli_code_invalid" => { - if std::time::Instant::now() >= deadline { - bail!( - "timed out waiting for the browser to authorize this login (60s).\n \ - fix: re-run `hm cloud login`, or use `hm cloud login --paste`" - ); - } - tokio::time::sleep(Duration::from_millis(750)).await; - } - Err(e) => return Err(e.into()), - } - } -} - -async fn login_paste( - env: &BTreeMap, - client: &HarmontClient, - app: &str, -) -> Result { - let auth_url = format!("{app}/cli-login?paste=true"); - tracing::info!("Open this URL in your browser, then paste the code:\n {auth_url}"); - let _ = webbrowser::open(&auth_url); - - // Tests inject the code via `HM_LOGIN_CODE` to avoid a TTY. - let code = if let Some(c) = env.get("HM_LOGIN_CODE") { - c.clone() - } else { - dialoguer::Input::::new() - .with_prompt("code") - .interact() - .map_err(|e| anyhow::anyhow!("failed to read code: {e}"))? - }; - let code = code.trim().to_string(); - if code.is_empty() { - bail!("no code pasted"); - } - Ok(client.redeem_code(&code).await?) -} - -/// A URL-safe random nonce for the loopback handoff. -fn random_nonce() -> String { - use base64::Engine; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - let id = uuid::Uuid::new_v4(); - URL_SAFE_NO_PAD.encode(id.as_bytes()) -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - fn nonces_are_distinct() { - assert_ne!(random_nonce(), random_nonce()); - } -} diff --git a/crates/hm/src/commands/cloud/cli.rs b/crates/hm/src/commands/cloud/cli.rs index 1920e5ee..20151d9f 100644 --- a/crates/hm/src/commands/cloud/cli.rs +++ b/crates/hm/src/commands/cloud/cli.rs @@ -38,7 +38,7 @@ pub async fn dispatch_command( ) -> Result { let result = match command { CloudCommand::Auth(cmd) => match cmd { - AuthCommand::Login { paste } => auth::login::run(&env, paste, app).await, + AuthCommand::Login => auth::login::run(app).await, AuthCommand::Logout => auth::logout::run(&env, app).await, AuthCommand::Whoami => auth::whoami::run(&env, app).await, }, diff --git a/crates/hm/src/commands/cloud/mod.rs b/crates/hm/src/commands/cloud/mod.rs index cb5a8a40..ae576b28 100644 --- a/crates/hm/src/commands/cloud/mod.rs +++ b/crates/hm/src/commands/cloud/mod.rs @@ -6,16 +6,14 @@ pub mod settings; mod auth; mod verbs; -/// Run the interactive browser-loopback login flow. +/// Run the interactive login flow. /// /// Designed for embedding in host commands (e.g. `hm init`) that need /// the user to authenticate before proceeding. /// /// # Errors /// -/// Returns an error if the browser cannot be opened, the login times -/// out, or the token cannot be persisted. +/// Returns an error if the login flow fails or the token cannot be persisted. 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, app).await + auth::login::run(app).await } From 1ca9926be7c805e0dc2f32764755a63c5d9ffab4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 28 Jul 2026 22:41:53 -0700 Subject: [PATCH 08/13] feat(hm-cloud): AuthProvider owns login/logout/whoami; review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework AuthProvider to hold {app_ctx, config} and build its own clients (anonymous for login, authenticated for whoami and the post-login readback) rather than taking an injected one — so it can own the whole `hm cloud auth` surface. logout and whoami move onto it; hm's login.rs/ logout.rs/whoami.rs become thin adapters, and the old settings::client- based whoami is dropped. Review fixes to the login flow: - Gate the browser flow on !is_ci() so a CI runner with a display fails fast instead of hanging on the loopback. - Poll for the token directly while the spawned listener serves the redirect concurrently, instead of waiting out the redirect timeout first (BrowserAuth becomes a side-effecting open()). Also fold NO_COLOR detection into Term (wants_color(), flag applied by callers), drop the now-unused hm-render::color_enabled, inline the trailing-slash trim in BackendDomain, and expose app() as a Url. --- Cargo.lock | 1 + crates/hm-cloud/Cargo.toml | 1 + crates/hm-cloud/src/auth.rs | 137 +++++++++++++------- crates/hm-core/src/config/domain.rs | 9 +- crates/hm-core/src/term.rs | 9 ++ crates/hm-render/src/lib.rs | 8 -- crates/hm/src/commands/cloud/auth/login.rs | 36 +---- crates/hm/src/commands/cloud/auth/logout.rs | 16 ++- crates/hm/src/commands/cloud/auth/mod.rs | 5 +- crates/hm/src/commands/cloud/auth/whoami.rs | 23 +--- crates/hm/src/commands/cloud/cli.rs | 4 +- crates/hm/src/commands/cloud/settings.rs | 17 ++- crates/hm/src/context.rs | 2 +- 13 files changed, 146 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc0113ad..45bdc433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1259,6 +1259,7 @@ dependencies = [ "harmont-cloud", "hm-common", "hm-core", + "secrecy", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/hm-cloud/Cargo.toml b/crates/hm-cloud/Cargo.toml index 9a37d211..ad31fe0a 100644 --- a/crates/hm-cloud/Cargo.toml +++ b/crates/hm-cloud/Cargo.toml @@ -17,6 +17,7 @@ dialoguer = "0.11" harmont-cloud = { workspace = true } hm-common = { workspace = true } hm-core = { workspace = true } +secrecy = "0.10" tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/hm-cloud/src/auth.rs b/crates/hm-cloud/src/auth.rs index 4d9c0bff..cac17f98 100644 --- a/crates/hm-cloud/src/auth.rs +++ b/crates/hm-cloud/src/auth.rs @@ -4,13 +4,12 @@ use std::time::{Duration, Instant}; use harmont_cloud::{HarmontClient, HarmontError}; use hm_common::url_nonce::UrlNonce; use hm_core::{app_ctx::AppCtx, config::ResolvedCloudConfig}; -use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpListener, task::JoinHandle, time::error::Elapsed}; +use secrecy::ExposeSecret as _; +use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpListener}; use thiserror::Error; use tracing::{info, instrument, warn}; use url::Url; -const LOGIN_TIMEOUT: Duration = Duration::from_mins(3); - /// How long to poll for the token before giving up. const CLAIM_TIMEOUT: Duration = Duration::from_mins(3); @@ -26,17 +25,14 @@ pub enum BrowserAuthError { CouldNotDeduceAddress(std::io::Error), } -/// The code-path taken to open the browser and allow the user to click a button to log in. -#[derive(Debug)] -struct BrowserAuth { - accept: JoinHandle<()>, -} +/// The code-path that opens the browser and serves the loopback redirect. +struct BrowserAuth; impl BrowserAuth { /// Bind the loopback listener, open the browser to the login page, and /// spawn the task that serves the redirect. #[instrument] - async fn new(app: Url, nonce: &UrlNonce) -> Result { + async fn open(app: Url, nonce: &UrlNonce) -> Result<(), BrowserAuthError> { let listener = TcpListener::bind("127.0.0.1:0").await .map_err(BrowserAuthError::CouldNotCreateListener)?; let port = listener.local_addr() @@ -53,9 +49,8 @@ impl BrowserAuth { warn!("couldn't open a browser automatically. open this URL manually:\n {url}"); } - let accept = tokio::spawn(Self::accept(listener)); - - Ok(Self { accept }) + tokio::spawn(Self::accept(listener)); + Ok(()) } async fn accept(listener: TcpListener) { @@ -83,11 +78,6 @@ impl BrowserAuth { writer.write_all(response.as_bytes()).await.ok(); writer.shutdown().await.ok(); } - - /// Wait for the login from the user. - async fn login(&mut self) -> Result<(), Elapsed> { - tokio::time::timeout(LOGIN_TIMEOUT, &mut self.accept).await.map(|_| ()) - } } /// A failure during the paste-in login flow. @@ -205,61 +195,116 @@ pub enum LoginError { Paste(#[from] PasteAuthError), } +/// A failure while reading the current user. +#[derive(Debug, Error)] +pub enum WhoamiError { + /// No credentials are stored. + #[error("not logged in — run `hm cloud auth login`")] + NotLoggedIn, + /// The user profile could not be read. + #[error("could not read user profile: {0}")] + Fetch(String), +} + #[derive(Debug)] -pub struct AuthProvider<'app, 'client, 'config> { +pub struct AuthProvider<'app, 'config> { app_ctx: &'app AppCtx, - harmont_client: &'client HarmontClient, config: &'config ResolvedCloudConfig, } -impl<'app, 'client, 'config> AuthProvider<'app, 'client, 'config> { +impl<'app, 'config> AuthProvider<'app, 'config> { /// Create a new authentication provider. #[must_use] - pub const fn new( - app_ctx: &'app AppCtx, - client: &'client HarmontClient, - config: &'config ResolvedCloudConfig, - ) -> Self { - Self { app_ctx, harmont_client: client, config } + pub const fn new(app_ctx: &'app AppCtx, config: &'config ResolvedCloudConfig) -> Self { + Self { app_ctx, config } } /// Log in — browser-loopback when a GUI is available, otherwise the - /// paste-in flow — persisting and returning the resulting token. + /// paste-in flow — persisting the token and confirming the signed-in user. /// /// # Errors /// /// [`LoginError::Unsupported`] when there is neither a browser nor an /// interactive terminal; [`LoginError::Browser`], [`LoginError::Claim`], /// or [`LoginError::Paste`] when the chosen flow fails. - pub async fn try_login(&self) -> Result { - let token = if self.app_ctx.term().has_gui() { - self.login_browser().await? - } else if self.app_ctx.term().is_interactive() { - self.login_paste().await? + pub async fn try_login(&self) -> Result<(), LoginError> { + let client = HarmontClient::anonymous(self.config.domain.api_url()); + let term = self.app_ctx.term(); + let token = if term.has_gui() && !term.is_ci() { + self.login_browser(&client).await? + } else if term.is_interactive() { + self.login_paste(&client).await? } else { return Err(LoginError::Unsupported); }; - self.app_ctx.creds().set(&token).await; - Ok(token) + + // Confirm by reading the user back — best-effort, the token is valid. + match self.fetch_user(&token).await { + Ok((name, email, _)) => info!("logged in as {name} ({email})"), + Err(e) => warn!("logged in, but could not read user profile: {e}"), + } + Ok(()) } - /// Open the browser, wait for its redirect, then claim the parked token. - async fn login_browser(&self) -> Result { - let nonce = UrlNonce::random(); - let mut browser = BrowserAuth::new(self.config.domain.app(), &nonce).await?; + /// Clear the stored credentials. + /// + /// # Errors + /// + /// Returns an error if the credential store cannot be cleared. + pub async fn logout(&self) -> std::io::Result<()> { + self.app_ctx.creds().clear().await?; + info!("logged out"); + Ok(()) + } - // Wait for the redirect so the tab can show "done", but claim by nonce - // regardless — a lost or slow redirect doesn't mean the login failed. - if let Err(elapsed) = browser.login().await { - warn!(%elapsed, "no browser redirect yet; claiming the token anyway"); - } + /// Print the user the stored token belongs to. + /// + /// # Errors + /// + /// [`WhoamiError::NotLoggedIn`] when no token is stored; + /// [`WhoamiError::Fetch`] when the profile cannot be read. + pub async fn whoami(&self) -> Result<(), WhoamiError> { + let token = self + .app_ctx + .creds() + .get() + .await + .ok_or(WhoamiError::NotLoggedIn)?; + let (name, email, id) = self + .fetch_user(token.expose_secret()) + .await + .map_err(WhoamiError::Fetch)?; + info!("{name} <{email}> (id {id})"); + Ok(()) + } + + /// The display name, email, and id of the user `token` authenticates as. + async fn fetch_user(&self, token: &str) -> Result<(String, String, String), String> { + let client = HarmontClient::with_base_url(token.to_owned(), self.config.domain.api_url()); + let me = client + .raw() + .get_current_user() + .await + .map_err(|e| e.to_string())? + .into_inner(); + let name = me.name.clone().unwrap_or_else(|| me.email.clone()); + Ok((name, me.email.clone(), me.id.to_string())) + } + + /// Open the browser, then claim the token the SPA parks under the nonce. + async fn login_browser(&self, client: &HarmontClient) -> Result { + let nonce = UrlNonce::random(); + BrowserAuth::open(self.config.domain.app(), &nonce).await?; - Ok(ClaimPoller::new(self.harmont_client, nonce).poll().await?) + // The token comes from polling; the spawned listener serves the browser + // redirect concurrently, so a lost or slow redirect never blocks us — + // the poll's retry loop is the wait. + Ok(ClaimPoller::new(client, nonce).poll().await?) } /// Show the paste page and redeem the code the user enters. - async fn login_paste(&self) -> Result { - Ok(PasteTokenAuth::login(self.harmont_client, self.config.domain.app()).await?) + async fn login_paste(&self, client: &HarmontClient) -> Result { + Ok(PasteTokenAuth::login(client, self.config.domain.app()).await?) } } diff --git a/crates/hm-core/src/config/domain.rs b/crates/hm-core/src/config/domain.rs index bc55fa2c..8d971316 100644 --- a/crates/hm-core/src/config/domain.rs +++ b/crates/hm-core/src/config/domain.rs @@ -52,13 +52,13 @@ impl BackendDomain { /// The API base URL (`https://api.`), without a trailing slash. #[must_use] pub fn api_url(&self) -> String { - trim_slash(&self.subdomain("api")) + self.subdomain("api").as_str().trim_end_matches('/').to_owned() } /// The dashboard base URL (`https://app.`), without a trailing slash. #[must_use] pub fn app_url(&self) -> String { - trim_slash(&self.app()) + self.app().as_str().trim_end_matches('/').to_owned() } /// The dashboard base URL (`https://app.`), for extending with a @@ -80,11 +80,6 @@ impl BackendDomain { } } -/// A URL rendered without its trailing slash. -fn trim_slash(url: &Url) -> String { - url.as_str().trim_end_matches('/').to_owned() -} - impl Default for BackendDomain { fn default() -> Self { #[allow( diff --git a/crates/hm-core/src/term.rs b/crates/hm-core/src/term.rs index 0458781d..12544d86 100644 --- a/crates/hm-core/src/term.rs +++ b/crates/hm-core/src/term.rs @@ -16,6 +16,7 @@ pub struct Term { ci: bool, ssh: bool, display: bool, + no_color: bool, } impl Term { @@ -32,6 +33,7 @@ impl Term { || env_present("SSH_TTY") || env_present("SSH_CLIENT"), display: env_present("DISPLAY") || env_present("WAYLAND_DISPLAY"), + no_color: std::env::var_os("NO_COLOR").is_some(), } } @@ -42,6 +44,13 @@ impl Term { self.stdin && self.stdout && !self.ci } + /// Whether the environment permits ANSI color: `NO_COLOR` is unset and + /// stderr is a terminal. + #[must_use] + pub const fn wants_color(&self) -> bool { + !self.no_color && self.stderr + } + /// Whether stdin is a terminal. #[must_use] pub const fn stdin_is_tty(&self) -> bool { diff --git a/crates/hm-render/src/lib.rs b/crates/hm-render/src/lib.rs index edd2db21..391ce888 100644 --- a/crates/hm-render/src/lib.rs +++ b/crates/hm-render/src/lib.rs @@ -10,14 +10,6 @@ use std::fmt; use hm_plugin_protocol::BuildEvent; -/// Whether ANSI color should be used, given an explicit no-color flag and -/// whether stderr is a TTY: enabled when the flag is unset, `NO_COLOR` is -/// unset, and stderr is a terminal. -#[must_use] -pub fn color_enabled(no_color_flag: bool, stderr_is_tty: bool) -> bool { - !no_color_flag && std::env::var_os("NO_COLOR").is_none() && stderr_is_tty -} - pub mod human; pub mod json; pub mod progress; diff --git a/crates/hm/src/commands/cloud/auth/login.rs b/crates/hm/src/commands/cloud/auth/login.rs index f136ff52..7980624d 100644 --- a/crates/hm/src/commands/cloud/auth/login.rs +++ b/crates/hm/src/commands/cloud/auth/login.rs @@ -1,39 +1,15 @@ -//! `hm cloud auth login` — drive the shared browser/paste login flow via -//! [`hm_cloud::auth::AuthProvider`], then confirm by reading the user back. +//! `hm cloud auth login` — drive the shared login flow via +//! [`hm_cloud::auth::AuthProvider`]. use anyhow::Result; -use harmont_cloud::HarmontClient; use hm_core::app_ctx::AppCtx; -use hm_core::config::ResolvedCloudConfig; use crate::commands::cloud::settings; pub(crate) async fn run(app: &AppCtx) -> Result<()> { - let (client, domain) = settings::anon_client(app); - let api = domain.api_url(); - let config = ResolvedCloudConfig { - domain, - org: None, - repo: None, - default_pipeline: None, - }; - - let token = hm_cloud::auth::AuthProvider::new(app, &client, &config) + let config = settings::auth_config(app); + hm_cloud::auth::AuthProvider::new(app, &config) .try_login() - .await?; - - // Confirm by reading back the authenticated user. - let authed = HarmontClient::with_base_url(token, &api); - match authed.raw().get_current_user().await { - Ok(resp) => { - let me = resp.into_inner(); - tracing::info!( - "logged in as {} ({})", - me.name.clone().unwrap_or_else(|| me.email.clone()), - me.email, - ); - } - Err(e) => tracing::warn!("logged in, but could not read user profile: {e}"), - } - Ok(()) + .await + .map_err(anyhow::Error::from) } diff --git a/crates/hm/src/commands/cloud/auth/logout.rs b/crates/hm/src/commands/cloud/auth/logout.rs index 033b4a92..41047879 100644 --- a/crates/hm/src/commands/cloud/auth/logout.rs +++ b/crates/hm/src/commands/cloud/auth/logout.rs @@ -1,12 +1,14 @@ -//! `hm cloud logout` — clears the stored bearer token. - -use std::collections::BTreeMap; +//! `hm cloud auth logout` — clear the stored bearer token. use anyhow::Result; use hm_core::app_ctx::AppCtx; -pub(crate) async fn run(_env: &BTreeMap, app: &AppCtx) -> Result<()> { - app.creds().clear().await?; - tracing::info!("logged out"); - Ok(()) +use crate::commands::cloud::settings; + +pub(crate) async fn run(app: &AppCtx) -> Result<()> { + let config = settings::auth_config(app); + hm_cloud::auth::AuthProvider::new(app, &config) + .logout() + .await + .map_err(anyhow::Error::from) } diff --git a/crates/hm/src/commands/cloud/auth/mod.rs b/crates/hm/src/commands/cloud/auth/mod.rs index 663c73b8..d51376a8 100644 --- a/crates/hm/src/commands/cloud/auth/mod.rs +++ b/crates/hm/src/commands/cloud/auth/mod.rs @@ -1,6 +1,7 @@ -//! `hm cloud login | logout | whoami`. +//! `hm cloud auth login | logout | whoami`. //! -//! Each submodule exposes a single `run(env, ...)` entry point. +//! Thin adapters over [`hm_cloud::auth::AuthProvider`]; each exposes a single +//! `run(app)` entry point. pub(crate) mod login; pub(crate) mod logout; diff --git a/crates/hm/src/commands/cloud/auth/whoami.rs b/crates/hm/src/commands/cloud/auth/whoami.rs index 1a5911e4..7a78051f 100644 --- a/crates/hm/src/commands/cloud/auth/whoami.rs +++ b/crates/hm/src/commands/cloud/auth/whoami.rs @@ -1,25 +1,14 @@ -//! `hm cloud whoami` — print the user the stored token belongs to. - -use std::collections::BTreeMap; +//! `hm cloud auth whoami` — print the signed-in user. use anyhow::Result; use hm_core::app_ctx::AppCtx; use crate::commands::cloud::settings; -pub(crate) async fn run(_env: &BTreeMap, app: &AppCtx) -> Result<()> { - let (client, _ctx) = settings::client(app).await?; - let me = client - .raw() - .get_current_user() +pub(crate) async fn run(app: &AppCtx) -> Result<()> { + let config = settings::auth_config(app); + hm_cloud::auth::AuthProvider::new(app, &config) + .whoami() .await - .map_err(settings::map_raw)? - .into_inner(); - tracing::info!( - "{} <{}> (id {})", - me.name.clone().unwrap_or_else(|| me.email.clone()), - me.email, - me.id, - ); - Ok(()) + .map_err(anyhow::Error::from) } diff --git a/crates/hm/src/commands/cloud/cli.rs b/crates/hm/src/commands/cloud/cli.rs index 20151d9f..16f6bca9 100644 --- a/crates/hm/src/commands/cloud/cli.rs +++ b/crates/hm/src/commands/cloud/cli.rs @@ -39,8 +39,8 @@ pub async fn dispatch_command( let result = match command { CloudCommand::Auth(cmd) => match cmd { AuthCommand::Login => auth::login::run(app).await, - AuthCommand::Logout => auth::logout::run(&env, app).await, - AuthCommand::Whoami => auth::whoami::run(&env, app).await, + AuthCommand::Logout => auth::logout::run(app).await, + AuthCommand::Whoami => auth::whoami::run(app).await, }, CloudCommand::Org(cmd) => verbs::org::run(&env, cmd, app).await, CloudCommand::Pipeline(cmd) => verbs::pipeline::run(&env, cmd, app).await, diff --git a/crates/hm/src/commands/cloud/settings.rs b/crates/hm/src/commands/cloud/settings.rs index 9460f247..6dbd6c5a 100644 --- a/crates/hm/src/commands/cloud/settings.rs +++ b/crates/hm/src/commands/cloud/settings.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result}; use harmont_cloud::HarmontClient; use hm_core::app_ctx::AppCtx; +use hm_core::config::ResolvedCloudConfig; use hm_core::config::domain::{BackendConfig, BackendDomain}; use hm_core::term::Term; use hm_core::config::user::UserCloudConfig; @@ -51,6 +52,18 @@ pub fn domain(app: &AppCtx) -> BackendDomain { .unwrap_or_default() } +/// A minimal resolved cloud config for the auth flows, which only need the +/// domain; org/repo/pipeline are irrelevant to authentication. +#[must_use] +pub fn auth_config(app: &AppCtx) -> ResolvedCloudConfig { + ResolvedCloudConfig { + domain: domain(app), + org: None, + repo: None, + default_pipeline: None, + } +} + /// An authenticated cloud client built from the user config + stored token. /// /// Fails fast with a clear message when no token is present. @@ -117,9 +130,9 @@ pub struct RenderPrefs { impl RenderPrefs { /// Derive render preferences from the terminal state and `NO_COLOR`. #[must_use] - pub fn detect(term: Term) -> Self { + pub const fn detect(term: Term) -> Self { Self { - color: hm_render::color_enabled(false, term.stderr_is_tty()), + color: term.wants_color(), logs: !term.stdout_is_tty(), } } diff --git a/crates/hm/src/context.rs b/crates/hm/src/context.rs index 5a8bd928..182da755 100644 --- a/crates/hm/src/context.rs +++ b/crates/hm/src/context.rs @@ -18,7 +18,7 @@ impl<'app> RunContext<'app> { let term = app.term(); 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, term.stderr_is_tty()), + color: !cli.no_color && term.wants_color(), // Interactive prompts/spinners key off stdout being a TTY. interactive: term.stdout_is_tty(), }; From d58f4ae49c1eab400cd1ed9c426c7b6db8d142b4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 28 Jul 2026 23:25:47 -0700 Subject: [PATCH 09/13] docs(hm-core): TODO to return Url from api_url/app_url when the SDK allows The harmont-cloud client string-concatenates its base (format!("{base}{path}")), so BackendDomain must hand back a trailing-slash-trimmed String. Note to switch to Url once the client accepts a URL base. --- crates/hm-core/src/config/domain.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/hm-core/src/config/domain.rs b/crates/hm-core/src/config/domain.rs index 8d971316..98949a4d 100644 --- a/crates/hm-core/src/config/domain.rs +++ b/crates/hm-core/src/config/domain.rs @@ -49,6 +49,10 @@ impl BackendDomain { Ok(Self(Url::parse(&normalized)?)) } + // TODO: return `Url` from api_url()/app_url() once the harmont-cloud client + // accepts a URL base instead of string-concatenating (`format!("{base}{path}")`). + // Its naive concat is why these must hand back a trailing-slash-trimmed String. + /// The API base URL (`https://api.`), without a trailing slash. #[must_use] pub fn api_url(&self) -> String { From 5e97558eb8c16bf5584e8c8863df3aa3b02c9306 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 28 Jul 2026 23:40:15 -0700 Subject: [PATCH 10/13] feat(hm-core): add EnvVarProvider, read env through AppCtx::env() Snapshot the process environment once at startup behind EnvVarProvider (get/is_set/is_present/parse, redacted Debug since env can hold secrets), exposed as AppCtx::env(). Term::detect now reads SSH/DISPLAY/NO_COLOR from it instead of hitting std::env inline. Also drop the dead env threading through the cloud dispatch: verbs all ignored the BTreeMap (the run verb that used it moved to hm run --cloud), so remove the _env params, the dispatch env argument, and the std::env::vars() collect. --- crates/hm-core/src/app_ctx.rs | 14 ++++- crates/hm-core/src/config/domain.rs | 5 +- crates/hm-core/src/env.rs | 55 +++++++++++++++++++ crates/hm-core/src/lib.rs | 1 + crates/hm-core/src/term.rs | 21 +++---- crates/hm/src/cli/mod.rs | 5 +- crates/hm/src/commands/cloud/cli.rs | 18 ++---- crates/hm/src/commands/cloud/settings.rs | 2 +- crates/hm/src/commands/cloud/verbs/billing.rs | 10 +--- crates/hm/src/commands/cloud/verbs/build.rs | 18 +++--- crates/hm/src/commands/cloud/verbs/job.rs | 10 +--- crates/hm/src/commands/cloud/verbs/org.rs | 10 +--- .../hm/src/commands/cloud/verbs/pipeline.rs | 10 +--- 13 files changed, 106 insertions(+), 73 deletions(-) create mode 100644 crates/hm-core/src/env.rs diff --git a/crates/hm-core/src/app_ctx.rs b/crates/hm-core/src/app_ctx.rs index ba26ea9b..0c927551 100644 --- a/crates/hm-core/src/app_ctx.rs +++ b/crates/hm-core/src/app_ctx.rs @@ -11,6 +11,7 @@ use hm_common::python::Python; use crate::config::domain::ConfigLoadingError; use crate::config::user::UserConfig; use crate::creds::{CredsInitError, CredsProvider}; +use crate::env::EnvVarProvider; use crate::term::Term; /// Failure to initialize the [`AppCtx`]. @@ -42,6 +43,7 @@ pub struct AppCtx { dirs: DirProvider, user_config: Option, creds: CredsProvider, + env: EnvVarProvider, term: Term, } @@ -70,6 +72,9 @@ impl AppCtx { let user_config = user_config.map_err(InitError::UserConfig)?; let creds = creds?; + let env = EnvVarProvider::init(); + let term = Term::detect(&env); + Ok(Self { git, python3, @@ -77,7 +82,8 @@ impl AppCtx { dirs, user_config, creds, - term: Term::detect(), + env, + term, }) } @@ -117,6 +123,12 @@ impl AppCtx { self.term } + /// The environment variables captured at initialization. + #[must_use] + pub const fn env(&self) -> &EnvVarProvider { + &self.env + } + /// The user config, or `None` when no user config file is present. #[must_use] pub const fn user_config(&self) -> Option<&UserConfig> { diff --git a/crates/hm-core/src/config/domain.rs b/crates/hm-core/src/config/domain.rs index 98949a4d..11d91cf8 100644 --- a/crates/hm-core/src/config/domain.rs +++ b/crates/hm-core/src/config/domain.rs @@ -56,7 +56,10 @@ impl BackendDomain { /// The API base URL (`https://api.`), without a trailing slash. #[must_use] pub fn api_url(&self) -> String { - self.subdomain("api").as_str().trim_end_matches('/').to_owned() + self.subdomain("api") + .as_str() + .trim_end_matches('/') + .to_owned() } /// The dashboard base URL (`https://app.`), without a trailing slash. diff --git a/crates/hm-core/src/env.rs b/crates/hm-core/src/env.rs new file mode 100644 index 00000000..cb63e47e --- /dev/null +++ b/crates/hm-core/src/env.rs @@ -0,0 +1,55 @@ +//! Process environment: a snapshot of the environment variables the CLI reads. + +use std::collections::HashMap; + +/// A snapshot of the process environment, captured once at startup so the CLI +/// reads variables from one place rather than hitting `std::env` ad hoc. +pub struct EnvVarProvider { + vars: HashMap, +} + +impl EnvVarProvider { + /// Capture the current environment. Variables whose name or value is not + /// valid UTF-8 are skipped. + #[must_use] + pub fn init() -> Self { + let vars = std::env::vars_os() + .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) + .collect(); + Self { vars } + } + + /// The value of `name`, if present — which may be an empty string. + #[must_use] + pub fn get(&self, name: &str) -> Option<&str> { + self.vars.get(name).map(String::as_str) + } + + /// Whether `name` is present at all, even set to an empty string. + #[must_use] + pub fn is_present(&self, name: &str) -> bool { + self.vars.contains_key(name) + } + + /// Whether `name` is present and non-empty. + #[must_use] + pub fn is_set(&self, name: &str) -> bool { + self.get(name).is_some_and(|value| !value.is_empty()) + } + + /// Parse `name`'s value into `T`, if it is present and parses. + #[must_use] + pub fn parse(&self, name: &str) -> Option { + self.get(name)?.parse().ok() + } +} + +impl std::fmt::Debug for EnvVarProvider { + /// Redacted: environment variables can hold secrets, so only the count of + /// captured variables is shown. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvVarProvider") + .field("vars", &format_args!("<{} variables>", self.vars.len())) + .finish() + } +} diff --git a/crates/hm-core/src/lib.rs b/crates/hm-core/src/lib.rs index 0775a8f1..5f212d9e 100644 --- a/crates/hm-core/src/lib.rs +++ b/crates/hm-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod app_ctx; pub mod config; pub mod creds; +pub mod env; pub mod exec; pub mod project_ctx; pub mod term; diff --git a/crates/hm-core/src/term.rs b/crates/hm-core/src/term.rs index 12544d86..2183d6ea 100644 --- a/crates/hm-core/src/term.rs +++ b/crates/hm-core/src/term.rs @@ -3,6 +3,8 @@ use std::io::IsTerminal as _; +use crate::env::EnvVarProvider; + /// The runtime environment facts, captured once at startup. #[derive(Debug, Clone, Copy)] #[allow( @@ -20,20 +22,18 @@ pub struct Term { } impl Term { - /// Capture the environment from the standard streams, CI signals, the SSH - /// session variables, and the display variables. + /// Capture terminal state from the standard streams and CI signals, and the + /// session/display facts from `env`. #[must_use] - pub fn detect() -> Self { + pub fn detect(env: &EnvVarProvider) -> Self { Self { stdin: std::io::stdin().is_terminal(), stdout: std::io::stdout().is_terminal(), stderr: std::io::stderr().is_terminal(), ci: is_ci::cached(), - ssh: env_present("SSH_CONNECTION") - || env_present("SSH_TTY") - || env_present("SSH_CLIENT"), - display: env_present("DISPLAY") || env_present("WAYLAND_DISPLAY"), - no_color: std::env::var_os("NO_COLOR").is_some(), + ssh: env.is_set("SSH_CONNECTION") || env.is_set("SSH_TTY") || env.is_set("SSH_CLIENT"), + display: env.is_set("DISPLAY") || env.is_set("WAYLAND_DISPLAY"), + no_color: env.is_present("NO_COLOR"), } } @@ -91,8 +91,3 @@ impl Term { } } } - -/// Whether an environment variable is set to a non-empty value. -fn env_present(name: &str) -> bool { - std::env::var_os(name).is_some_and(|v| !v.is_empty()) -} diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index ddc247c9..86e72a93 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -114,9 +114,6 @@ pub async fn dispatch(command: Command, ctx: RunContext<'_>) -> Result { }, 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(); - crate::commands::cloud::cli::dispatch_command(cmd, env, app).await - } + Command::Cloud(cmd) => crate::commands::cloud::cli::dispatch_command(cmd, app).await, } } diff --git a/crates/hm/src/commands/cloud/cli.rs b/crates/hm/src/commands/cloud/cli.rs index 16f6bca9..06bc63d0 100644 --- a/crates/hm/src/commands/cloud/cli.rs +++ b/crates/hm/src/commands/cloud/cli.rs @@ -1,7 +1,5 @@ //! Dispatch for `hm cloud` subcommands. -use std::collections::BTreeMap; - use anyhow::Result; use hm_cloud::cli::{AuthCommand, CloudCommand}; use hm_core::app_ctx::AppCtx; @@ -31,22 +29,18 @@ impl From for i32 { /// /// 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, - app: &AppCtx, -) -> Result { +pub async fn dispatch_command(command: CloudCommand, app: &AppCtx) -> Result { let result = match command { CloudCommand::Auth(cmd) => match cmd { AuthCommand::Login => auth::login::run(app).await, AuthCommand::Logout => auth::logout::run(app).await, AuthCommand::Whoami => auth::whoami::run(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::Org(cmd) => verbs::org::run(cmd, app).await, + CloudCommand::Pipeline(cmd) => verbs::pipeline::run(cmd, app).await, + CloudCommand::Build(cmd) => verbs::build::run(cmd, app).await, + CloudCommand::Job(cmd) => verbs::job::run(cmd, app).await, + CloudCommand::Billing(cmd) => verbs::billing::run(cmd, app).await, }; match result { Ok(()) => Ok(ExitCode::Success.into()), diff --git a/crates/hm/src/commands/cloud/settings.rs b/crates/hm/src/commands/cloud/settings.rs index 6dbd6c5a..7459f05e 100644 --- a/crates/hm/src/commands/cloud/settings.rs +++ b/crates/hm/src/commands/cloud/settings.rs @@ -8,8 +8,8 @@ use harmont_cloud::HarmontClient; use hm_core::app_ctx::AppCtx; use hm_core::config::ResolvedCloudConfig; use hm_core::config::domain::{BackendConfig, BackendDomain}; -use hm_core::term::Term; use hm_core::config::user::UserCloudConfig; +use hm_core::term::Term; use secrecy::ExposeSecret as _; /// Resolved cloud context for the `hm cloud` verbs. diff --git a/crates/hm/src/commands/cloud/verbs/billing.rs b/crates/hm/src/commands/cloud/verbs/billing.rs index 5592605b..581b59a2 100644 --- a/crates/hm/src/commands/cloud/verbs/billing.rs +++ b/crates/hm/src/commands/cloud/verbs/billing.rs @@ -1,12 +1,10 @@ //! `hm cloud billing balance|transactions|usage|topup|redeem`. -use std::collections::BTreeMap; - use anyhow::Result; use harmont_cloud::HarmontClient; -use hm_cloud::cli::BillingCommand; use crate::commands::cloud::settings; +use hm_cloud::cli::BillingCommand; use hm_core::app_ctx::AppCtx; /// Convert an integer cent amount to dollars for display. @@ -18,11 +16,7 @@ fn cents_to_dollars(cents: i64) -> f64 { cents as f64 / 100.0 } -pub(crate) async fn run( - _env: &BTreeMap, - cmd: BillingCommand, - app: &AppCtx, -) -> Result<()> { +pub(crate) async fn run(cmd: BillingCommand, app: &AppCtx) -> Result<()> { let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; diff --git a/crates/hm/src/commands/cloud/verbs/build.rs b/crates/hm/src/commands/cloud/verbs/build.rs index 7940e154..cc4fecb8 100644 --- a/crates/hm/src/commands/cloud/verbs/build.rs +++ b/crates/hm/src/commands/cloud/verbs/build.rs @@ -1,21 +1,15 @@ //! `hm cloud build list|show|cancel|watch`. -use std::collections::BTreeMap; - use anyhow::Result; use harmont_cloud::HarmontClient; -use hm_cloud::cli::BuildCommand; use crate::commands::cloud::settings; +use hm_cloud::cli::BuildCommand; use hm_core::app_ctx::AppCtx; use hm_core::exec::cloud::watch::watch_build; use hm_core::term::Term; -pub(crate) async fn run( - _env: &BTreeMap, - cmd: BuildCommand, - app: &AppCtx, -) -> Result<()> { +pub(crate) async fn run(cmd: BuildCommand, app: &AppCtx) -> Result<()> { let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; @@ -70,7 +64,13 @@ async fn cancel(client: &HarmontClient, org: &str, pipe: &str, number: i64) -> R Ok(()) } -async fn watch(client: &HarmontClient, org: &str, pipe: &str, number: i64, term: Term) -> Result<()> { +async fn watch( + client: &HarmontClient, + org: &str, + pipe: &str, + number: i64, + term: Term, +) -> Result<()> { // Render the live build through the shared `hm-render` renderers (the same // ones a local `hm run` uses), driven by the `BuildEvent`s `watch_build` // emits over an mpsc channel. diff --git a/crates/hm/src/commands/cloud/verbs/job.rs b/crates/hm/src/commands/cloud/verbs/job.rs index 2a9403f8..27eb15eb 100644 --- a/crates/hm/src/commands/cloud/verbs/job.rs +++ b/crates/hm/src/commands/cloud/verbs/job.rs @@ -1,7 +1,5 @@ //! `hm cloud job list|show|log`. -use std::collections::BTreeMap; - use anyhow::Result; use chrono::Utc; use harmont_cloud::HarmontClient; @@ -9,17 +7,13 @@ use hm_plugin_protocol::events::{BuildEvent, PlanSummary}; use hm_plugin_protocol::ir::DurationMs; use uuid::Uuid; -use hm_cloud::cli::JobCommand; use crate::commands::cloud::settings; +use hm_cloud::cli::JobCommand; use hm_core::app_ctx::AppCtx; use hm_core::exec::cloud::watch::stream_job_logs_as_events; use hm_core::term::Term; -pub(crate) async fn run( - _env: &BTreeMap, - cmd: JobCommand, - app: &AppCtx, -) -> Result<()> { +pub(crate) async fn run(cmd: JobCommand, app: &AppCtx) -> Result<()> { let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; diff --git a/crates/hm/src/commands/cloud/verbs/org.rs b/crates/hm/src/commands/cloud/verbs/org.rs index a531cc35..14f035f4 100644 --- a/crates/hm/src/commands/cloud/verbs/org.rs +++ b/crates/hm/src/commands/cloud/verbs/org.rs @@ -1,20 +1,14 @@ //! `hm cloud org switch ` — pick the active organization. -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 hm_cloud::cli::OrgCommand; use crate::commands::cloud::settings; +use hm_cloud::cli::OrgCommand; -pub(crate) async fn run( - _env: &BTreeMap, - cmd: OrgCommand, - app: &AppCtx, -) -> Result<()> { +pub(crate) async fn run(cmd: OrgCommand, app: &AppCtx) -> Result<()> { let (client, _ctx) = settings::client(app).await?; match cmd { diff --git a/crates/hm/src/commands/cloud/verbs/pipeline.rs b/crates/hm/src/commands/cloud/verbs/pipeline.rs index 39bf62a4..1139eb73 100644 --- a/crates/hm/src/commands/cloud/verbs/pipeline.rs +++ b/crates/hm/src/commands/cloud/verbs/pipeline.rs @@ -1,19 +1,13 @@ //! `hm cloud pipeline list|show`. -use std::collections::BTreeMap; - use anyhow::Result; use harmont_cloud::HarmontClient; -use hm_cloud::cli::PipelineCommand; use crate::commands::cloud::settings; +use hm_cloud::cli::PipelineCommand; use hm_core::app_ctx::AppCtx; -pub(crate) async fn run( - _env: &BTreeMap, - cmd: PipelineCommand, - app: &AppCtx, -) -> Result<()> { +pub(crate) async fn run(cmd: PipelineCommand, app: &AppCtx) -> Result<()> { let (client, ctx) = settings::client(app).await?; let org = ctx.org()?; From 4331af3c204ce492168f3d7894284fb020a9fe22 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 28 Jul 2026 23:47:35 -0700 Subject: [PATCH 11/13] cleanup --- TODO.md | 1 + crates/hm-core/src/env.rs | 75 ++++++++++++++++++++++---------------- crates/hm-core/src/term.rs | 8 ++-- 3 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..2d992af5 --- /dev/null +++ b/TODO.md @@ -0,0 +1 @@ +- Clean up `hm-core/src/exec`. This is legacy debt code. diff --git a/crates/hm-core/src/env.rs b/crates/hm-core/src/env.rs index cb63e47e..b1a5620f 100644 --- a/crates/hm-core/src/env.rs +++ b/crates/hm-core/src/env.rs @@ -1,55 +1,66 @@ -//! Process environment: a snapshot of the environment variables the CLI reads. +//! Process environment: the environment variables the CLI reads, captured at +//! startup. -use std::collections::HashMap; - -/// A snapshot of the process environment, captured once at startup so the CLI -/// reads variables from one place rather than hitting `std::env` ad hoc. +/// The environment variables the CLI reads, captured once at startup so the +/// rest of the CLI reads them from here rather than hitting `std::env` inline. +#[derive(Debug, Clone, Default)] pub struct EnvVarProvider { - vars: HashMap, + ssh_connection: Option, + ssh_tty: Option, + ssh_client: Option, + display: Option, + wayland_display: Option, + no_color: bool, } impl EnvVarProvider { - /// Capture the current environment. Variables whose name or value is not - /// valid UTF-8 are skipped. + /// Capture the current values of the environment variables the CLI reads. #[must_use] pub fn init() -> Self { - let vars = std::env::vars_os() - .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) - .collect(); - Self { vars } + Self { + ssh_connection: std::env::var("SSH_CONNECTION").ok(), + ssh_tty: std::env::var("SSH_TTY").ok(), + ssh_client: std::env::var("SSH_CLIENT").ok(), + display: std::env::var("DISPLAY").ok(), + wayland_display: std::env::var("WAYLAND_DISPLAY").ok(), + no_color: std::env::var_os("NO_COLOR").is_some(), + } } - /// The value of `name`, if present — which may be an empty string. + /// `SSH_CONNECTION` — set for a session opened over SSH. #[must_use] - pub fn get(&self, name: &str) -> Option<&str> { - self.vars.get(name).map(String::as_str) + pub fn ssh_connection(&self) -> Option<&str> { + self.ssh_connection.as_deref() } - /// Whether `name` is present at all, even set to an empty string. + /// `SSH_TTY` — the SSH session's controlling terminal. #[must_use] - pub fn is_present(&self, name: &str) -> bool { - self.vars.contains_key(name) + pub fn ssh_tty(&self) -> Option<&str> { + self.ssh_tty.as_deref() } - /// Whether `name` is present and non-empty. + /// `SSH_CLIENT` — the SSH client's address. #[must_use] - pub fn is_set(&self, name: &str) -> bool { - self.get(name).is_some_and(|value| !value.is_empty()) + pub fn ssh_client(&self) -> Option<&str> { + self.ssh_client.as_deref() } - /// Parse `name`'s value into `T`, if it is present and parses. + /// `DISPLAY` — the X11 display, when a graphical session is reachable. #[must_use] - pub fn parse(&self, name: &str) -> Option { - self.get(name)?.parse().ok() + pub fn display(&self) -> Option<&str> { + self.display.as_deref() + } + + /// `WAYLAND_DISPLAY` — the Wayland display, when a graphical session is + /// reachable. + #[must_use] + pub fn wayland_display(&self) -> Option<&str> { + self.wayland_display.as_deref() } -} -impl std::fmt::Debug for EnvVarProvider { - /// Redacted: environment variables can hold secrets, so only the count of - /// captured variables is shown. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EnvVarProvider") - .field("vars", &format_args!("<{} variables>", self.vars.len())) - .finish() + /// Whether `NO_COLOR` is set (to any value), disabling ANSI color. + #[must_use] + pub const fn no_color(&self) -> bool { + self.no_color } } diff --git a/crates/hm-core/src/term.rs b/crates/hm-core/src/term.rs index 2183d6ea..17db4d17 100644 --- a/crates/hm-core/src/term.rs +++ b/crates/hm-core/src/term.rs @@ -31,9 +31,11 @@ impl Term { stdout: std::io::stdout().is_terminal(), stderr: std::io::stderr().is_terminal(), ci: is_ci::cached(), - ssh: env.is_set("SSH_CONNECTION") || env.is_set("SSH_TTY") || env.is_set("SSH_CLIENT"), - display: env.is_set("DISPLAY") || env.is_set("WAYLAND_DISPLAY"), - no_color: env.is_present("NO_COLOR"), + ssh: env.ssh_connection().is_some() + || env.ssh_tty().is_some() + || env.ssh_client().is_some(), + display: env.display().is_some() || env.wayland_display().is_some(), + no_color: env.no_color(), } } From 0175bdef21fa0ef23ec813d96d4cf90b1bfa288e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 28 Jul 2026 23:59:43 -0700 Subject: [PATCH 12/13] refactor(hm-core): move env facts onto EnvVarProvider, borrow it in Term EnvVarProvider now owns the environment-derived facts (is_ci, is_ssh, has_display, no_color) as typed accessors; is_ci::cached() moves here. Term drops its ci/ssh/display/no_color fields and holds a borrowed &EnvVarProvider instead, so those facts live in one place and Term is back under the bool count that needed struct_excessive_bools. AppCtx no longer stores a Term (it would be self-referential); term() builds Term<'_> on demand against the captured env. --- crates/hm-core/src/app_ctx.rs | 10 ++--- crates/hm-core/src/env.rs | 41 +++++++------------ crates/hm-core/src/term.rs | 44 ++++++++------------- crates/hm/src/commands/cloud/settings.rs | 2 +- crates/hm/src/commands/cloud/verbs/build.rs | 2 +- crates/hm/src/commands/cloud/verbs/job.rs | 2 +- 6 files changed, 38 insertions(+), 63 deletions(-) diff --git a/crates/hm-core/src/app_ctx.rs b/crates/hm-core/src/app_ctx.rs index 0c927551..0c0e859b 100644 --- a/crates/hm-core/src/app_ctx.rs +++ b/crates/hm-core/src/app_ctx.rs @@ -44,7 +44,6 @@ pub struct AppCtx { user_config: Option, creds: CredsProvider, env: EnvVarProvider, - term: Term, } impl AppCtx { @@ -73,7 +72,6 @@ impl AppCtx { let creds = creds?; let env = EnvVarProvider::init(); - let term = Term::detect(&env); Ok(Self { git, @@ -83,7 +81,6 @@ impl AppCtx { user_config, creds, env, - term, }) } @@ -117,10 +114,11 @@ impl AppCtx { &self.dirs } - /// The terminal environment captured at initialization. + /// The terminal state of the standard streams, interpreted against the + /// captured environment. #[must_use] - pub const fn term(&self) -> Term { - self.term + pub fn term(&self) -> Term<'_> { + Term::detect(&self.env) } /// The environment variables captured at initialization. diff --git a/crates/hm-core/src/env.rs b/crates/hm-core/src/env.rs index b1a5620f..22986e58 100644 --- a/crates/hm-core/src/env.rs +++ b/crates/hm-core/src/env.rs @@ -1,8 +1,8 @@ -//! Process environment: the environment variables the CLI reads, captured at +//! Process environment: the environment facts the CLI reads, captured at //! startup. -/// The environment variables the CLI reads, captured once at startup so the -/// rest of the CLI reads them from here rather than hitting `std::env` inline. +/// The environment facts the CLI reads, captured once at startup so the rest of +/// the CLI reads them from here rather than hitting `std::env` inline. #[derive(Debug, Clone, Default)] pub struct EnvVarProvider { ssh_connection: Option, @@ -10,11 +10,12 @@ pub struct EnvVarProvider { ssh_client: Option, display: Option, wayland_display: Option, + ci: bool, no_color: bool, } impl EnvVarProvider { - /// Capture the current values of the environment variables the CLI reads. + /// Capture the current environment facts the CLI reads. #[must_use] pub fn init() -> Self { Self { @@ -23,39 +24,27 @@ impl EnvVarProvider { ssh_client: std::env::var("SSH_CLIENT").ok(), display: std::env::var("DISPLAY").ok(), wayland_display: std::env::var("WAYLAND_DISPLAY").ok(), + ci: is_ci::cached(), no_color: std::env::var_os("NO_COLOR").is_some(), } } - /// `SSH_CONNECTION` — set for a session opened over SSH. + /// Whether a CI runner is detected. #[must_use] - pub fn ssh_connection(&self) -> Option<&str> { - self.ssh_connection.as_deref() + pub const fn is_ci(&self) -> bool { + self.ci } - /// `SSH_TTY` — the SSH session's controlling terminal. + /// Whether the process runs inside an SSH session (any `SSH_*` var set). #[must_use] - pub fn ssh_tty(&self) -> Option<&str> { - self.ssh_tty.as_deref() + pub const fn is_ssh(&self) -> bool { + self.ssh_connection.is_some() || self.ssh_tty.is_some() || self.ssh_client.is_some() } - /// `SSH_CLIENT` — the SSH client's address. + /// Whether a display server is reachable (`DISPLAY` or `WAYLAND_DISPLAY` set). #[must_use] - pub fn ssh_client(&self) -> Option<&str> { - self.ssh_client.as_deref() - } - - /// `DISPLAY` — the X11 display, when a graphical session is reachable. - #[must_use] - pub fn display(&self) -> Option<&str> { - self.display.as_deref() - } - - /// `WAYLAND_DISPLAY` — the Wayland display, when a graphical session is - /// reachable. - #[must_use] - pub fn wayland_display(&self) -> Option<&str> { - self.wayland_display.as_deref() + pub const fn has_display(&self) -> bool { + self.display.is_some() || self.wayland_display.is_some() } /// Whether `NO_COLOR` is set (to any value), disabling ANSI color. diff --git a/crates/hm-core/src/term.rs b/crates/hm-core/src/term.rs index 17db4d17..c0328413 100644 --- a/crates/hm-core/src/term.rs +++ b/crates/hm-core/src/term.rs @@ -1,41 +1,29 @@ -//! The process's runtime environment: terminal, session, and display facts -//! captured at startup. +//! The process's runtime environment: terminal, session, and display facts. use std::io::IsTerminal as _; use crate::env::EnvVarProvider; -/// The runtime environment facts, captured once at startup. +/// The terminal state of the standard streams, paired with the environment +/// facts they are interpreted against. #[derive(Debug, Clone, Copy)] -#[allow( - clippy::struct_excessive_bools, - reason = "independent environment facts, not a packed state machine" -)] -pub struct Term { +pub struct Term<'env> { stdin: bool, stdout: bool, stderr: bool, - ci: bool, - ssh: bool, - display: bool, - no_color: bool, + env: &'env EnvVarProvider, } -impl Term { - /// Capture terminal state from the standard streams and CI signals, and the - /// session/display facts from `env`. +impl<'env> Term<'env> { + /// Capture terminal state from the standard streams, interpreted against + /// `env`. #[must_use] - pub fn detect(env: &EnvVarProvider) -> Self { + pub fn detect(env: &'env EnvVarProvider) -> Self { Self { stdin: std::io::stdin().is_terminal(), stdout: std::io::stdout().is_terminal(), stderr: std::io::stderr().is_terminal(), - ci: is_ci::cached(), - ssh: env.ssh_connection().is_some() - || env.ssh_tty().is_some() - || env.ssh_client().is_some(), - display: env.display().is_some() || env.wayland_display().is_some(), - no_color: env.no_color(), + env, } } @@ -43,14 +31,14 @@ impl Term { /// both terminals and no CI runner is present. #[must_use] pub const fn is_interactive(&self) -> bool { - self.stdin && self.stdout && !self.ci + self.stdin && self.stdout && !self.env.is_ci() } /// Whether the environment permits ANSI color: `NO_COLOR` is unset and /// stderr is a terminal. #[must_use] pub const fn wants_color(&self) -> bool { - !self.no_color && self.stderr + !self.env.no_color() && self.stderr } /// Whether stdin is a terminal. @@ -74,22 +62,22 @@ impl Term { /// Whether a CI runner is detected. #[must_use] pub const fn is_ci(&self) -> bool { - self.ci + self.env.is_ci() } /// Whether the process is running inside an SSH session. #[must_use] pub const fn is_ssh(&self) -> bool { - self.ssh + self.env.is_ssh() } /// Whether a graphical browser could plausibly be opened for the user. #[must_use] pub const fn has_gui(&self) -> bool { if cfg!(any(target_os = "macos", target_os = "windows")) { - !self.ssh + !self.env.is_ssh() } else { - self.display + self.env.has_display() } } } diff --git a/crates/hm/src/commands/cloud/settings.rs b/crates/hm/src/commands/cloud/settings.rs index 7459f05e..3da5c14a 100644 --- a/crates/hm/src/commands/cloud/settings.rs +++ b/crates/hm/src/commands/cloud/settings.rs @@ -130,7 +130,7 @@ pub struct RenderPrefs { impl RenderPrefs { /// Derive render preferences from the terminal state and `NO_COLOR`. #[must_use] - pub const fn detect(term: Term) -> Self { + pub const fn detect(term: Term<'_>) -> Self { Self { color: term.wants_color(), logs: !term.stdout_is_tty(), diff --git a/crates/hm/src/commands/cloud/verbs/build.rs b/crates/hm/src/commands/cloud/verbs/build.rs index cc4fecb8..b34294ee 100644 --- a/crates/hm/src/commands/cloud/verbs/build.rs +++ b/crates/hm/src/commands/cloud/verbs/build.rs @@ -69,7 +69,7 @@ async fn watch( org: &str, pipe: &str, number: i64, - term: Term, + term: Term<'_>, ) -> Result<()> { // Render the live build through the shared `hm-render` renderers (the same // ones a local `hm run` uses), driven by the `BuildEvent`s `watch_build` diff --git a/crates/hm/src/commands/cloud/verbs/job.rs b/crates/hm/src/commands/cloud/verbs/job.rs index 27eb15eb..ceb94ffd 100644 --- a/crates/hm/src/commands/cloud/verbs/job.rs +++ b/crates/hm/src/commands/cloud/verbs/job.rs @@ -71,7 +71,7 @@ async fn log_cmd( pipe: &str, build: i64, jid: &str, - term: Term, + term: Term<'_>, ) -> Result<()> { let job_id = Uuid::parse_str(jid) .map_err(|e| anyhow::anyhow!("job id '{jid}' is not a valid UUID: {e}"))?; From 93b4ecd594c4568d89ea84fbabf353e7108b67bd Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 29 Jul 2026 00:27:14 -0700 Subject: [PATCH 13/13] refactor(hm-core): split TerminalState from the Term env view Store the probed terminal state (TerminalState) and the EnvVarProvider as plain sibling fields on AppCtx. TerminalState::term(&self, env) binds the two into a Copy Term<'_> view; AppCtx::term() returns it. Terminal state is probed once at init (not per call), and there is no self-reference, so no ouroboros. --- crates/hm-core/src/app_ctx.rs | 24 ++++++++++----------- crates/hm-core/src/term.rs | 39 ++++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/crates/hm-core/src/app_ctx.rs b/crates/hm-core/src/app_ctx.rs index 0c0e859b..81cf060d 100644 --- a/crates/hm-core/src/app_ctx.rs +++ b/crates/hm-core/src/app_ctx.rs @@ -12,7 +12,7 @@ use crate::config::domain::ConfigLoadingError; use crate::config::user::UserConfig; use crate::creds::{CredsInitError, CredsProvider}; use crate::env::EnvVarProvider; -use crate::term::Term; +use crate::term::{Term, TerminalState}; /// Failure to initialize the [`AppCtx`]. #[derive(Debug, thiserror::Error)] @@ -34,7 +34,8 @@ pub enum InitError { Creds(#[from] CredsInitError), } -/// Resolved toolchain, platform directories, user config, and credentials. +/// Resolved toolchain, platform directories, user config, credentials, and the +/// captured environment and terminal state. #[derive(Debug)] pub struct AppCtx { git: PathBuf, @@ -44,6 +45,7 @@ pub struct AppCtx { user_config: Option, creds: CredsProvider, env: EnvVarProvider, + term: TerminalState, } impl AppCtx { @@ -71,8 +73,6 @@ impl AppCtx { let user_config = user_config.map_err(InitError::UserConfig)?; let creds = creds?; - let env = EnvVarProvider::init(); - Ok(Self { git, python3, @@ -80,7 +80,8 @@ impl AppCtx { dirs, user_config, creds, - env, + env: EnvVarProvider::init(), + term: TerminalState::detect(), }) } @@ -114,17 +115,16 @@ impl AppCtx { &self.dirs } - /// The terminal state of the standard streams, interpreted against the - /// captured environment. + /// The environment facts captured at initialization. #[must_use] - pub fn term(&self) -> Term<'_> { - Term::detect(&self.env) + pub const fn env(&self) -> &EnvVarProvider { + &self.env } - /// The environment variables captured at initialization. + /// The terminal state captured at initialization, bound to the environment. #[must_use] - pub const fn env(&self) -> &EnvVarProvider { - &self.env + pub const fn term(&self) -> Term<'_> { + self.term.term(&self.env) } /// The user config, or `None` when no user config file is present. diff --git a/crates/hm-core/src/term.rs b/crates/hm-core/src/term.rs index c0328413..b3457727 100644 --- a/crates/hm-core/src/term.rs +++ b/crates/hm-core/src/term.rs @@ -4,59 +4,70 @@ use std::io::IsTerminal as _; use crate::env::EnvVarProvider; -/// The terminal state of the standard streams, paired with the environment -/// facts they are interpreted against. +/// The terminal state of the standard streams, probed once at startup. #[derive(Debug, Clone, Copy)] -pub struct Term<'env> { +pub struct TerminalState { stdin: bool, stdout: bool, stderr: bool, - env: &'env EnvVarProvider, } -impl<'env> Term<'env> { - /// Capture terminal state from the standard streams, interpreted against - /// `env`. +impl TerminalState { + /// Probe whether each standard stream is a terminal. #[must_use] - pub fn detect(env: &'env EnvVarProvider) -> Self { + pub fn detect() -> Self { Self { stdin: std::io::stdin().is_terminal(), stdout: std::io::stdout().is_terminal(), stderr: std::io::stderr().is_terminal(), - env, } } + /// Interpret the probed terminal state against the environment facts `env`. + #[must_use] + pub const fn term<'a>(&'a self, env: &'a EnvVarProvider) -> Term<'a> { + Term { term: self, env } + } +} + +/// The terminal state interpreted against the environment facts it is bound to. +#[derive(Debug, Clone, Copy)] +pub struct Term<'a> { + term: &'a TerminalState, + env: &'a EnvVarProvider, +} + +impl Term<'_> { /// Whether the app can drive an interactive session: stdin and stdout are /// both terminals and no CI runner is present. #[must_use] pub const fn is_interactive(&self) -> bool { - self.stdin && self.stdout && !self.env.is_ci() + self.term.stdin && self.term.stdout && !self.env.is_ci() } /// Whether the environment permits ANSI color: `NO_COLOR` is unset and /// stderr is a terminal. #[must_use] pub const fn wants_color(&self) -> bool { - !self.env.no_color() && self.stderr + !self.env.no_color() && self.term.stderr } /// Whether stdin is a terminal. #[must_use] pub const fn stdin_is_tty(&self) -> bool { - self.stdin + self.term.stdin } /// Whether stdout is a terminal. #[must_use] pub const fn stdout_is_tty(&self) -> bool { - self.stdout + self.term.stdout } /// Whether stderr is a terminal. #[must_use] pub const fn stderr_is_tty(&self) -> bool { - self.stderr + self.term.stderr } /// Whether a CI runner is detected.