diff --git a/Cargo.lock b/Cargo.lock index df60f48c..fc0113ad 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,42 @@ 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", + "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 +1294,7 @@ dependencies = [ "unicode-width", "which", "windows", + "zeroize", ] [[package]] @@ -1295,6 +1317,7 @@ dependencies = [ "hm-vm", "human-units", "ignore", + "is_ci", "rstest", "secrecy", "serde", @@ -4404,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/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..9a37d211 --- /dev/null +++ b/crates/hm-cloud/Cargo.toml @@ -0,0 +1,27 @@ +[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 } +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 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..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/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-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..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-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..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/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/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 915fa3aa..20151d9f 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 => auth::login::run(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/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..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 _; @@ -70,6 +71,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 +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/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..7940e154 100644 --- a/crates/hm/src/commands/cloud/verbs/build.rs +++ b/crates/hm/src/commands/cloud/verbs/build.rs @@ -5,10 +5,11 @@ 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; +use hm_core::term::Term; pub(crate) async fn run( _env: &BTreeMap, @@ -19,10 +20,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, app.term()).await + } } } @@ -57,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 86649ae6..2a9403f8 100644 --- a/crates/hm/src/commands/cloud/verbs/job.rs +++ b/crates/hm/src/commands/cloud/verbs/job.rs @@ -9,10 +9,11 @@ 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; +use hm_core::term::Term; pub(crate) async fn run( _env: &BTreeMap, @@ -23,17 +24,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, app.term()).await + } } } @@ -67,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}"))?; @@ -75,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/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 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..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 } 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")); +}