Skip to content

Commit 6ec49ef

Browse files
feat(init): ask to link org during hm init (CLI-39) (#110)
1 parent 3cdf447 commit 6ec49ef

3 files changed

Lines changed: 144 additions & 2 deletions

File tree

crates/hm-plugin-cloud/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,17 @@ pub mod settings;
1717

1818
mod auth;
1919
mod verbs;
20+
21+
/// Run the interactive browser-loopback login flow.
22+
///
23+
/// Designed for embedding in host commands (e.g. `hm init`) that need
24+
/// the user to authenticate before proceeding.
25+
///
26+
/// # Errors
27+
///
28+
/// Returns an error if the browser cannot be opened, the login times
29+
/// out, or the token cannot be persisted.
30+
pub async fn login_interactive() -> anyhow::Result<()> {
31+
let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
32+
auth::login::run(&env, false).await
33+
}

crates/hm/src/commands/init.rs

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,86 @@ fn prompt_skills() -> Result<bool> {
8888
Ok(install)
8989
}
9090

91+
/// Prompt the user to link this repo to a Harmont Cloud organization.
92+
///
93+
/// Flow:
94+
/// - If not logged in → offer to log in first (Confirm, default no).
95+
/// - If logged in (or just logged in) → fetch orgs → Select with "No, skip" as first item.
96+
/// - On org selection → write a sparse `.hm/config.toml` with `backend = "cloud"` and the org slug.
97+
///
98+
/// Silently returns `Ok(())` on any user-cancellation (Esc, Ctrl-C on a prompt).
99+
async fn prompt_cloud_registration(dir: &std::path::Path) -> Result<()> {
100+
let cfg = hm_config::Config::load(None).unwrap_or_default();
101+
let api_url = &cfg.cloud.api_url;
102+
let is_logged_in = hm_config::creds::cloud_token(api_url).is_some();
103+
104+
if !is_logged_in {
105+
let want_login = dialoguer::Confirm::new()
106+
.with_prompt("You are not logged in to Harmont Cloud. Log in now?")
107+
.default(false)
108+
.interact()
109+
.unwrap_or(false);
110+
111+
if !want_login {
112+
return Ok(());
113+
}
114+
115+
hm_plugin_cloud::login_interactive().await?;
116+
}
117+
118+
let (client, _ctx) = hm_plugin_cloud::settings::client()
119+
.context("could not build authenticated cloud client")?;
120+
121+
let orgs = client
122+
.raw()
123+
.list_organizations(None, None)
124+
.await
125+
.map_err(hm_plugin_cloud::settings::map_raw)
126+
.context("fetching organizations")?
127+
.into_inner();
128+
129+
if orgs.data.is_empty() {
130+
tracing::warn!("no organizations found — create one at https://app.harmont.dev");
131+
return Ok(());
132+
}
133+
134+
let mut items: Vec<String> = vec!["No, skip".to_string()];
135+
items.extend(orgs.data.iter().map(|o| format!("{} ({})", o.name, o.slug)));
136+
137+
let selection = dialoguer::Select::new()
138+
.with_prompt("Link this repo to Harmont Cloud?")
139+
.items(&items)
140+
.default(0)
141+
.interact()
142+
.unwrap_or(0);
143+
144+
if selection == 0 {
145+
return Ok(());
146+
}
147+
148+
let chosen = &orgs.data[selection - 1];
149+
write_cloud_project_config(dir, &chosen.slug)?;
150+
tracing::info!(
151+
"linked to {} ({}) — `hm run` will now use Harmont Cloud by default",
152+
chosen.name,
153+
chosen.slug,
154+
);
155+
Ok(())
156+
}
157+
158+
fn write_cloud_project_config(dir: &std::path::Path, org_slug: &str) -> Result<()> {
159+
let config_path = dir.join(".hm/config.toml");
160+
let content = format!(
161+
"backend = \"cloud\"\n\
162+
\n\
163+
[cloud]\n\
164+
org = \"{org_slug}\"\n"
165+
);
166+
std::fs::write(&config_path, &content)
167+
.with_context(|| format!("writing {}", config_path.display()))?;
168+
Ok(())
169+
}
170+
91171
fn write_template(dir: &Path, tmpl: &Template, force: bool) -> Result<bool> {
92172
let harmont_dir = dir.join(".hm");
93173
let already_has_pipeline = detect::has_pipeline_files(dir);
@@ -164,7 +244,6 @@ fn has_github_workflows(dir: &Path) -> bool {
164244
///
165245
/// Returns an error if the target directory is unwritable, or if no template
166246
/// can be determined in a non-interactive context.
167-
#[allow(clippy::unused_async)]
168247
pub async fn handle(args: InitArgs) -> Result<()> {
169248
let tty = std::io::stdin().is_terminal();
170249
let has_pipeline = detect::has_pipeline_files(&args.dir);
@@ -202,6 +281,10 @@ pub async fn handle(args: InitArgs) -> Result<()> {
202281
}
203282
}
204283

284+
if tty && let Err(e) = prompt_cloud_registration(&args.dir).await {
285+
tracing::warn!("cloud registration skipped: {e:#}");
286+
}
287+
205288
if has_github_workflows(&args.dir) {
206289
tracing::info!(
207290
"detected GitHub Actions workflows in .github/workflows/\n \
@@ -215,6 +298,17 @@ pub async fn handle(args: InitArgs) -> Result<()> {
215298
write_skills(&args.dir)?;
216299
}
217300

218-
tracing::info!("next step: run `hm run` to execute your pipeline locally");
301+
let project_config = hm_config::Config::project_config_path(&args.dir);
302+
if project_config.exists() {
303+
let cfg =
304+
hm_config::Config::load_from_paths(None, Some(&project_config)).unwrap_or_default();
305+
if cfg.backend == "cloud" {
306+
tracing::info!("next step: run `hm run` to execute your pipeline on Harmont Cloud");
307+
} else {
308+
tracing::info!("next step: run `hm run` to execute your pipeline locally");
309+
}
310+
} else {
311+
tracing::info!("next step: run `hm run` to execute your pipeline locally");
312+
}
219313
Ok(())
220314
}

crates/hm/tests/cmd_init.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,37 @@ fn init_without_template_in_non_tty_errors_clearly() {
460460
"no pipeline should be written when none could be chosen"
461461
);
462462
}
463+
464+
// ── cloud registration ──────────────────────────────────────
465+
466+
#[test]
467+
fn init_noninteractive_skips_cloud_registration() {
468+
let dir = tempfile::tempdir().unwrap();
469+
hm().args(["init", "--template", "rust", "--dir"])
470+
.arg(dir.path())
471+
.assert()
472+
.success();
473+
474+
let config = dir.path().join(".hm/config.toml");
475+
assert!(
476+
!config.exists(),
477+
"non-interactive init should not create .hm/config.toml"
478+
);
479+
}
480+
481+
#[test]
482+
fn cloud_project_config_layers_correctly() {
483+
let dir = tempfile::tempdir().unwrap();
484+
let hm_dir = dir.path().join(".hm");
485+
std::fs::create_dir(&hm_dir).unwrap();
486+
487+
let config_path = hm_dir.join("config.toml");
488+
let content = "backend = \"cloud\"\n\n[cloud]\norg = \"test-org\"\n";
489+
std::fs::write(&config_path, content).unwrap();
490+
491+
let cfg = hm_config::Config::load_from_paths(None, Some(&config_path)).unwrap();
492+
assert_eq!(cfg.backend, "cloud");
493+
assert_eq!(cfg.cloud.org.as_deref(), Some("test-org"));
494+
// Unrelated defaults survive layering.
495+
assert_eq!(cfg.preferences.format, "human");
496+
}

0 commit comments

Comments
 (0)