Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
sed -i "s|hm-render = { path = \"crates/hm-render\", version = \"0.0.0-dev\" }|hm-render = { path = \"crates/hm-render\", version = \"$VERSION\" }|" Cargo.toml
sed -i "s|hm-vm = { path = \"crates/hm-vm\", version = \"0.0.0-dev\" }|hm-vm = { path = \"crates/hm-vm\", version = \"$VERSION\" }|" Cargo.toml

cargo check --workspace --exclude hm-fixtures
cargo check --workspace

- name: Publishability guard (dry-run package the whole graph)
# Fail fast before any real `cargo publish` if the dependency graph
Expand All @@ -68,7 +68,7 @@ jobs:
# would fail), so it catches exactly the publish=false / unpublished-dep
# class of regression. `--no-verify` skips the per-crate rebuild;
# `cargo check --workspace` above already proved it compiles.
run: cargo package --workspace --exclude hm-fixtures --allow-dirty --no-verify
run: cargo package --workspace --allow-dirty --no-verify

- name: Publish hm-util
run: |
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ The `cli/` directory is a Cargo workspace.
- `crates/hm-pipeline-ir/` — pipeline IR schema (serde structs only, no runtime).
- `crates/hm-util/` — shared OS and filesystem utilities.
- `crates/hm-plugin-protocol/` — wire types (serde structs only).
- `crates/hm-plugin-sdk/` — authoring SDK for plugin writers.
Run `cargo build` from the workspace root.

For cross-cutting doctrine see [PRINCIPLES.md](../PRINCIPLES.md).
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ cargo insta review
| `crates/hm-pipeline-ir` | The pipeline IR wire-format schema. |
| `crates/hm-config` | Layered configuration and credential storage. |
| `crates/hm-plugin-cloud` | Cloud client library. |
| `crates/hm-plugin-protocol` | Wire types shared between `hm` and plugins. |
| `crates/hm-plugin-protocol` | Wire types shared between `hm` crate internals. |
| `crates/hm-util` | Shared OS and filesystem utilities. |

A useful mental model: the DSL engine evaluates your `.hm/*.py` pipeline
Expand Down
2 changes: 0 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ hm-util → hm-pipeline-ir → hm-config → hm-plugin-protocol → hm-render

`harmont-cli` (the `hm` binary) depends on every other crate, so they
must all reach crates.io first — including `hm-vm`, which carries the
local Docker backend. `hm-fixtures` is test-only and not published.
local Docker backend.

### Prerequisites (one-time)

Expand Down
9 changes: 4 additions & 5 deletions crates/hm-config/src/creds.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! File-backed credential store at `~/.config/hm/credentials.toml`.
//!
//! Replaces the OS keyring as the sole backend. The file is written with
//! mode 0o600 (parent dir 0o700) via [`hm_util::os::fs::blocking::write_atomic_restricted`].
//! Keyed by `(service, account)` to match the host-fn ABI plugins use.
//! The file is written with mode 0o600 (parent dir 0o700) via
//! [`hm_util::os::fs::blocking::write_atomic_restricted`], keyed by
//! `(service, account)`.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -50,8 +50,7 @@ pub fn get(service: &str, account: &str) -> Option<String> {
load().entries.get(service)?.get(account).cloned()
}

/// Write a credential. Silently no-ops on I/O failure so plugin callers
/// match the prior keyring-backed best-effort semantics.
/// Write a credential. Silently no-ops on I/O failure (best-effort).
pub fn set(service: &str, account: &str, secret: &str) {
let mut f = load();
f.entries
Expand Down
8 changes: 4 additions & 4 deletions crates/hm-exec/src/local/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
//!
//! On build start the orchestrator tar.gzs the user's working
//! directory once (via [`crate::local::build_archive_bytes`])
//! and registers the bytes under an opaque `ArchiveId`. Step-executor
//! plugins receive that ID in their `ExecutorInput` and pull bytes
//! via `hm_archive_read`. The host caches archives in memory keyed
//! by ID for the duration of a single `local::run` invocation.
//! and registers the bytes under an opaque `ArchiveId`. Runners receive
//! that ID in their `ExecutorInput` and read the bytes back from this
//! store. Archives are cached in memory keyed by ID for the duration of
//! a single `local::run` invocation.

use std::collections::HashMap;
use std::sync::Mutex;
Expand Down
8 changes: 3 additions & 5 deletions crates/hm-exec/src/local/events.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
//! Build-event broadcast channel.
//!
//! Subscribers (output formatter plugin, lifecycle hook plugins,
//! the human-readable progress sink) all subscribe to the same
//! channel; the host's `emit_event` / `emit_step_log` host fns
//! publish into it.
//! Subscribers (the output renderers and progress sink) subscribe to the
//! same channel; runners and the scheduler publish [`BuildEvent`]s into it.

// `new()` returning `Arc<Self>` is intentional (the bus is always
// shared); `subscribe()` returns a tokio receiver that callers must
Expand Down Expand Up @@ -39,7 +37,7 @@ impl EventBus {
/// received it. A return of 0 is normal (no subscribers yet).
pub fn emit(&self, event: BuildEvent) {
// We intentionally drop the error: zero-subscriber sends are
// not interesting and we don't want host_fn impls to fail
// not interesting and we don't want publishers to fail
// because nobody is listening.
let _ = self.tx.send(event);
}
Expand Down
9 changes: 3 additions & 6 deletions crates/hm-exec/src/local/runner/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
//! Static runner interface.
//!
//! This module replaces the old WASM plugin system with a static DI
//! approach. Step executors implement [`StepRunner`]. A [`RunnerRegistry`]
//! maps runner names to concrete implementations at startup.
//! Step executors implement [`StepRunner`]; a [`RunnerRegistry`] maps
//! runner names to concrete implementations at startup.

use std::collections::HashMap;
use std::fmt;
Expand All @@ -21,9 +20,7 @@ pub mod vm;

/// Shared context threaded into every runner invocation.
///
/// Replaces the monolithic `OrchestratorState` that the old plugin
/// system passed as opaque host memory. All fields are cheaply
/// cloneable (`Arc` / `CancellationToken`).
/// All fields are cheaply cloneable (`Arc` / `CancellationToken`).
#[derive(Clone, Debug)]
pub struct StepContext {
pub event_bus: Arc<EventBus>,
Expand Down
4 changes: 2 additions & 2 deletions crates/hm-pipeline-ir/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ pub struct CommandStep {
/// Cache configuration for this step's committed snapshot.
#[serde(default)]
pub cache: Option<Cache>,
/// Step-executor plugin name. `None` falls back to the default
/// Step-executor (runner) name. `None` falls back to the default
/// runner (Docker in the shipped configuration).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runner: Option<String>,
/// Plugin-specific extra fields passed verbatim to the runner.
/// Runner-specific extra fields passed verbatim to the runner.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runner_args: Option<serde_json::Value>,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,14 @@ expression: schema
]
},
"runner": {
"description": "Step-executor plugin name. `None` falls back to the default runner (Docker in the shipped configuration).",
"description": "Step-executor (runner) name. `None` falls back to the default runner (Docker in the shipped configuration).",
"type": [
"string",
"null"
]
},
"runner_args": {
"description": "Plugin-specific extra fields passed verbatim to the runner."
"description": "Runner-specific extra fields passed verbatim to the runner."
}
},
"definitions": {
Expand Down
4 changes: 2 additions & 2 deletions crates/hm-plugin-cloud/src/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async fn login_loopback(client: &HarmontClient, app: &str) -> Result<String> {
}
};
// Give the browser up to 3 minutes to complete sign-in and redirect.
let _ = tokio::time::timeout(Duration::from_secs(180), accept).await;
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.
Expand All @@ -92,7 +92,7 @@ async fn login_loopback(client: &HarmontClient, app: &str) -> Result<String> {

/// Poll `claim_token` until the token is parked or the ~60s window elapses.
async fn poll_claim(client: &HarmontClient, nonce: &str) -> Result<String> {
let deadline = std::time::Instant::now() + Duration::from_secs(60);
let deadline = std::time::Instant::now() + Duration::from_mins(1);
loop {
match client.claim_token(nonce).await {
Ok(token) => return Ok(token),
Expand Down
4 changes: 4 additions & 0 deletions crates/hm-plugin-cloud/src/auth/logout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ use anyhow::Result;

use crate::settings;

#[allow(
clippy::unused_async,
reason = "async to match the uniform await arm in cli::dispatch_command"
)]
pub(crate) async fn run(_env: &BTreeMap<String, String>) -> Result<()> {
let (_client, api) = settings::anon_client()?;
hm_config::creds::forget_cloud_token(&api);
Expand Down
3 changes: 1 addition & 2 deletions crates/hm-plugin-cloud/src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
//! `hm cloud login | logout | whoami`.
//!
//! Each submodule exposes a single `run(env, ...)` entry point that
//! the plugin's `cli::dispatch` calls once it has parsed argv.
//! Each submodule exposes a single `run(env, ...)` entry point.

pub(crate) mod login;
pub(crate) mod logout;
Expand Down
59 changes: 7 additions & 52 deletions crates/hm-plugin-cloud/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,27 @@
use std::collections::BTreeMap;

use anyhow::Result;
use clap::{Parser, Subcommand};
use clap::Subcommand;

use crate::{auth, verbs};

/// Process exit status for the cloud subcommands.
///
/// Centralizes the otherwise-magic 0/1/2 integers so each status is
/// self-documenting and the mapping lives in exactly one place.
enum ExitCode {
/// The command completed successfully.
Success,
/// The command ran but failed at runtime.
RuntimeError,
/// The arguments could not be parsed.
UsageError,
}

impl From<ExitCode> for i32 {
fn from(code: ExitCode) -> Self {
match code {
ExitCode::Success => 0,
ExitCode::RuntimeError => 1,
ExitCode::UsageError => 2,
}
}
}

#[derive(Debug, Parser)]
#[command(
name = "hm cloud",
about = "Talk to the Harmont cloud API",
disable_help_subcommand = true
)]
struct CloudCli {
#[command(subcommand)]
command: CloudCommand,
}

#[derive(Debug, Clone, Subcommand)]
pub enum CloudCommand {
/// Authenticate this CLI against the Harmont API.
Expand Down Expand Up @@ -169,40 +152,12 @@ pub enum BillingCommand {
Redeem { code: String },
}

/// Dispatch from raw argv (used if calling from an external-subcommand
/// pattern). Returns an exit code.
pub async fn dispatch(argv: Vec<String>, env: BTreeMap<String, String>) -> Result<i32> {
let mut full: Vec<String> = vec!["hm cloud".to_string()];
full.extend(argv.into_iter().skip(1));
let parsed = match CloudCli::try_parse_from(&full) {
Ok(p) => p,
Err(e) => {
use clap::error::ErrorKind;
let msg = e.to_string();
return match e.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
#[allow(clippy::print_stdout)]
{
use std::io::Write;
std::io::stdout().write_all(msg.as_bytes()).ok();
}
Ok(ExitCode::Success.into())
}
_ => {
#[allow(clippy::print_stderr)]
{
use std::io::Write;
std::io::stderr().write_all(msg.as_bytes()).ok();
}
Ok(ExitCode::UsageError.into())
}
};
}
};
dispatch_command(parsed.command, env).await
}

/// Dispatch from a pre-parsed `CloudCommand`. Returns an exit code.
/// 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<String, String>) -> Result<i32> {
let result = match command {
CloudCommand::Login { paste } => auth::login::run(&env, paste).await,
Expand Down
10 changes: 0 additions & 10 deletions crates/hm-plugin-cloud/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,6 @@
//!
//! Implements `hm cloud {login,logout,whoami,org,pipeline,build,job,billing,run}`.

#![allow(
clippy::pedantic,
clippy::nursery,
clippy::cargo,
clippy::multiple_crate_versions,
clippy::cargo_common_metadata,
clippy::missing_errors_doc,
reason = "quick migration from plugin crate; polish later"
)]

pub mod cli;
pub mod settings;

Expand Down
26 changes: 11 additions & 15 deletions crates/hm-plugin-cloud/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ impl ResolvedCtx {
///
/// Returns an error if config can't be loaded or no token is available.
pub fn client() -> Result<(HarmontClient, ResolvedCtx)> {
let cfg = hm_config::Config::load(None).context("loading config")?; // user + env layering
let cfg = hm_config::Config::load(None).context("loading config")?;
let api = cfg.cloud.api_url.clone();
let token = hm_config::creds::cloud_token(&api)
.context("not logged in — run `hm cloud login` or set HM_API_TOKEN")?;
Expand All @@ -64,7 +64,7 @@ pub fn client() -> Result<(HarmontClient, ResolvedCtx)> {
/// Returns an error if config can't be loaded.
pub fn anon_client() -> Result<(HarmontClient, String)> {
let cfg = hm_config::Config::load(None).context("loading config")?;
let api = cfg.cloud.api_url.clone();
let api = cfg.cloud.api_url;
Ok((HarmontClient::anonymous(&api), api))
}

Expand All @@ -75,10 +75,6 @@ pub fn anon_client() -> Result<(HarmontClient, String)> {
#[derive(Debug, Clone, Copy)]
pub struct RenderPrefs {
/// ANSI enabled when `NO_COLOR` is unset and stderr is a TTY.
///
/// The plugin verbs have no `--no-color` flag, so we pass `false` for the
/// flag; the `--no-color` asymmetry vs. the host `hm run` path is explicit
/// here at the call site.
pub color: bool,
/// Force the streaming `HumanRenderer` over the live `ProgressRenderer`.
///
Expand All @@ -88,11 +84,7 @@ pub struct RenderPrefs {
}

impl RenderPrefs {
/// Detect render preferences from the live environment via `hm-render`'s
/// shared TTY/color helpers — the single source of truth, also used by
/// `hm/src/context.rs`. This inspects `NO_COLOR` and the stdout/stderr
/// TTY state at call time, so it is a deliberate observation of the
/// environment rather than a constant default.
/// Detect render preferences from the current `NO_COLOR` and TTY state.
#[must_use]
pub fn detect() -> Self {
Self {
Expand All @@ -102,10 +94,14 @@ impl RenderPrefs {
}
}

/// Map a raw generated-client error into an `anyhow` error with a readable
/// message. The raw `Error<E>` renders the server's error body (status,
/// headers, decoded value) via its `Display` impl, which holds for any
/// `E: Debug` — true of the generated `types::Error` body.
/// Map a raw generated-client error into a readable `anyhow` error.
///
/// The server's error body (status, headers, decoded value) is rendered
/// via the raw `Error<E>`'s `Display` impl.
#[allow(
clippy::needless_pass_by_value,
reason = "by-value signature lets this be used directly as `.map_err(map_raw)`"
)]
pub fn map_raw<E: std::fmt::Debug>(e: harmont_cloud_raw::Error<E>) -> anyhow::Error {
anyhow::anyhow!("{e}")
}
Loading
Loading