use std::collections::BTreeSet;
use std::path::PathBuf;
use clap::Args;
use secrecy::SecretString;
use crate::api::client::ApiClient;
use crate::api::editor;
use crate::api::probe;
use crate::auth::binding_secrets::{
default_file_dir, BindingSecretStore, FileBindingSecretStore, KeyringBindingSecretStore,
};
use crate::cli::commands::shared::make_client;
use crate::cli::GlobalArgs;
use crate::config;
use crate::error::{
OlError, OL_4210_SCHEMA_MISMATCH, OL_4244_SYNTHETIC_PROBE_FAILED,
OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG, OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG,
OL_4283_PLATFORM_DUPLICATE_BINDING, OL_4284_PREFLIGHT_VALIDATION_FAILED,
};
use crate::manifest::{self, Manifest};
use crate::ui::output::OutputConfig;
#[derive(Args, Debug)]
pub struct RegisterArgs {
#[arg(long, alias = "provider", value_name = "PATH")]
pub manifest: Option<String>,
#[arg(long)]
pub allow_local_endpoints: bool,
#[arg(long)]
pub skip_preflight: bool,
}
pub async fn run(g: &GlobalArgs, args: RegisterArgs) -> Result<(), OlError> {
let out = OutputConfig::resolve(g);
let manifest_path: PathBuf = match args.manifest {
Some(p) => PathBuf::from(p),
None => config::active_manifest_path(g.profile.as_deref())?,
};
out.print_step(&format!(
"Loading manifest from {}",
manifest_path.display()
));
let m = manifest::load(&manifest_path)?;
let probe_opts = probe::ProbeOpts {
allow_local: args.allow_local_endpoints,
};
if args.allow_local_endpoints {
out.print_info(
"⚠ --allow-local-endpoints enabled: skipping public-IP / cloud-metadata \
checks. Never use this flag for production registrations.",
);
}
let mut failed_probes: BTreeSet<String> = BTreeSet::new();
if args.skip_preflight {
out.print_info(
"⚠ --skip-preflight set: bypassing client-side endpoint probe. The platform's \
server-side probe still validates SSRF posture at upsert time.",
);
} else if !m.providers.is_empty() {
out.print_step(&format!(
"Probing {} provider endpoint(s) (client-side)",
m.providers.len()
));
for p in &m.providers {
match probe::probe_with_opts(&p.endpoint_url, probe_opts) {
Ok(report) => {
out.print_substep(&format!(
"✓ {} ({})",
p.endpoint_url,
report.resolved_ips.join(",")
));
}
Err(e) => {
failed_probes.insert(p.endpoint_url.clone());
out.print_substep(&format!(
"✗ {} — [{}] {}",
p.endpoint_url, e.code.code, e.message
));
}
}
}
if failed_probes.len() == m.providers.len() {
return Err(OlError::new(
OL_4244_SYNTHETIC_PROBE_FAILED,
format!(
"all {} provider probe(s) failed; aborting before contacting the platform",
m.providers.len()
),
));
}
if !failed_probes.is_empty() && !g.yes {
return Err(OlError::new(
OL_4244_SYNTHETIC_PROBE_FAILED,
format!(
"{}/{} provider probe(s) failed; re-run with --yes to proceed with the \
remaining providers (exit code 5)",
failed_probes.len(),
m.providers.len()
),
));
}
}
let probe_failures = failed_probes.len();
let client = make_client().await?;
let (live_tools, live_providers, live_bindings) = tokio::try_join!(
editor::list_tools(&client),
editor::list_providers(&client),
editor::list_bindings(&client),
)
.unwrap_or_default();
let live_tool_slugs: BTreeSet<String> = live_tools.iter().map(|t| t.slug.clone()).collect();
let live_provider_slugs: BTreeSet<String> =
live_providers.iter().map(|p| p.slug.clone()).collect();
let live_binding_keys: BTreeSet<(String, String)> = live_bindings
.iter()
.map(|b| (b.tool.clone(), b.provider.clone()))
.collect();
if !args.skip_preflight {
out.print_step("Pre-flight: validating manifest against platform");
preflight_validate(
&client,
&m,
&live_tool_slugs,
&live_provider_slugs,
&live_binding_keys,
)
.await?;
out.print_substep("✓ pre-flight passed");
} else {
out.print_info(
"⚠ --skip-preflight set: bypassing platform :validate. Slug conflicts will surface mid-upsert.",
);
}
let mut plan: Vec<String> = Vec::new();
plan.push(format!(
"PATCH /api/v1/editor/profile (editor `{}`)",
*m.editor.slug
));
for t in &m.tools {
let kind = if live_tool_slugs.contains(&*t.slug) {
"update"
} else {
"create"
};
plan.push(format!(
"POST /api/v1/editor/tools (tool `{}` v{}, {kind})",
*t.slug, *t.version
));
}
for p in &m.providers {
let kind = if live_provider_slugs.contains(&*p.slug) {
"update"
} else {
"create"
};
plan.push(format!(
"POST /api/v1/editor/providers (provider `{}`, {kind})",
*p.slug
));
}
for b in &m.bindings {
let kind = if live_binding_keys.contains(&(b.tool.clone(), b.provider.clone())) {
"update"
} else {
"create"
};
plan.push(format!(
"POST /api/v1/editor/bindings (binding {}/{}, {kind})",
b.tool, b.provider
));
}
if g.dry_run {
out.print_step("Dry run — would execute:");
for line in &plan {
out.print_substep(line);
}
return Ok(());
}
out.print_step("Updating editor profile");
let editor_patch = editor::EditorProfilePatch {
display_name: Some(m.editor.display_name.to_string()),
description: m.editor.description.as_ref().map(|d| d.to_string()),
homepage_url: m.editor.homepage_url.clone(),
docs_url: m.editor.docs_url.clone(),
};
editor::update_profile(&client, &editor_patch).await?;
if !m.tools.is_empty() {
out.print_step(&format!("Upserting {} tool(s)", m.tools.len()));
for t in &m.tools {
let body = serde_json::to_value(t).map_err(|e| {
OlError::new(
OL_4210_SCHEMA_MISMATCH,
format!("serialise tool `{}`: {e}", *t.slug),
)
})?;
editor::upsert_tool(&client, &body).await?;
out.print_substep(&format!("✓ tool `{}`", *t.slug));
}
}
let mut binding_count = 0u32;
if !m.providers.is_empty() {
out.print_step(&format!("Upserting {} provider(s)", m.providers.len()));
for p in &m.providers {
let body = serde_json::to_value(p).map_err(|e| {
OlError::new(
OL_4210_SCHEMA_MISMATCH,
format!("serialise provider `{}`: {e}", *p.slug),
)
})?;
editor::upsert_provider(&client, &body).await?;
out.print_substep(&format!("✓ provider `{}`", *p.slug));
}
}
if !m.bindings.is_empty() {
let provider_endpoint_by_slug: std::collections::BTreeMap<String, String> = m
.providers
.iter()
.map(|p| (p.slug.to_string(), p.endpoint_url.clone()))
.collect();
out.print_step(&format!(
"Upserting {} binding(s) (probes already passed)",
m.bindings.len()
));
for b in &m.bindings {
let provider_url = provider_endpoint_by_slug
.get(&*b.provider)
.cloned()
.unwrap_or_default();
if !provider_url.is_empty() && failed_probes.contains(&provider_url) {
out.print_substep(&format!(
"skipping {}/{} (provider probe failed earlier)",
b.tool, b.provider
));
continue;
}
let mut body = serde_json::to_value(b).map_err(|e| {
OlError::new(
OL_4210_SCHEMA_MISMATCH,
format!("serialise binding {}/{}: {e}", b.tool, b.provider),
)
})?;
manifest::strip_local_only_binding_fields(&mut body);
let resp = editor::upsert_binding(&client, &body).await?;
binding_count += 1;
out.print_substep(&format!(
"✓ binding {} ({}/{}) state={}",
resp.id,
b.tool,
b.provider,
resp.state.as_deref().unwrap_or("active")
));
if let Some(secret) = resp.secret {
println!();
out.print_substep(&format!(
"🔑 {} ← STORE THIS — will not be shown again",
secret
));
if let Err(e) = persist_secret(&resp.id, &secret) {
tracing::warn!(
binding_id = %resp.id,
error = %e,
"could not persist binding secret locally"
);
}
}
}
}
let region = m
.providers
.first()
.map(|p| p.region.to_string())
.unwrap_or_default();
crate::telemetry::capture_global(crate::telemetry::Event::provider_registered(
®ion,
binding_count,
false,
));
let exit_code = if probe_failures > 0 {
out.print_info(&format!(
"{} provider probe(s) skipped — exit code 5 (partial success)",
probe_failures
));
Err(OlError::new(
OL_4244_SYNTHETIC_PROBE_FAILED,
"partial success — some bindings were skipped because their provider's probe failed",
))
} else {
out.print_step("Registration complete.");
Ok(())
};
exit_code
}
async fn preflight_validate(
client: &ApiClient,
m: &Manifest,
owned_tools: &BTreeSet<String>,
owned_providers: &BTreeSet<String>,
owned_bindings: &BTreeSet<(String, String)>,
) -> Result<(), OlError> {
let mut conflicts: Vec<serde_json::Value> = Vec::new();
for t in &m.tools {
let slug = (*t.slug).to_string();
if owned_tools.contains(&slug) {
continue;
}
let body = serde_json::to_value(t).map_err(|e| {
OlError::new(
OL_4210_SCHEMA_MISMATCH,
format!("serialise tool `{slug}`: {e}"),
)
})?;
if let Err(e) = editor::validate_tool(client, &body).await {
if e.is(OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG) {
conflicts.push(serde_json::json!({
"kind": "tool",
"slug": slug,
"message": e.message,
}));
} else {
return Err(e);
}
}
}
for p in &m.providers {
let slug = (*p.slug).to_string();
if owned_providers.contains(&slug) {
continue;
}
let body = serde_json::to_value(p).map_err(|e| {
OlError::new(
OL_4210_SCHEMA_MISMATCH,
format!("serialise provider `{slug}`: {e}"),
)
})?;
if let Err(e) = editor::validate_provider(client, &body).await {
if e.is(OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG) {
conflicts.push(serde_json::json!({
"kind": "provider",
"slug": slug,
"message": e.message,
}));
} else {
return Err(e);
}
}
}
for b in &m.bindings {
let key = (b.tool.clone(), b.provider.clone());
if owned_bindings.contains(&key) {
continue;
}
if let Err(e) = editor::validate_binding(client, &b.tool, &b.provider).await {
if e.is(OL_4283_PLATFORM_DUPLICATE_BINDING) {
conflicts.push(serde_json::json!({
"kind": "binding",
"tool": b.tool,
"provider": b.provider,
"message": e.message,
}));
} else {
return Err(e);
}
}
}
if conflicts.is_empty() {
return Ok(());
}
let summary = conflicts
.iter()
.map(|c| {
let kind = c.get("kind").and_then(|v| v.as_str()).unwrap_or("?");
if let Some(slug) = c.get("slug").and_then(|v| v.as_str()) {
format!("{kind} `{slug}`")
} else {
let tool = c.get("tool").and_then(|v| v.as_str()).unwrap_or("?");
let prov = c.get("provider").and_then(|v| v.as_str()).unwrap_or("?");
format!("{kind} {tool}/{prov}")
}
})
.collect::<Vec<_>>()
.join(", ");
Err(OlError::new(
OL_4284_PREFLIGHT_VALIDATION_FAILED,
format!(
"pre-flight validation found {} conflict(s): {}",
conflicts.len(),
summary
),
)
.with_context(serde_json::json!({ "conflicts": conflicts }))
.with_suggestion(
"Pick different slugs in the manifest, or pass --skip-preflight to retry an in-progress register.",
))
}
fn persist_secret(binding_id: &str, secret: &str) -> Result<(), OlError> {
let secret = SecretString::from(secret.to_string());
let keyring = KeyringBindingSecretStore::new();
if keyring.store(binding_id, secret.clone()).is_ok() {
return Ok(());
}
let dir = default_file_dir(&config::provider_dir());
let machine_id = config::machine_id_or_init().unwrap_or_else(|_| "unknown".into());
FileBindingSecretStore::new(dir, machine_id).store(binding_id, secret)
}