use crate::commands::linear::{fetch_available_initiatives, LinearInitiativeSummary};
use crate::commands::ssh_helpers::prompt_for_password;
use crate::config::{
global_xbp_paths, resolve_linear_api_key, LinearConfig, LinearReleaseConfig, SecretProvider,
SshConfig,
};
use crate::logging::{get_prefix, log_info};
use crate::strategies::project_detector::DeploymentRecommendations;
use crate::strategies::{ProjectDetector, ProjectType, XbpConfig};
use crate::utils::{
collapse_home_to_env, default_project_yaml_config_path, find_xbp_config_upwards,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal, FoundXbpConfig,
};
use crate::utils::{open_path_with_editor, open_with_default_handler};
use dialoguer::{theme::ColorfulTheme, FuzzySelect};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, error, info};
const GITHUB_CLASSIC_PAT_URL: &str = "https://github.com/settings/tokens";
pub async fn run_config(debug: bool) -> Result<(), String> {
let current_dir: PathBuf =
env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let found = find_xbp_config_upwards(¤t_dir);
let project_root = found
.as_ref()
.map(|f| f.project_root.clone())
.unwrap_or_else(|| current_dir.clone());
let xbp_yaml_path_dotfolder: PathBuf = project_root.join(".xbp/xbp.yaml");
let xbp_yml_path_dotfolder: PathBuf = project_root.join(".xbp/xbp.yml");
let xbp_json_path_dotfolder: PathBuf = project_root.join(".xbp/xbp.json");
let xbp_yaml_path_root: PathBuf = project_root.join("xbp.yaml");
let xbp_yml_path_root: PathBuf = project_root.join("xbp.yml");
let xbp_json_path_root: PathBuf = project_root.join("xbp.json");
if debug {
debug!("Current dir: {}", current_dir.display());
debug!("Project root: {}", project_root.display());
debug!("Checking for: {}", xbp_yaml_path_dotfolder.display());
debug!("Checking for: {}", xbp_yml_path_dotfolder.display());
debug!("Checking for: {}", xbp_json_path_dotfolder.display());
debug!("Checking for: {}", xbp_yaml_path_root.display());
debug!("Checking for: {}", xbp_yml_path_root.display());
debug!("Checking for: {}", xbp_json_path_root.display());
}
let (mut found_path, mut found_location, mut kind): (Option<PathBuf>, Option<String>, &str) =
if let Some(f) = &found {
(
Some(f.config_path.clone()),
Some(f.location.clone()),
f.kind,
)
} else if xbp_yaml_path_dotfolder.exists() {
(
Some(xbp_yaml_path_dotfolder.clone()),
Some(".xbp/xbp.yaml".to_string()),
"yaml",
)
} else if xbp_yml_path_dotfolder.exists() {
(
Some(xbp_yml_path_dotfolder.clone()),
Some(".xbp/xbp.yml".to_string()),
"yaml",
)
} else if xbp_json_path_dotfolder.exists() {
(
Some(xbp_json_path_dotfolder.clone()),
Some(".xbp/xbp.json".to_string()),
"json",
)
} else if xbp_yaml_path_root.exists() {
(
Some(xbp_yaml_path_root.clone()),
Some("xbp.yaml".to_string()),
"yaml",
)
} else if xbp_yml_path_root.exists() {
(
Some(xbp_yml_path_root.clone()),
Some("xbp.yml".to_string()),
"yaml",
)
} else if xbp_json_path_root.exists() {
(
Some(xbp_json_path_root.clone()),
Some("xbp.json".to_string()),
"json",
)
} else {
(None, None, "unknown")
};
if kind == "json" {
if let Some(path) = &found_path {
if let Ok(Some(yaml_path)) =
maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, path)
{
let yaml_location = yaml_path
.strip_prefix(&project_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| yaml_path.to_string_lossy().replace('\\', "/"));
found_path = Some(yaml_path);
found_location = Some(yaml_location);
kind = "yaml";
}
}
}
if found_path.is_none() {
let detected: Option<ProjectType> = ProjectDetector::detect_project_type(¤t_dir)
.await
.ok();
let recommendations: Option<DeploymentRecommendations> = detected
.as_ref()
.map(ProjectDetector::get_deployment_recommendations);
let inferred_name: String = current_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("app")
.to_string();
let default_port: u16 = recommendations
.as_ref()
.map(|r| r.default_port)
.unwrap_or(8080);
let target = detected.as_ref().map(|t| match t {
ProjectType::NextJs { .. } => "nextjs",
ProjectType::NodeJs { .. } => "expressjs",
ProjectType::Rust { .. } => "rust",
ProjectType::Python { .. } => "python",
ProjectType::DockerCompose { .. } => "docker-compose",
ProjectType::Docker { .. } => "docker",
ProjectType::Railway { .. } => "railway",
ProjectType::OpenApi { .. } => "openapi",
ProjectType::Terraform { .. } => "terraform",
ProjectType::Unknown => "unknown",
});
let baseline: XbpConfig = XbpConfig {
project_name: recommendations
.as_ref()
.and_then(|r| r.process_name.clone())
.unwrap_or(inferred_name),
version: "0.1.0".to_string(),
port: default_port,
build_dir: collapse_home_to_env(current_dir.to_string_lossy().as_ref()),
app_type: target.map(|s| s.to_string()),
build_command: recommendations
.as_ref()
.and_then(|r| r.build_command.clone()),
start_command: recommendations
.as_ref()
.and_then(|r| r.start_command.clone()),
install_command: recommendations
.as_ref()
.and_then(|r| r.install_command.clone()),
environment: None,
services: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
target: target.map(|s| s.to_string()),
branch: Some("main".to_string()),
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
};
if let Err(e) = fs::create_dir_all(current_dir.join(".xbp")) {
let msg: String = format!("Failed to create .xbp directory: {}", e);
error!("{}", msg);
eprintln!("{}", msg);
} else {
let out_yaml = current_dir.join(".xbp/xbp.yaml");
if let Ok(yaml) = serde_yaml::to_string(&baseline) {
if fs::write(&out_yaml, yaml).is_ok() {
found_path = Some(out_yaml);
found_location = Some(".xbp/xbp.yaml".to_string());
kind = "yaml";
println!("Generated .xbp/xbp.yaml from detected manifests.");
}
}
}
}
if let (Some(path), Some(location)) = (found_path, found_location) {
let _ = log_info(
"config",
&format!("Found config at: {}", path.display()),
None,
)
.await;
println!("Found config at: {}", path.display());
match fs::read_to_string(&path) {
Ok(contents) => {
if debug {
debug!("config contents: {}", contents);
}
let data = if kind == "yaml" {
serde_yaml::from_str::<serde_yaml::Value>(&contents)
.ok()
.and_then(|v| serde_json::to_value(v).ok())
} else {
serde_json::from_str::<serde_json::Value>(&contents).ok()
};
if let Some(json_data) = data {
let prefix = get_prefix();
println!("\nConfiguration:");
println!("{}", "─".repeat(50));
for (key, value) in json_data.as_object().unwrap() {
let value_str: String = value.to_string().replace("\"", "");
println!("{:<15} | {}", key, value_str);
info!("{}{:<15} | {}", prefix, key, value_str);
}
println!("{}", "─".repeat(50));
} else {
let msg = format!("Failed to parse {} contents.", location);
error!("{}", msg);
eprintln!("{}", msg);
}
}
Err(e) => {
let msg = format!("Failed to read {}: {}", location, e);
error!("{}", msg);
eprintln!("{}", msg);
}
}
} else {
let msg =
"No .xbp/xbp.yaml|.yml|.json or xbp.yaml|.yml|.json found in the current directory.";
error!("{}", msg);
eprintln!("{}", msg);
}
Ok(())
}
pub async fn open_global_config(no_open: bool) -> Result<(), String> {
let paths = global_xbp_paths()?;
println!("Global XBP directory: {}", paths.root_dir.display());
println!("Config file: {}", paths.config_file.display());
println!("SSH directory: {}", paths.ssh_dir.display());
println!("Cache directory: {}", paths.cache_dir.display());
println!("Logs directory: {}", paths.logs_dir.display());
if no_open {
return Ok(());
}
let _ = open_with_default_handler(&paths.root_dir.display().to_string());
let _ = open_path_with_editor(&paths.config_file);
Ok(())
}
pub async fn run_config_secret_set(provider_key: &str, key: Option<String>) -> Result<(), String> {
let provider = SecretProvider::from_key(provider_key).ok_or_else(|| {
format!(
"Unsupported provider `{}`. Use `openrouter`, `github`, or `linear`.",
provider_key
)
})?;
let mut config = SshConfig::load()?;
let resolved_key = match key {
Some(value) => value.trim().to_string(),
None => prompt_for_password(&format!("Enter {} key/token: ", provider.as_key()))?,
};
if resolved_key.trim().is_empty() {
if provider == SecretProvider::Github {
let _ = open_with_default_handler(GITHUB_CLASSIC_PAT_URL);
println!(
"No GitHub key/token provided.\nOpened: {}\nCreate a Personal Access Token (classic), copy it, then run `xbp config github set-key` again.",
GITHUB_CLASSIC_PAT_URL
);
return Ok(());
}
return Err("Refusing to store an empty key/token.".to_string());
}
config.set_secret(provider, Some(resolved_key.clone()));
config.save()?;
println!(
"Saved {} in global config field `{}`: {}",
provider.as_key(),
provider.config_field(),
mask_secret(&resolved_key)
);
Ok(())
}
pub async fn run_config_secret_delete(provider_key: &str) -> Result<(), String> {
let provider = SecretProvider::from_key(provider_key).ok_or_else(|| {
format!(
"Unsupported provider `{}`. Use `openrouter`, `github`, or `linear`.",
provider_key
)
})?;
let mut config = SshConfig::load()?;
if config.get_secret(provider).is_none() {
println!("No {} key/token is currently set.", provider.as_key());
return Ok(());
}
config.set_secret(provider, None);
config.save()?;
println!(
"Deleted {} key/token from global config.",
provider.as_key()
);
Ok(())
}
pub async fn run_config_secret_show(provider_key: &str, raw: bool) -> Result<(), String> {
let provider = SecretProvider::from_key(provider_key).ok_or_else(|| {
format!(
"Unsupported provider `{}`. Use `openrouter`, `github`, or `linear`.",
provider_key
)
})?;
let config = SshConfig::load()?;
if let Some(secret) = config.get_secret(provider) {
let display = if raw {
secret.to_string()
} else {
mask_secret(secret)
};
println!(
"{} key/token is set in `{}`: {}",
provider.as_key(),
provider.config_field(),
display
);
} else {
println!(
"{} key/token is not configured in `{}`.",
provider.as_key(),
provider.config_field()
);
}
Ok(())
}
pub async fn run_config_linear_select_initiative() -> Result<(), String> {
let current_dir =
env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
"No XBP project config found in the current directory tree. Run this inside a repo with `.xbp/xbp.yaml` or `xbp.yaml`.".to_string()
})?;
let linear_api_key = resolve_linear_api_key().ok_or_else(|| {
"No Linear API key found. Configure one first with `xbp config linear set-key`."
.to_string()
})?;
let initiatives = fetch_available_initiatives(&linear_api_key).await?;
if initiatives.is_empty() {
println!("No accessible Linear initiatives were found for the configured API key.");
return Ok(());
}
let labels = initiatives
.iter()
.map(format_linear_initiative_label)
.collect::<Vec<_>>();
let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select a Linear initiative for this repo")
.default(0)
.items(&labels)
.interact_opt()
.map_err(|e| format!("Failed to run initiative picker: {}", e))?;
let Some(selection) = selection else {
println!("No initiative selected. Repo config was not changed.");
return Ok(());
};
let selected = initiatives
.get(selection)
.ok_or_else(|| "Selected initiative index was out of range.".to_string())?;
let saved_path = save_repo_linear_initiative(&found, selected)?;
println!(
"Saved Linear initiative `{}` ({}) to {}",
selected.name,
selected.id,
saved_path.display()
);
Ok(())
}
fn mask_secret(secret: &str) -> String {
let chars: Vec<char> = secret.chars().collect();
if chars.is_empty() {
return "(empty)".to_string();
}
if chars.len() <= 8 {
return "*".repeat(chars.len());
}
let prefix: String = chars.iter().take(4).collect();
let suffix: String = chars.iter().skip(chars.len() - 4).collect();
format!("{}...{}", prefix, suffix)
}
fn format_linear_initiative_label(initiative: &LinearInitiativeSummary) -> String {
let mut parts = vec![initiative.name.trim().to_string()];
if let Some(status) = non_empty_text(initiative.status.as_deref()) {
parts.push(format!("[{}]", status));
}
if let Some(owner) = non_empty_text(initiative.owner_name.as_deref()) {
parts.push(owner.to_string());
}
if let Some(target_date) = non_empty_text(initiative.target_date.as_deref()) {
parts.push(target_date.to_string());
}
parts.join(" ")
}
fn save_repo_linear_initiative(
found: &FoundXbpConfig,
initiative: &LinearInitiativeSummary,
) -> Result<PathBuf, String> {
let (mut config, output_path) = load_project_config_for_repo_linear_update(found)?;
set_repo_linear_release_initiative(&mut config, initiative.id.clone());
write_project_yaml_config(&output_path, &config)?;
Ok(output_path)
}
fn load_project_config_for_repo_linear_update(
found: &FoundXbpConfig,
) -> Result<(XbpConfig, PathBuf), String> {
let source_path = if found.kind == "json" {
maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)?
.unwrap_or_else(|| default_project_yaml_config_path(&found.project_root))
} else {
found.config_path.clone()
};
let kind = if source_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
.unwrap_or(false)
{
"yaml"
} else {
"json"
};
let content = fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read config {}: {}", source_path.display(), e))?;
let (config, _healed_content) = parse_config_with_auto_heal::<XbpConfig>(&content, kind)
.map_err(|e| format!("Failed to parse project config: {}", e))?;
Ok((config, default_project_yaml_config_path(&found.project_root)))
}
fn set_repo_linear_release_initiative(config: &mut XbpConfig, initiative_id: String) {
let linear = config.linear.get_or_insert_with(|| LinearConfig { release: None });
let release = linear.release.get_or_insert_with(|| LinearReleaseConfig {
enabled: None,
initiative_ids: None,
health: None,
});
release.initiative_ids = Some(vec![initiative_id]);
}
fn write_project_yaml_config(path: &Path, config: &XbpConfig) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory {}: {}", parent.display(), e))?;
}
let content = serde_yaml::to_string(config)
.map_err(|e| format!("Failed to serialize project config: {}", e))?;
fs::write(path, content)
.map_err(|e| format!("Failed to write project config {}: {}", path.display(), e))
}
fn non_empty_text(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::{
format_linear_initiative_label, load_project_config_for_repo_linear_update,
save_repo_linear_initiative, set_repo_linear_release_initiative,
};
use crate::commands::linear::LinearInitiativeSummary;
use crate::config::{LinearConfig, LinearReleaseConfig};
use crate::strategies::XbpConfig;
use crate::utils::FoundXbpConfig;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn creates_linear_release_block_when_missing() {
let mut config = sample_xbp_config();
set_repo_linear_release_initiative(&mut config, "initiative-1".to_string());
let release = config
.linear
.as_ref()
.and_then(|linear| linear.release.as_ref())
.expect("release config");
assert_eq!(
release.initiative_ids.as_ref(),
Some(&vec!["initiative-1".to_string()])
);
assert_eq!(release.enabled, None);
assert_eq!(release.health, None);
}
#[test]
fn replaces_repo_linear_initiatives_and_preserves_enabled_and_health() {
let mut config = sample_xbp_config();
config.linear = Some(LinearConfig {
release: Some(LinearReleaseConfig {
enabled: Some(false),
initiative_ids: Some(vec!["old-1".to_string(), "old-2".to_string()]),
health: Some("at_risk".to_string()),
}),
});
set_repo_linear_release_initiative(&mut config, "new-initiative".to_string());
let release = config
.linear
.as_ref()
.and_then(|linear| linear.release.as_ref())
.expect("release config");
assert_eq!(
release.initiative_ids.as_ref(),
Some(&vec!["new-initiative".to_string()])
);
assert_eq!(release.enabled, Some(false));
assert_eq!(release.health.as_deref(), Some("at_risk"));
}
#[test]
fn formats_initiative_labels_when_optional_fields_are_missing() {
let label = format_linear_initiative_label(&LinearInitiativeSummary {
id: "initiative-1".to_string(),
name: "Formations live".to_string(),
status: Some("Active".to_string()),
health: None,
archived_at: None,
target_date: None,
owner_name: None,
});
assert_eq!(label, "Formations live [Active]");
}
#[test]
fn converts_json_only_repo_config_and_writes_selected_initiative_to_yaml() {
let temp_dir = create_temp_dir("linear-select-json");
let dot_xbp = temp_dir.join(".xbp");
fs::create_dir_all(&dot_xbp).expect("create .xbp");
let json_path = dot_xbp.join("xbp.json");
fs::write(
&json_path,
r#"{
"project_name": "XBP",
"version": "10.22.0",
"port": 3398,
"build_dir": "./",
"linear": {
"release": {
"enabled": true,
"health": "off_track",
"initiative_ids": ["legacy-id"]
}
}
}"#,
)
.expect("write json config");
let found = FoundXbpConfig {
project_root: temp_dir.clone(),
config_path: json_path,
kind: "json",
location: ".xbp/xbp.json".to_string(),
};
let selected = LinearInitiativeSummary {
id: "new-id".to_string(),
name: "Changelog".to_string(),
status: Some("Active".to_string()),
health: Some("onTrack".to_string()),
archived_at: None,
target_date: None,
owner_name: Some("floris".to_string()),
};
let saved_path = save_repo_linear_initiative(&found, &selected).expect("save config");
let rendered = fs::read_to_string(&saved_path).expect("read yaml config");
assert_eq!(saved_path, temp_dir.join(".xbp").join("xbp.yaml"));
assert!(rendered.contains("initiative_ids:"));
assert!(rendered.contains("- new-id"));
assert!(rendered.contains("enabled: true"));
assert!(rendered.contains("health: off_track"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn loads_existing_yaml_config_for_repo_linear_update() {
let temp_dir = create_temp_dir("linear-select-yaml");
let dot_xbp = temp_dir.join(".xbp");
fs::create_dir_all(&dot_xbp).expect("create .xbp");
let yaml_path = dot_xbp.join("xbp.yaml");
fs::write(
&yaml_path,
r#"project_name: XBP
version: 10.22.0
port: 3398
build_dir: ./
"#,
)
.expect("write yaml config");
let found = FoundXbpConfig {
project_root: temp_dir.clone(),
config_path: yaml_path.clone(),
kind: "yaml",
location: ".xbp/xbp.yaml".to_string(),
};
let (_, output_path) =
load_project_config_for_repo_linear_update(&found).expect("load config");
assert_eq!(output_path, yaml_path);
let _ = fs::remove_dir_all(temp_dir);
}
fn sample_xbp_config() -> XbpConfig {
XbpConfig {
project_name: "XBP".to_string(),
version: "10.22.0".to_string(),
port: 3398,
build_dir: "./".to_string(),
app_type: None,
build_command: None,
start_command: None,
install_command: None,
environment: None,
services: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
}
}
fn create_temp_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = env::temp_dir().join(format!("xbp-{}-{}", name, unique));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}