use std::collections::HashMap;
use std::path::Path;
use chrono::Utc;
use colored::Colorize;
use crate::cli::commands::VersionDiscoverServicesCmd;
use crate::commands::cli_session::{
post_project_registration, CliProjectRegisterDevicePayload, CliProjectRegisterPayload,
CliProjectRegisterResponse, CliProjectRegisterServicePayload,
};
use crate::commands::project_services::{
discover_marker_service_projects, register_discovered_services, DiscoveredServiceProject,
RegisterServicesOptions, RegisterServicesReport,
};
use crate::config::resolve_device_identity;
use crate::strategies::{ServiceConfig, XbpConfig};
use crate::utils::{
find_xbp_config_upwards, git_remote_url_from_metadata, parse_config_with_auto_heal,
parse_github_repo_from_remote_url,
};
pub async fn run_version_discover_services(
args: &VersionDiscoverServicesCmd,
) -> Result<(), String> {
let current_dir = std::env::current_dir()
.map_err(|error| format!("Failed to read current directory: {}", error))?;
let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
"Currently not in an XBP project. Run `xbp init` to create a project config here."
.to_string()
})?;
let project_root = found.project_root.clone();
let content = std::fs::read_to_string(&found.config_path).map_err(|error| {
format!(
"Failed to read root XBP config {}: {}",
found.config_path.display(),
error
)
})?;
let (mut config, healed_content): (XbpConfig, Option<String>) =
parse_config_with_auto_heal(&content, found.kind).map_err(|error| {
format!(
"Failed to parse root XBP config {}: {}",
found.config_path.display(),
error
)
})?;
if let Some(healed_content) = healed_content {
let _ = std::fs::write(&found.config_path, healed_content);
}
let discovered = discover_marker_service_projects(&project_root).await?;
print_discovered_projects(&project_root, &discovered);
let options = RegisterServicesOptions {
dry_run: args.dry_run,
no_register: args.no_register,
};
let report = register_discovered_services(
&project_root,
&found.config_path,
&mut config,
&options,
)
.await?;
print_registration_report(&found.config_path, &report, &options);
if should_sync_to_dashboard(&options, &report) {
sync_project_registration_to_dashboard(
&project_root,
&config,
found.kind,
&discovered,
)
.await;
}
Ok(())
}
fn should_sync_to_dashboard(options: &RegisterServicesOptions, report: &RegisterServicesReport) -> bool {
!options.dry_run && !options.no_register && report.discovered > 0
}
async fn sync_project_registration_to_dashboard(
project_root: &Path,
config: &XbpConfig,
config_kind: &str,
discovered: &[DiscoveredServiceProject],
) {
let payload = match build_project_register_payload(project_root, config, config_kind, discovered)
{
Ok(payload) => payload,
Err(error) => {
println!(
" {} Could not prepare dashboard registration payload: {}",
"!".bright_yellow(),
error
);
return;
}
};
match post_project_registration(&payload).await {
Ok(response) => print_dashboard_registration_success(&response),
Err(error) if error.contains("xbp login") => {
println!();
println!("Dashboard registration");
println!(
" {} Skipped server registration. {}",
"i".bright_blue(),
error
);
}
Err(error) => {
println!();
println!("Dashboard registration");
println!(
" {} Failed to register project on xbp.app: {}",
"!".bright_yellow(),
error
);
}
}
}
fn build_project_register_payload(
project_root: &Path,
config: &XbpConfig,
config_kind: &str,
discovered: &[DiscoveredServiceProject],
) -> Result<CliProjectRegisterPayload, String> {
let device = resolve_device_identity()?;
let marker_by_root = discovered
.iter()
.map(|project| (project.root_directory.clone(), project.marker.clone()))
.collect::<HashMap<_, _>>();
let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
let services = config
.services
.as_ref()
.map(|services| {
services
.iter()
.map(|service| service_to_register_payload(service, &marker_by_root))
.collect()
})
.unwrap_or_default();
Ok(CliProjectRegisterPayload {
project_name: config.project_name.clone(),
project_path: canonical_project_path(project_root),
version: config.version.clone(),
build_dir: config.build_dir.clone(),
port: config.port,
app_type: config.app_type.clone(),
branch: config.branch.clone(),
target: config.target.clone(),
config_kind: Some(config_kind.to_string()),
repository_owner,
repository_name,
device: CliProjectRegisterDevicePayload {
hardware_id: device.hardware_id,
device_name: build_device_name(),
hostname: current_hostname(),
platform: Some(std::env::consts::OS.to_string()),
},
services,
registered_at: Utc::now().to_rfc3339(),
})
}
fn service_to_register_payload(
service: &ServiceConfig,
marker_by_root: &HashMap<String, String>,
) -> CliProjectRegisterServicePayload {
let marker = service
.root_directory
.as_deref()
.and_then(|root| marker_by_root.get(root).cloned());
CliProjectRegisterServicePayload {
name: service.name.clone(),
target: service.target.clone(),
branch: service.branch.clone(),
port: service.port,
root_directory: service.root_directory.clone(),
url: service.url.clone(),
healthcheck_path: service.healthcheck_path.clone(),
restart_policy: service.restart_policy.clone(),
start_wrapper: service.start_wrapper.clone(),
systemd_service_name: service.systemd_service_name.clone(),
marker,
commands: service.commands.clone(),
environment: service.environment.clone(),
version_targets: service.version_targets.clone(),
}
}
fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
let Ok(Some(origin_url)) = git_remote_url_from_metadata(project_root, "origin") else {
return (None, None);
};
let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
return (None, None);
};
(Some(owner), Some(repo))
}
fn canonical_project_path(project_root: &Path) -> String {
std::fs::canonicalize(project_root)
.unwrap_or_else(|_| project_root.to_path_buf())
.to_string_lossy()
.to_string()
}
fn current_hostname() -> Option<String> {
for key in ["HOSTNAME", "COMPUTERNAME"] {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn build_device_name() -> Option<String> {
let hostname = current_hostname();
let username = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
match (username, hostname) {
(Some(user), Some(host)) => Some(format!("{user}@{host}")),
(Some(user), None) => Some(user),
(None, Some(host)) => Some(host),
(None, None) => None,
}
}
fn print_discovered_projects(project_root: &Path, discovered: &[DiscoveredServiceProject]) {
println!("Service discovery");
println!("Root: {}", project_root.display());
if discovered.is_empty() {
println!(
" {} No package roots found (looked for Cargo.toml, package.json, pyproject.toml, requirements.txt, and related markers).",
"i".bright_blue()
);
return;
}
println!(
" {} Found {} service candidate(s):",
"✓".bright_green(),
discovered.len()
);
for project in discovered {
println!(
" {} {} (marker: {})",
"•".bright_cyan(),
project.root_directory,
project.marker
);
}
}
fn print_registration_report(
config_path: &Path,
report: &RegisterServicesReport,
options: &RegisterServicesOptions,
) {
if report.discovered == 0 {
return;
}
println!();
println!("Service registration");
println!(
" {} added, {} updated, {} unchanged",
report.added, report.updated, report.unchanged
);
if options.dry_run {
println!(
" {} Dry run enabled; no changes written to {}",
"i".bright_blue(),
config_path.display()
);
return;
}
if options.no_register {
println!(
" {} Registration skipped (`--no-register`); no changes written.",
"i".bright_blue()
);
return;
}
if report.wrote_config {
println!(
" {} Updated {}",
"✓".bright_green(),
config_path.display()
);
}
}
fn print_dashboard_registration_success(response: &CliProjectRegisterResponse) {
if response.project_id.is_empty() {
return;
}
println!();
println!("Dashboard registration");
println!(
" {} Registered on xbp.app ({} services upserted, {} removed)",
"✓".bright_green(),
response.services_upserted,
response.services_removed
);
if response.project_created {
println!(" {} Created new project record", "•".bright_cyan());
}
if let Some(repository_id) = response.repository_id.as_deref() {
println!(
" {} Linked GitHub repository ({})",
"•".bright_cyan(),
repository_id
);
}
}