diff --git a/Cargo.lock b/Cargo.lock
index df60f48c..45bdc433 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",
@@ -1249,22 +1250,43 @@ 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",
+ "dialoguer",
+ "harmont-cloud",
+ "hm-common",
+ "hm-core",
+ "secrecy",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "url",
+ "webbrowser",
+]
+
[[package]]
name = "hm-common"
version = "0.0.0-dev"
dependencies = [
"anyhow",
"async-trait",
+ "base64",
"bstr",
"chrono",
"derive_more",
"dirs",
+ "hex",
"include_dir",
"num-traits",
"proptest",
+ "rand 0.8.6",
"rstest",
"serde",
"serde_json",
+ "subtle",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -1273,6 +1295,7 @@ dependencies = [
"unicode-width",
"which",
"windows",
+ "zeroize",
]
[[package]]
@@ -1295,6 +1318,7 @@ dependencies = [
"hm-vm",
"human-units",
"ignore",
+ "is_ci",
"rstest",
"secrecy",
"serde",
@@ -4404,6 +4428,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/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/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-cloud/Cargo.toml b/crates/hm-cloud/Cargo.toml
new file mode 100644
index 00000000..ad31fe0a
--- /dev/null
+++ b/crates/hm-cloud/Cargo.toml
@@ -0,0 +1,28 @@
+[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"] }
+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 }
+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..cac17f98
--- /dev/null
+++ b/crates/hm-cloud/src/auth.rs
@@ -0,0 +1,310 @@
+//! 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 secrecy::ExposeSecret as _;
+use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpListener};
+use thiserror::Error;
+use tracing::{info, instrument, warn};
+use url::Url;
+
+/// 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 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 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()
+ .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}");
+ }
+
+ tokio::spawn(Self::accept(listener));
+ Ok(())
+ }
+
+ 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();
+ }
+}
+
+/// 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),
+}
+
+/// 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, 'config> {
+ app_ctx: &'app AppCtx,
+ config: &'config ResolvedCloudConfig,
+}
+
+impl<'app, 'config> AuthProvider<'app, 'config> {
+ /// Create a new authentication provider.
+ #[must_use]
+ 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 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<(), 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;
+
+ // 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(())
+ }
+
+ /// 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(())
+ }
+
+ /// 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?;
+
+ // 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, client: &HarmontClient) -> Result {
+ Ok(PasteTokenAuth::login(client, self.config.domain.app()).await?)
+ }
+}
diff --git a/crates/hm-cloud/src/cli.rs b/crates/hm-cloud/src/cli.rs
new file mode 100644
index 00000000..6623ebf2
--- /dev/null
+++ b/crates/hm-cloud/src/cli.rs
@@ -0,0 +1,158 @@
+//! 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,
+ /// 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..8043fa10
--- /dev/null
+++ b/crates/hm-cloud/src/lib.rs
@@ -0,0 +1,4 @@
+//! Harmont cloud: the cloud execution backend and `hm cloud` command surface.
+
+pub mod auth;
+pub mod cli;
diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml
index 13eaf7f8..b63abe7f 100644
--- a/crates/hm-common/Cargo.toml
+++ b/crates/hm-common/Cargo.toml
@@ -20,12 +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/git.rs b/crates/hm-common/src/git.rs
index a0033387..33adffa3 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,87 @@ use bstr::{BStr, BString, ByteSlice};
use crate::process::{CapturedStreams as _, CommandExt as _};
+/// 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 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")]
+ NotHex,
+}
+
+impl GitSha {
+ /// The all-zero null oid git uses to mean "no commit".
+ #[must_use]
+ 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 == [0; 20]
+ }
+}
+
+impl std::str::FromStr for GitSha {
+ type Err = GitShaError;
+
+ fn from_str(s: &str) -> Result {
+ if s.len() != 40 {
+ return Err(GitShaError::BadLength(s.len()));
+ }
+ let mut bytes = [0; 20];
+ hex::decode_to_slice(s, &mut bytes).map_err(|_| GitShaError::NotHex)?;
+ Ok(Self(bytes))
+ }
+}
+
+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 {
+ for byte in self.0 {
+ write!(f, "{byte:02x}")?;
+ }
+ Ok(())
+ }
+}
+
+impl serde::Serialize for GitSha {
+ fn serialize(&self, serializer: S) -> Result {
+ serializer.collect_str(self)
+ }
+}
+
+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 +176,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()
}
}
@@ -180,8 +262,68 @@ 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::lower("0123456789abcdef0123456789abcdef01234567")]
+ #[case::upper("0123456789ABCDEF0123456789ABCDEF01234567")]
+ fn parses_and_renders_lowercase(#[case] input: &str) {
+ let sha: GitSha = input.parse().unwrap();
+ assert_eq!(sha.to_string(), input.to_ascii_lowercase());
+ }
+
+ #[rstest]
+ #[case::empty(0)]
+ #[case::short(39)]
+ #[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)));
+ }
+
+ #[rstest]
+ fn rejects_non_hex() {
+ let with_g = format!("{}g", "a".repeat(39));
+ assert_eq!(with_g.parse::(), Err(GitShaError::NotHex));
+ }
+
+ #[rstest]
+ fn zero_is_the_null_oid() {
+ let sha: GitSha = "0".repeat(40).parse().unwrap();
+ assert!(sha.is_zero());
+ assert_eq!(sha, GitSha::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());
+ }
+
+ 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"))]
@@ -251,7 +393,7 @@ mod tests {
let branch = repo.current_branch().unwrap();
assert_eq!(branch.name(), "main");
- assert_eq!(branch.head_commit().unwrap().len(), 40);
+ assert!(!branch.head_commit().unwrap().is_zero());
let remote = repo.remote("origin").unwrap();
assert_eq!(remote.name(), "origin");
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()));
+ }
+}
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..81cf060d 100644
--- a/crates/hm-core/src/app_ctx.rs
+++ b/crates/hm-core/src/app_ctx.rs
@@ -11,6 +11,8 @@ 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, TerminalState};
/// Failure to initialize the [`AppCtx`].
#[derive(Debug, thiserror::Error)]
@@ -32,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,
@@ -41,6 +44,8 @@ pub struct AppCtx {
dirs: DirProvider,
user_config: Option,
creds: CredsProvider,
+ env: EnvVarProvider,
+ term: TerminalState,
}
impl AppCtx {
@@ -75,6 +80,8 @@ impl AppCtx {
dirs,
user_config,
creds,
+ env: EnvVarProvider::init(),
+ term: TerminalState::detect(),
})
}
@@ -108,6 +115,18 @@ impl AppCtx {
&self.dirs
}
+ /// The environment facts captured at initialization.
+ #[must_use]
+ pub const fn env(&self) -> &EnvVarProvider {
+ &self.env
+ }
+
+ /// The terminal state captured at initialization, bound to the environment.
+ #[must_use]
+ pub const fn term(&self) -> Term<'_> {
+ self.term.term(&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 410cc9ca..11d91cf8 100644
--- a/crates/hm-core/src/config/domain.rs
+++ b/crates/hm-core/src/config/domain.rs
@@ -49,26 +49,40 @@ 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 {
- self.subdomain_url("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 {
- self.subdomain_url("app")
+ self.app().as_str().trim_end_matches('/').to_owned()
+ }
+
+ /// 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_url(&self, sub: &str) -> String {
+ 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(),
}
}
}
diff --git a/crates/hm-core/src/env.rs b/crates/hm-core/src/env.rs
new file mode 100644
index 00000000..22986e58
--- /dev/null
+++ b/crates/hm-core/src/env.rs
@@ -0,0 +1,55 @@
+//! Process environment: the environment facts the CLI reads, captured at
+//! startup.
+
+/// 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,
+ ssh_tty: Option,
+ ssh_client: Option,
+ display: Option,
+ wayland_display: Option,
+ ci: bool,
+ no_color: bool,
+}
+
+impl EnvVarProvider {
+ /// Capture the current environment facts the CLI reads.
+ #[must_use]
+ pub fn init() -> Self {
+ 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(),
+ ci: is_ci::cached(),
+ no_color: std::env::var_os("NO_COLOR").is_some(),
+ }
+ }
+
+ /// Whether a CI runner is detected.
+ #[must_use]
+ pub const fn is_ci(&self) -> bool {
+ self.ci
+ }
+
+ /// Whether the process runs inside an SSH session (any `SSH_*` var set).
+ #[must_use]
+ pub const fn is_ssh(&self) -> bool {
+ self.ssh_connection.is_some() || self.ssh_tty.is_some() || self.ssh_client.is_some()
+ }
+
+ /// Whether a display server is reachable (`DISPLAY` or `WAYLAND_DISPLAY` set).
+ #[must_use]
+ 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.
+ #[must_use]
+ pub const fn no_color(&self) -> bool {
+ self.no_color
+ }
+}
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/src/lib.rs b/crates/hm-core/src/lib.rs
index 746fedd0..5f212d9e 100644
--- a/crates/hm-core/src/lib.rs
+++ b/crates/hm-core/src/lib.rs
@@ -9,5 +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
new file mode 100644
index 00000000..b3457727
--- /dev/null
+++ b/crates/hm-core/src/term.rs
@@ -0,0 +1,94 @@
+//! The process's runtime environment: terminal, session, and display facts.
+
+use std::io::IsTerminal as _;
+
+use crate::env::EnvVarProvider;
+
+/// The terminal state of the standard streams, probed once at startup.
+#[derive(Debug, Clone, Copy)]
+pub struct TerminalState {
+ stdin: bool,
+ stdout: bool,
+ stderr: bool,
+}
+
+impl TerminalState {
+ /// Probe whether each standard stream is a terminal.
+ #[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(),
+ }
+ }
+
+ /// 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.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.term.stderr
+ }
+
+ /// Whether stdin is a terminal.
+ #[must_use]
+ pub const fn stdin_is_tty(&self) -> bool {
+ self.term.stdin
+ }
+
+ /// Whether stdout is a terminal.
+ #[must_use]
+ pub const fn stdout_is_tty(&self) -> bool {
+ self.term.stdout
+ }
+
+ /// Whether stderr is a terminal.
+ #[must_use]
+ pub const fn stderr_is_tty(&self) -> bool {
+ self.term.stderr
+ }
+
+ /// Whether a CI runner is detected.
+ #[must_use]
+ pub const fn is_ci(&self) -> bool {
+ self.env.is_ci()
+ }
+
+ /// Whether the process is running inside an SSH session.
+ #[must_use]
+ pub const fn is_ssh(&self) -> bool {
+ 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.env.is_ssh()
+ } else {
+ self.env.has_display()
+ }
+ }
+}
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-render/src/lib.rs b/crates/hm-render/src/lib.rs
index 42d4599d..391ce888 100644
--- a/crates/hm-render/src/lib.rs
+++ b/crates/hm-render/src/lib.rs
@@ -7,32 +7,9 @@
//! 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.
-#[must_use]
-pub fn stdout_piped() -> bool {
- !std::io::stdout().is_terminal()
-}
-
pub mod human;
pub mod json;
pub mod progress;
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..86e72a93 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)]
@@ -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/auth/login.rs b/crates/hm/src/commands/cloud/auth/login.rs
index f1200ddd..7980624d 100644
--- a/crates/hm/src/commands/cloud/auth/login.rs
+++ b/crates/hm/src/commands/cloud/auth/login.rs
@@ -1,163 +1,15 @@
-//! `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 login flow via
+//! [`hm_cloud::auth::AuthProvider`].
-use std::collections::BTreeMap;
-use std::time::Duration;
-
-use anyhow::{Result, bail};
-use harmont_cloud::{HarmontClient, HarmontError};
+use anyhow::Result;
use hm_core::app_ctx::AppCtx;
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);
- 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?
- };
-
- app_ctx.creds().set(&token).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(())
-}
-
-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());
- }
+pub(crate) async fn run(app: &AppCtx) -> Result<()> {
+ let config = settings::auth_config(app);
+ hm_cloud::auth::AuthProvider::new(app, &config)
+ .try_login()
+ .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 915fa3aa..06bc63d0 100644
--- a/crates/hm/src/commands/cloud/cli.rs
+++ b/crates/hm/src/commands/cloud/cli.rs
@@ -1,9 +1,7 @@
-//! CLI parsing for cloud subcommands.
-
-use std::collections::BTreeMap;
+//! Dispatch for `hm cloud` subcommands.
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,155 +23,24 @@ 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
///
/// 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::Login { paste } => auth::login::run(&env, paste, app).await,
- CloudCommand::Logout => auth::logout::run(&env, app).await,
- CloudCommand::Whoami => auth::whoami::run(&env, app).await,
- CloudCommand::Org(cmd) => verbs::org::run(&env, cmd, app).await,
- CloudCommand::Pipeline(cmd) => verbs::pipeline::run(&env, cmd, app).await,
- CloudCommand::Build(cmd) => verbs::build::run(&env, cmd, app).await,
- CloudCommand::Job(cmd) => verbs::job::run(&env, cmd, app).await,
- CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd, app).await,
- CloudCommand::Run(args) => verbs::run::run(&env, args, app).await,
+ 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(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/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
}
diff --git a/crates/hm/src/commands/cloud/settings.rs b/crates/hm/src/commands/cloud/settings.rs
index d7a36759..3da5c14a 100644
--- a/crates/hm/src/commands/cloud/settings.rs
+++ b/crates/hm/src/commands/cloud/settings.rs
@@ -6,8 +6,10 @@
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::config::user::UserCloudConfig;
+use hm_core::term::Term;
use secrecy::ExposeSecret as _;
/// Resolved cloud context for the `hm cloud` verbs.
@@ -50,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.
@@ -70,6 +84,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) {
@@ -93,12 +128,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 const fn detect(term: Term<'_>) -> Self {
Self {
- color: hm_render::color_enabled(false),
- logs: hm_render::stdout_piped(),
+ color: term.wants_color(),
+ logs: !term.stdout_is_tty(),
}
}
}
diff --git a/crates/hm/src/commands/cloud/verbs/billing.rs b/crates/hm/src/commands/cloud/verbs/billing.rs
index dff8bd4a..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 crate::commands::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 fe0d3577..b34294ee 100644
--- a/crates/hm/src/commands/cloud/verbs/build.rs
+++ b/crates/hm/src/commands/cloud/verbs/build.rs
@@ -1,28 +1,35 @@
//! `hm cloud build list|show|cancel|watch`.
-use std::collections::BTreeMap;
-
use anyhow::Result;
use harmont_cloud::HarmontClient;
-use crate::commands::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()?;
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, app.term()).await
+ }
}
}
@@ -57,11 +64,17 @@ 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 86649ae6..ceb94ffd 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,31 +7,37 @@ use hm_plugin_protocol::events::{BuildEvent, PlanSummary};
use hm_plugin_protocol::ir::DurationMs;
use uuid::Uuid;
-use crate::commands::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()?;
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, app.term()).await
+ }
}
}
@@ -67,6 +71,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}"))?;
@@ -75,7 +80,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/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..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 crate::commands::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 ebd95556..1139eb73 100644
--- a/crates/hm/src/commands/cloud/verbs/pipeline.rs
+++ b/crates/hm/src/commands/cloud/verbs/pipeline.rs
@@ -1,25 +1,22 @@
//! `hm cloud pipeline list|show`.
-use std::collections::BTreeMap;
-
use anyhow::Result;
use harmont_cloud::HarmontClient;
-use crate::commands::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()?;
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 700a0496..00000000
--- a/crates/hm/src/commands/cloud/verbs/run.rs
+++ /dev/null
@@ -1,86 +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_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 = "0000000000000000000000000000000000000000"
- )]
- pub commit: String,
- /// 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.clone(),
- 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/src/commands/run/mod.rs b/crates/hm/src/commands/run/mod.rs
index 5aa6fb0d..17a51d17 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};
@@ -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.
@@ -178,9 +176,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)
diff --git a/crates/hm/src/context.rs b/crates/hm/src/context.rs
index 2400aa7c..182da755 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: !cli.no_color && term.wants_color(),
// Interactive prompts/spinners key off stdout being a TTY.
- interactive: std::io::stdout().is_terminal(),
+ interactive: term.stdout_is_tty(),
};
Self { app, output }
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"));
+}