use crate::codetime::{
collect_system_inventory as collect_codetime_system_inventory, SystemInventory,
SystemInventoryOptions,
};
use crate::dns_inventory_cache::DnsInventoryCache;
use crate::openrouter::{
DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
};
use crate::utils::{
find_xbp_config_upwards, first_dotenv_value_upwards, first_lookup_value, load_env_lookup,
load_global_worktree_ignore_from_path, sync_global_worktree_ignore_file, XbpIgnoreSet,
CLOUDFLARE_ACCOUNT_ID_ENV_KEYS, GLOBAL_WORKTREE_IGNORE_FILENAME,
};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use xbp_core::{RunnerExecutionRuntime, RunnerPlatform};
#[derive(Debug, Clone)]
pub struct GlobalXbpPaths {
pub root_dir: PathBuf,
pub config_file: PathBuf,
pub ssh_dir: PathBuf,
pub cache_dir: PathBuf,
pub logs_dir: PathBuf,
pub versioning_files_file: PathBuf,
pub package_name_files_file: PathBuf,
pub worktree_ignore_file: PathBuf,
}
#[derive(Debug, Clone)]
pub struct SystemInventorySyncResult {
pub inventory: SystemInventory,
pub config_path: PathBuf,
pub refreshed: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct DeviceIdentity {
#[serde(default)]
pub hardware_id: String,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct LinearReleaseConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub initiative_ids: Option<Vec<String>>,
#[serde(default, alias = "org_name")]
pub organization_name: Option<String>,
#[serde(default)]
pub health: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct LinearConfig {
#[serde(default)]
pub release: Option<LinearReleaseConfig>,
#[serde(default)]
pub default_team_id: Option<String>,
#[serde(default)]
pub default_team_key: Option<String>,
#[serde(default, alias = "org_name")]
pub organization_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct DiscordEventsConfig {
#[serde(default)]
pub version_release: Option<bool>,
#[serde(default, alias = "tag", alias = "git_tag")]
pub github_tag: Option<bool>,
#[serde(default, alias = "gh_release", alias = "release_created")]
pub github_release: Option<bool>,
#[serde(default, alias = "crates", alias = "crates-io")]
pub crates_io: Option<bool>,
#[serde(default, alias = "npmjs")]
pub npm: Option<bool>,
#[serde(default, alias = "linear_post")]
pub linear: Option<bool>,
#[serde(default, alias = "dist", alias = "cargo-dist")]
pub cargo_dist: Option<bool>,
#[serde(default, alias = "oci_registry", alias = "docker")]
pub oci: Option<bool>,
#[serde(default, alias = "workers", alias = "worker_deploy")]
pub workers_deploy: Option<bool>,
#[serde(default, alias = "k8s", alias = "kubernetes")]
pub kubernetes_deploy: Option<bool>,
#[serde(default, alias = "deployment")]
pub deploy: Option<bool>,
#[serde(default, alias = "openapi_gen", alias = "openapi_generate")]
pub openapi: Option<bool>,
#[serde(default, alias = "issue", alias = "todos", alias = "todo_issues")]
pub issues: Option<bool>,
#[serde(default, alias = "commits", alias = "git_commit")]
pub commit: Option<bool>,
}
impl DiscordEventsConfig {
pub fn is_enabled(&self, event: DiscordNotifyEvent) -> bool {
match event {
DiscordNotifyEvent::VersionRelease => self.version_release.unwrap_or(true),
DiscordNotifyEvent::GitHubTag => self.github_tag.unwrap_or(false),
DiscordNotifyEvent::GitHubRelease => self.github_release.unwrap_or(false),
DiscordNotifyEvent::CratesIo => self.crates_io.unwrap_or(true),
DiscordNotifyEvent::Npm => self.npm.unwrap_or(true),
DiscordNotifyEvent::Linear => self.linear.unwrap_or(true),
DiscordNotifyEvent::CargoDist => self.cargo_dist.unwrap_or(true),
DiscordNotifyEvent::Oci => self.oci.unwrap_or(true),
DiscordNotifyEvent::WorkersDeploy => self.workers_deploy.unwrap_or(true),
DiscordNotifyEvent::KubernetesDeploy => self.kubernetes_deploy.unwrap_or(true),
DiscordNotifyEvent::Deploy => self.deploy.unwrap_or(true),
DiscordNotifyEvent::OpenApi => self.openapi.unwrap_or(true),
DiscordNotifyEvent::Issues => self.issues.unwrap_or(true),
DiscordNotifyEvent::Commit => self.commit.unwrap_or(true),
}
}
pub fn set_event(&mut self, event: DiscordNotifyEvent, enabled: bool) {
match event {
DiscordNotifyEvent::VersionRelease => self.version_release = Some(enabled),
DiscordNotifyEvent::GitHubTag => self.github_tag = Some(enabled),
DiscordNotifyEvent::GitHubRelease => self.github_release = Some(enabled),
DiscordNotifyEvent::CratesIo => self.crates_io = Some(enabled),
DiscordNotifyEvent::Npm => self.npm = Some(enabled),
DiscordNotifyEvent::Linear => self.linear = Some(enabled),
DiscordNotifyEvent::CargoDist => self.cargo_dist = Some(enabled),
DiscordNotifyEvent::Oci => self.oci = Some(enabled),
DiscordNotifyEvent::WorkersDeploy => self.workers_deploy = Some(enabled),
DiscordNotifyEvent::KubernetesDeploy => self.kubernetes_deploy = Some(enabled),
DiscordNotifyEvent::Deploy => self.deploy = Some(enabled),
DiscordNotifyEvent::OpenApi => self.openapi = Some(enabled),
DiscordNotifyEvent::Issues => self.issues = Some(enabled),
DiscordNotifyEvent::Commit => self.commit = Some(enabled),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiscordNotifyEvent {
VersionRelease,
GitHubTag,
GitHubRelease,
CratesIo,
Npm,
Linear,
CargoDist,
Oci,
WorkersDeploy,
KubernetesDeploy,
Deploy,
OpenApi,
Issues,
Commit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscordEventGroup {
ReleasePublish,
Deploy,
Automation,
}
impl DiscordEventGroup {
pub fn all() -> &'static [DiscordEventGroup] {
&[Self::ReleasePublish, Self::Deploy, Self::Automation]
}
pub fn label(self) -> &'static str {
match self {
Self::ReleasePublish => "Release & publish",
Self::Deploy => "Deploy",
Self::Automation => "Automation",
}
}
}
impl DiscordNotifyEvent {
pub fn as_key(self) -> &'static str {
match self {
Self::VersionRelease => "version_release",
Self::GitHubTag => "github_tag",
Self::GitHubRelease => "github_release",
Self::CratesIo => "crates_io",
Self::Npm => "npm",
Self::Linear => "linear",
Self::CargoDist => "cargo_dist",
Self::Oci => "oci",
Self::WorkersDeploy => "workers_deploy",
Self::KubernetesDeploy => "kubernetes_deploy",
Self::Deploy => "deploy",
Self::OpenApi => "openapi",
Self::Issues => "issues",
Self::Commit => "commit",
}
}
pub fn label(self) -> &'static str {
match self {
Self::VersionRelease => "Release summary (title + notes excerpt)",
Self::GitHubTag => "Git tag created/pushed (intermediate)",
Self::GitHubRelease => "GitHub Release created (intermediate)",
Self::CratesIo => "crates.io publish",
Self::Npm => "npm publish",
Self::Linear => "Linear release post",
Self::CargoDist => "cargo-dist build/status",
Self::Oci => "OCI registry publish",
Self::WorkersDeploy => {
"Cloudflare Workers / Containers deploy (batched; includes wrangler + containers)"
}
Self::KubernetesDeploy => "Kubernetes deploy (batched)",
Self::Deploy => "Other deploy outcomes (mixed providers)",
Self::OpenApi => "OpenAPI generation",
Self::Issues => "Issues created (Linear/GitHub, grouped)",
Self::Commit => "xbp commit / short commit messages",
}
}
pub fn group(self) -> DiscordEventGroup {
match self {
Self::VersionRelease
| Self::GitHubTag
| Self::GitHubRelease
| Self::CratesIo
| Self::Npm
| Self::Linear
| Self::CargoDist => DiscordEventGroup::ReleasePublish,
Self::Oci | Self::WorkersDeploy | Self::KubernetesDeploy | Self::Deploy => {
DiscordEventGroup::Deploy
}
Self::OpenApi | Self::Issues | Self::Commit => DiscordEventGroup::Automation,
}
}
pub fn all() -> &'static [DiscordNotifyEvent] {
&[
Self::VersionRelease,
Self::GitHubTag,
Self::GitHubRelease,
Self::CratesIo,
Self::Npm,
Self::Linear,
Self::CargoDist,
Self::Oci,
Self::WorkersDeploy,
Self::KubernetesDeploy,
Self::Deploy,
Self::OpenApi,
Self::Issues,
Self::Commit,
]
}
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"version_release" | "version" | "release" | "release_summary" => {
Some(Self::VersionRelease)
}
"github_tag" | "tag" | "git_tag" => Some(Self::GitHubTag),
"github_release" | "gh_release" | "release_created" => Some(Self::GitHubRelease),
"crates_io" | "crates" | "crates-io" | "cratesio" => Some(Self::CratesIo),
"npm" | "npmjs" => Some(Self::Npm),
"linear" | "linear_post" => Some(Self::Linear),
"cargo_dist" | "dist" | "cargo-dist" => Some(Self::CargoDist),
"oci" | "oci_registry" | "docker" | "ghcr" => Some(Self::Oci),
"workers_deploy"
| "workers"
| "worker_deploy"
| "worker"
| "cloudflare"
| "cloudflare_deploy"
| "containers"
| "cf_containers" => Some(Self::WorkersDeploy),
"kubernetes_deploy" | "k8s" | "kubernetes" => Some(Self::KubernetesDeploy),
"deploy" | "deployment" => Some(Self::Deploy),
"openapi" | "openapi_gen" | "openapi_generate" => Some(Self::OpenApi),
"issues" | "issue" | "todos" | "todo_issues" => Some(Self::Issues),
"commit" | "commits" | "git_commit" => Some(Self::Commit),
_ => None,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct DiscordConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default, alias = "url", alias = "webhook")]
pub webhook_url: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub avatar_url: Option<String>,
#[serde(default)]
pub events: Option<DiscordEventsConfig>,
}
impl DiscordConfig {
pub fn is_master_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn event_enabled(&self, event: DiscordNotifyEvent) -> bool {
if !self.is_master_enabled() {
return false;
}
self.events
.as_ref()
.map(|events| events.is_enabled(event))
.unwrap_or(true)
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct ServiceDiscordConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub events: Option<DiscordEventsConfig>,
}
impl ServiceDiscordConfig {
pub fn event_enabled(&self, event: DiscordNotifyEvent, project_default: bool) -> bool {
if self.enabled == Some(false) {
return false;
}
self.events
.as_ref()
.map(|events| events.is_enabled(event))
.unwrap_or(project_default)
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct TodosConfig {
#[serde(default)]
pub default_to: Option<String>,
#[serde(default)]
pub auto_yes: Option<bool>,
#[serde(default)]
pub prompt_sync_after_scan: Option<bool>,
#[serde(default)]
pub linear_labels: Option<Vec<String>>,
#[serde(default)]
pub github_labels: Option<Vec<String>>,
#[serde(default)]
pub linear_assignee: Option<String>,
#[serde(default)]
pub linear_project: Option<String>,
#[serde(default)]
pub watch_paths: Option<Vec<String>>,
#[serde(default)]
pub linear_link_wait_secs: Option<u64>,
#[serde(default)]
pub linear_link_poll_ms: Option<u64>,
#[serde(default)]
pub purge_duplicate_linear: Option<bool>,
#[serde(default)]
pub kinds: Option<Vec<String>>,
#[serde(default)]
pub priority_by_kind: Option<BTreeMap<String, i32>>,
#[serde(default)]
pub annotate_source: Option<bool>,
#[serde(default)]
pub openrouter_enrich: Option<bool>,
#[serde(default)]
pub openrouter_enrich_model: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct WorktreeWatchConfig {
#[serde(default, alias = "exclude_paths", alias = "forbidden_path")]
pub forbidden_paths: Vec<String>,
#[serde(default, alias = "exclude_folders", alias = "forbidden_folder")]
pub forbidden_folders: Vec<String>,
#[serde(default, alias = "forbidden_words", alias = "banned_word")]
pub banned_words: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idle_after_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cold_after_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hot_commit_check_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warm_commit_check_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cold_commit_check_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_commit_checks_per_tick: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_rescan_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rank_persist_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_hot_repos: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_fs_watches: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spool_retention_days: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repo_ranks: Vec<WorktreeWatchRepoRank>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
pub struct WorktreeWatchRepoRank {
pub root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub score: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_event_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rank: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tier: Option<String>,
}
impl WorktreeWatchConfig {
pub fn idle_after_seconds(&self) -> u64 {
self.idle_after_seconds.unwrap_or(180).max(30)
}
pub fn cold_after_seconds(&self) -> u64 {
self.cold_after_seconds
.unwrap_or(600)
.max(self.idle_after_seconds())
}
pub fn hot_commit_check_seconds(&self) -> u64 {
self.hot_commit_check_seconds.unwrap_or(12).max(5)
}
pub fn warm_commit_check_seconds(&self) -> u64 {
self.warm_commit_check_seconds.unwrap_or(120).max(30)
}
pub fn cold_commit_check_seconds(&self) -> u64 {
self.cold_commit_check_seconds.unwrap_or(300).max(60)
}
pub fn parent_rescan_seconds(&self) -> u64 {
self.parent_rescan_seconds.unwrap_or(300).max(60)
}
pub fn rank_persist_seconds(&self) -> u64 {
self.rank_persist_seconds.unwrap_or(120).max(30)
}
pub fn max_hot_repos(&self) -> usize {
self.max_hot_repos.unwrap_or(4).max(1)
}
pub fn max_fs_watches(&self) -> usize {
self.max_fs_watches
.unwrap_or_else(|| self.max_hot_repos().saturating_add(2).max(6))
.max(1)
}
pub fn max_commit_checks_per_tick(&self) -> usize {
self.max_commit_checks_per_tick.unwrap_or(6).max(1)
}
pub fn spool_retention_days(&self) -> u64 {
self.spool_retention_days.unwrap_or(7).max(1)
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct SecretMetadata {
#[serde(default)]
pub added_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct CliAuthState {
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub user_email: Option<String>,
#[serde(default)]
pub token_label: Option<String>,
#[serde(default)]
pub token_prefix: Option<String>,
#[serde(default)]
pub token_created_at: Option<DateTime<Utc>>,
#[serde(default)]
pub token_expires_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_verified_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct CursorIngestState {
#[serde(default)]
pub last_status: Option<String>,
#[serde(default)]
pub last_trigger: Option<String>,
#[serde(default)]
pub last_mode: Option<String>,
#[serde(default)]
pub last_attempted_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_succeeded_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub last_workspace_count: Option<usize>,
#[serde(default)]
pub last_entry_count: Option<usize>,
#[serde(default)]
pub last_entries_skipped: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct SystemInventoryRefreshState {
#[serde(default)]
pub last_status: Option<String>,
#[serde(default)]
pub last_trigger: Option<String>,
#[serde(default)]
pub last_mode: Option<String>,
#[serde(default)]
pub last_attempted_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_succeeded_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub include_cursor: Option<bool>,
#[serde(default)]
pub refreshed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct ConfiguredRunnerHost {
pub runner_host_id: String,
#[serde(default)]
pub organization_id: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub platform: Option<RunnerPlatform>,
#[serde(default)]
pub runtime: Option<RunnerExecutionRuntime>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct RunnerMachineConfig {
#[serde(default)]
pub runtime_root: Option<String>,
#[serde(default)]
pub preferred_runtime: Option<RunnerExecutionRuntime>,
#[serde(default)]
pub hosts: Vec<ConfiguredRunnerHost>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct OpenRouterConfig {
#[serde(default = "default_openrouter_commit_model")]
pub commit_model: String,
#[serde(default = "default_openrouter_release_notes_model")]
pub release_notes_model: String,
#[serde(default = "default_openrouter_commit_system_prompt")]
pub commit_system_prompt: String,
#[serde(default = "default_openrouter_release_notes_system_prompt")]
pub release_notes_system_prompt: String,
}
impl Default for OpenRouterConfig {
fn default() -> Self {
Self {
commit_model: default_openrouter_commit_model(),
release_notes_model: default_openrouter_release_notes_model(),
commit_system_prompt: default_openrouter_commit_system_prompt(),
release_notes_system_prompt: default_openrouter_release_notes_system_prompt(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SshConfig {
pub password: Option<String>,
pub username: Option<String>,
pub host: Option<String>,
pub project_dir: Option<String>,
#[serde(default)]
pub xbp_api_token: Option<String>,
pub openrouter_api_key: Option<String>,
pub github_oauth2_key: Option<String>,
#[serde(default)]
pub cloudflare_api_token: Option<String>,
#[serde(default)]
pub cloudflare_user_api_token: Option<String>,
#[serde(default)]
pub cloudflare_account_api_token: Option<String>,
#[serde(default)]
pub cloudflare_account_id: Option<String>,
pub linear_api_key: Option<String>,
#[serde(default)]
pub npm_token: Option<String>,
#[serde(default)]
pub crates_token: Option<String>,
#[serde(default)]
pub openrouter: OpenRouterConfig,
#[serde(default)]
pub linear: Option<LinearConfig>,
#[serde(default)]
pub cli_auth: Option<CliAuthState>,
#[serde(default)]
pub device: Option<DeviceIdentity>,
#[serde(default)]
pub secret_metadata: BTreeMap<String, SecretMetadata>,
#[serde(default)]
pub system_inventory: Option<SystemInventory>,
#[serde(default)]
pub cursor_ingest: Option<CursorIngestState>,
#[serde(default)]
pub system_inventory_refresh: Option<SystemInventoryRefreshState>,
#[serde(default)]
pub dns_inventory: Option<DnsInventoryCache>,
#[serde(default)]
pub runners: Option<RunnerMachineConfig>,
#[serde(default)]
pub worktree_watch: Option<WorktreeWatchConfig>,
#[serde(default)]
pub issues: Option<TodosConfig>,
#[serde(default)]
pub todos: Option<TodosConfig>,
#[serde(default)]
pub discord: Option<DiscordConfig>,
#[serde(default)]
pub oci: Option<GlobalOciConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct GlobalOciConfig {
#[serde(default)]
pub registries: BTreeMap<String, GlobalOciRegistryCred>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct GlobalOciRegistryCred {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub token: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretProvider {
OpenRouter,
Github,
Cloudflare,
Linear,
Npm,
Crates,
}
impl SecretProvider {
pub fn from_key(key: &str) -> Option<Self> {
match key.trim().to_ascii_lowercase().as_str() {
"openrouter" => Some(Self::OpenRouter),
"github" => Some(Self::Github),
"cloudflare" => Some(Self::Cloudflare),
"linear" => Some(Self::Linear),
"npm" | "npmjs" => Some(Self::Npm),
"crates" | "crates-io" | "cratesio" => Some(Self::Crates),
_ => None,
}
}
pub fn as_key(&self) -> &'static str {
match self {
SecretProvider::OpenRouter => "openrouter",
SecretProvider::Github => "github",
SecretProvider::Cloudflare => "cloudflare",
SecretProvider::Linear => "linear",
SecretProvider::Npm => "npm",
SecretProvider::Crates => "crates",
}
}
pub fn config_field(&self) -> &'static str {
match self {
SecretProvider::OpenRouter => "openrouter_api_key",
SecretProvider::Github => "github_oauth2_key",
SecretProvider::Cloudflare => "cloudflare_api_token",
SecretProvider::Linear => "linear_api_key",
SecretProvider::Npm => "npm_token",
SecretProvider::Crates => "crates_token",
}
}
}
impl Default for SshConfig {
fn default() -> Self {
Self::new()
}
}
impl SshConfig {
pub fn new() -> Self {
SshConfig {
password: None,
username: None,
host: None,
project_dir: None,
xbp_api_token: None,
openrouter_api_key: None,
github_oauth2_key: None,
cloudflare_api_token: None,
cloudflare_user_api_token: None,
cloudflare_account_api_token: None,
cloudflare_account_id: None,
linear_api_key: None,
npm_token: None,
crates_token: None,
openrouter: OpenRouterConfig::default(),
linear: None,
cli_auth: None,
device: None,
secret_metadata: BTreeMap::new(),
system_inventory: None,
cursor_ingest: None,
system_inventory_refresh: None,
dns_inventory: None,
runners: None,
worktree_watch: None,
issues: None,
todos: None,
discord: None,
oci: None,
}
}
pub fn worktree_watch_config(&self) -> WorktreeWatchConfig {
self.worktree_watch.clone().unwrap_or_default()
}
pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
match provider {
SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
SecretProvider::Github => self.github_oauth2_key.as_deref(),
SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
SecretProvider::Linear => self.linear_api_key.as_deref(),
SecretProvider::Npm => self.npm_token.as_deref(),
SecretProvider::Crates => self.crates_token.as_deref(),
}
}
pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
match provider {
SecretProvider::OpenRouter => self.openrouter_api_key = value,
SecretProvider::Github => self.github_oauth2_key = value,
SecretProvider::Cloudflare => self.cloudflare_api_token = value,
SecretProvider::Linear => self.linear_api_key = value,
SecretProvider::Npm => self.npm_token = value,
SecretProvider::Crates => self.crates_token = value,
}
match self.get_secret(provider) {
Some(_) => {
self.secret_metadata.insert(
provider.as_key().to_string(),
SecretMetadata {
added_at: Some(Utc::now()),
},
);
}
None => {
self.secret_metadata.remove(provider.as_key());
}
}
}
pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
self.secret_metadata.get(provider.as_key())
}
pub fn load() -> Result<Self, String> {
let config_path = get_config_path();
let legacy_path = legacy_config_path();
let path_to_read = if config_path.exists() {
config_path
} else if legacy_path.exists() {
legacy_path
} else {
return Ok(SshConfig::new());
};
let content = fs::read_to_string(&path_to_read)
.map_err(|e| format!("Failed to read config file: {}", e))?;
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
}
pub fn save(&self) -> Result<(), String> {
let config_path = get_config_path();
let config_dir = config_path.parent().ok_or("Invalid config path")?;
fs::create_dir_all(config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
let content = serde_yaml::to_string(self)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
}
}
pub fn resolve_worktree_watch_config() -> WorktreeWatchConfig {
SshConfig::load()
.ok()
.map(|cfg| cfg.worktree_watch_config())
.unwrap_or_default()
}
pub fn global_runner_root_dir() -> Result<PathBuf, String> {
let paths = ensure_global_xbp_paths()?;
let config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
if let Some(path) = config
.runners
.as_ref()
.and_then(|runners| runners.runtime_root.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
{
let candidate = PathBuf::from(path);
fs::create_dir_all(&candidate).map_err(|error| {
format!(
"Failed to create runner runtime root {}: {}",
candidate.display(),
error
)
})?;
return Ok(candidate);
}
let default_root = paths.root_dir.join("github").join("runners");
fs::create_dir_all(&default_root).map_err(|error| {
format!(
"Failed to create runner runtime root {}: {}",
default_root.display(),
error
)
})?;
Ok(default_root)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VersioningFilesConfig {
#[serde(default = "default_versioning_files")]
pub files: Vec<String>,
}
impl Default for VersioningFilesConfig {
fn default() -> Self {
Self {
files: default_versioning_files(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct PackageNameLookup {
pub file: String,
pub format: String,
pub key: String,
pub registry: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PackageNameFilesConfig {
#[serde(default = "default_package_name_lookups")]
pub lookups: Vec<PackageNameLookup>,
}
impl Default for PackageNameFilesConfig {
fn default() -> Self {
Self {
lookups: default_package_name_lookups(),
}
}
}
pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
let root_dir = preferred_global_root_dir();
let paths = GlobalXbpPaths {
config_file: root_dir.join("config.yaml"),
ssh_dir: root_dir.join("ssh"),
cache_dir: root_dir.join("cache"),
logs_dir: root_dir.join("logs"),
versioning_files_file: root_dir.join("versioning-files.yaml"),
package_name_files_file: root_dir.join("package-name-files.yaml"),
worktree_ignore_file: root_dir.join(GLOBAL_WORKTREE_IGNORE_FILENAME),
root_dir,
};
for dir in [
&paths.root_dir,
&paths.ssh_dir,
&paths.cache_dir,
&paths.logs_dir,
] {
fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
}
maybe_migrate_legacy_windows_files(&paths)?;
if !paths.config_file.exists() {
let default_config = serde_yaml::to_string(&SshConfig::new())
.map_err(|e| format!("Failed to serialize default config: {}", e))?;
fs::write(&paths.config_file, default_config).map_err(|e| {
format!(
"Failed to initialize config file {}: {}",
paths.config_file.display(),
e
)
})?;
}
sync_global_config_defaults_at(&paths.config_file)?;
sync_versioning_files_registry_at(&paths.versioning_files_file)?;
sync_package_name_files_registry_at(&paths.package_name_files_file)?;
sync_global_worktree_ignore_file(&paths.worktree_ignore_file)?;
Ok(paths)
}
pub fn load_global_worktree_ignore() -> XbpIgnoreSet {
match ensure_global_xbp_paths() {
Ok(paths) => load_global_worktree_ignore_from_path(&paths.worktree_ignore_file),
Err(_) => XbpIgnoreSet::empty(),
}
}
fn default_openrouter_commit_model() -> String {
DEFAULT_MODEL.to_string()
}
fn default_openrouter_release_notes_model() -> String {
DEFAULT_MODEL.to_string()
}
fn default_openrouter_commit_system_prompt() -> String {
DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
}
fn default_openrouter_release_notes_system_prompt() -> String {
DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
}
const SYSTEM_INVENTORY_REFRESH_HOURS: i64 = 24;
fn resolve_openrouter_config_value<F>(selector: F, fallback: &str) -> String
where
F: FnOnce(&OpenRouterConfig) -> &str,
{
SshConfig::load()
.ok()
.map(|cfg| selector(&cfg.openrouter).trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| fallback.to_string())
}
fn sync_global_config_defaults_at(config_path: &PathBuf) -> Result<(), String> {
let content = fs::read_to_string(config_path).map_err(|e| {
format!(
"Failed to read config file {}: {}",
config_path.display(),
e
)
})?;
if content.contains("openrouter:")
&& content.contains("commit_model:")
&& content.contains("release_notes_model:")
&& content.contains("commit_system_prompt:")
&& content.contains("release_notes_system_prompt:")
{
return Ok(());
}
let config: SshConfig = serde_yaml::from_str(&content)
.map_err(|e| format!("Failed to parse config file: {}", e))?;
let normalized =
serde_yaml::to_string(&config).map_err(|e| format!("Failed to serialize config: {}", e))?;
if normalized != content {
fs::write(config_path, normalized).map_err(|e| {
format!(
"Failed to write config file {}: {}",
config_path.display(),
e
)
})?;
}
Ok(())
}
pub fn resolve_openrouter_api_key() -> Option<String> {
env::var("OPENROUTER_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
SshConfig::load()
.ok()
.and_then(|cfg| {
cfg.get_secret(SecretProvider::OpenRouter)
.map(str::to_string)
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
pub fn resolve_openrouter_commit_model() -> String {
resolve_openrouter_config_value(|cfg| cfg.commit_model.as_str(), DEFAULT_MODEL)
}
pub fn resolve_openrouter_release_notes_model() -> String {
resolve_openrouter_config_value(|cfg| cfg.release_notes_model.as_str(), DEFAULT_MODEL)
}
pub fn resolve_openrouter_commit_system_prompt() -> String {
resolve_openrouter_config_value(
|cfg| cfg.commit_system_prompt.as_str(),
DEFAULT_COMMIT_SYSTEM_PROMPT,
)
}
pub fn resolve_openrouter_release_notes_system_prompt() -> String {
resolve_openrouter_config_value(
|cfg| cfg.release_notes_system_prompt.as_str(),
DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
)
}
pub fn resolve_xbp_api_token() -> Option<String> {
env::var("XBP_API_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.xbp_api_token)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
pub fn resolve_github_oauth2_key() -> Option<String> {
for env_var in [
"GITHUB_TOKEN",
"GITHUB_OAUTH2_KEY",
"GITHUB_OAUTH2_TOKEN",
"GITHUB_OAUTH_TOKEN",
] {
if let Ok(value) = env::var(env_var) {
let token = value.trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
}
SshConfig::load()
.ok()
.and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn resolve_cloudflare_api_token() -> Option<String> {
cloudflare_api_token_candidates()
.into_iter()
.next()
.map(|(_, token)| token)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloudflareTokenKind {
User,
Account,
GlobalKey,
Legacy,
Empty,
}
pub fn classify_cloudflare_token(token: &str) -> CloudflareTokenKind {
let token = token.trim();
if token.is_empty() {
return CloudflareTokenKind::Empty;
}
if token.starts_with("cfut_") {
CloudflareTokenKind::User
} else if token.starts_with("cfat_") {
CloudflareTokenKind::Account
} else if token.starts_with("cfk_") {
CloudflareTokenKind::GlobalKey
} else {
CloudflareTokenKind::Legacy
}
}
pub fn cloudflare_token_kind_label(kind: CloudflareTokenKind) -> &'static str {
match kind {
CloudflareTokenKind::User => "user token (cfut_)",
CloudflareTokenKind::Account => "account-owned token (cfat_)",
CloudflareTokenKind::GlobalKey => "global API key (cfk_)",
CloudflareTokenKind::Legacy => "legacy unprefixed token",
CloudflareTokenKind::Empty => "missing",
}
}
pub fn format_cloudflare_token_preview(token: &str) -> String {
let t = token.trim();
if t.is_empty() {
return "missing".to_string();
}
let kind = classify_cloudflare_token(t);
let kind_hint = match kind {
CloudflareTokenKind::User => "user",
CloudflareTokenKind::Account => "account-owned",
CloudflareTokenKind::GlobalKey => "global-key",
CloudflareTokenKind::Legacy => "legacy",
CloudflareTokenKind::Empty => "missing",
};
if t.len() <= 8 {
return format!("******** ({kind_hint})");
}
format!(
"{}…{} (len={}, {kind_hint})",
&t[..4.min(t.len())],
&t[t.len().saturating_sub(4)..],
t.len()
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CloudflareTokenSlot {
#[default]
Auto,
User,
Account,
Primary,
All,
}
impl CloudflareTokenSlot {
pub fn parse(raw: &str) -> Result<Self, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"user" | "cfut" | "cfut_" => Ok(Self::User),
"account" | "account-owned" | "cfat" | "cfat_" => Ok(Self::Account),
"primary" | "default" | "legacy" => Ok(Self::Primary),
"all" => Ok(Self::All),
other => Err(format!(
"Unknown Cloudflare token kind `{other}`. Use auto, user, account, primary, or all."
)),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::User => "user",
Self::Account => "account",
Self::Primary => "primary",
Self::All => "all",
}
}
}
#[derive(Debug, Clone)]
pub struct CloudflareTokenStoreReport {
pub kind: CloudflareTokenKind,
pub slots_written: Vec<&'static str>,
pub notes: Vec<String>,
}
impl SshConfig {
pub fn cloudflare_user_api_token(&self) -> Option<&str> {
self.cloudflare_user_api_token
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
}
pub fn cloudflare_account_api_token(&self) -> Option<&str> {
self.cloudflare_account_api_token
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
}
pub fn cloudflare_primary_api_token(&self) -> Option<&str> {
self.cloudflare_api_token
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
}
pub fn store_cloudflare_token(
&mut self,
token: impl Into<String>,
slot: CloudflareTokenSlot,
) -> Result<CloudflareTokenStoreReport, String> {
let token = token.into().trim().to_string();
if token.is_empty() {
return Err("Refusing to store an empty Cloudflare API token.".into());
}
if matches!(slot, CloudflareTokenSlot::All) {
return Err(
"Cannot store into kind `all` — use auto, user, account, or primary.".into(),
);
}
let detected = classify_cloudflare_token(&token);
let mut slots_written = Vec::new();
let mut notes = Vec::new();
let target = match slot {
CloudflareTokenSlot::Auto => match detected {
CloudflareTokenKind::User => CloudflareTokenSlot::User,
CloudflareTokenKind::Account => CloudflareTokenSlot::Account,
CloudflareTokenKind::GlobalKey => {
notes.push(
"Global API keys (`cfk_`) are not recommended; stored in primary only."
.into(),
);
CloudflareTokenSlot::Primary
}
CloudflareTokenKind::Legacy | CloudflareTokenKind::Empty => {
notes.push(
"Unprefixed/legacy token — stored in primary only. Prefer scannable \
`cfut_` (user) or `cfat_` (account) tokens."
.into(),
);
CloudflareTokenSlot::Primary
}
},
other => other,
};
if slot != CloudflareTokenSlot::Auto {
match (slot, detected) {
(CloudflareTokenSlot::User, CloudflareTokenKind::Account) => notes.push(
"Warning: value looks like an account-owned token (`cfat_`) but was forced \
into the user slot."
.into(),
),
(CloudflareTokenSlot::Account, CloudflareTokenKind::User) => notes.push(
"Warning: value looks like a user token (`cfut_`) but was forced into the \
account slot."
.into(),
),
_ => {}
}
}
match target {
CloudflareTokenSlot::User => {
self.cloudflare_user_api_token = Some(token.clone());
slots_written.push("cloudflare_user_api_token");
self.touch_secret_metadata("cloudflare_user");
if self.should_mirror_cloudflare_primary(CloudflareTokenKind::User) {
self.cloudflare_api_token = Some(token.clone());
slots_written.push("cloudflare_api_token (primary)");
self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
notes.push(
"Also set primary `cloudflare_api_token` for Wrangler/default resolve."
.into(),
);
}
if detected == CloudflareTokenKind::User {
notes.push(
"User token (`cfut_`) — suitable for Workers Builds and Wrangler.".into(),
);
}
}
CloudflareTokenSlot::Account => {
self.cloudflare_account_api_token = Some(token.clone());
slots_written.push("cloudflare_account_api_token");
self.touch_secret_metadata("cloudflare_account");
if self.should_mirror_cloudflare_primary(CloudflareTokenKind::Account) {
self.cloudflare_api_token = Some(token.clone());
slots_written.push("cloudflare_api_token (primary)");
self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
notes.push(
"Also set primary `cloudflare_api_token` for Wrangler/default resolve."
.into(),
);
}
if detected == CloudflareTokenKind::Account {
notes.push(
"Account-owned token (`cfat_`) — Wrangler OK; Workers Builds needs a \
separate user token (`cfut_`) in `cloudflare_user_api_token`."
.into(),
);
}
}
CloudflareTokenSlot::Primary => {
self.cloudflare_api_token = Some(token);
slots_written.push("cloudflare_api_token (primary)");
self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
}
CloudflareTokenSlot::Auto | CloudflareTokenSlot::All => unreachable!(),
}
Ok(CloudflareTokenStoreReport {
kind: detected,
slots_written,
notes,
})
}
pub fn clear_cloudflare_token_slot(&mut self, slot: CloudflareTokenSlot) -> Vec<&'static str> {
let mut cleared = Vec::new();
let clear_primary = || {};
let _ = clear_primary;
match slot {
CloudflareTokenSlot::User => {
if self.cloudflare_user_api_token.take().is_some() {
cleared.push("cloudflare_user_api_token");
self.secret_metadata.remove("cloudflare_user");
}
}
CloudflareTokenSlot::Account => {
if self.cloudflare_account_api_token.take().is_some() {
cleared.push("cloudflare_account_api_token");
self.secret_metadata.remove("cloudflare_account");
}
}
CloudflareTokenSlot::Primary => {
if self.cloudflare_api_token.take().is_some() {
cleared.push("cloudflare_api_token");
self.secret_metadata.remove(SecretProvider::Cloudflare.as_key());
}
}
CloudflareTokenSlot::Auto | CloudflareTokenSlot::All => {
if self.cloudflare_user_api_token.take().is_some() {
cleared.push("cloudflare_user_api_token");
self.secret_metadata.remove("cloudflare_user");
}
if self.cloudflare_account_api_token.take().is_some() {
cleared.push("cloudflare_account_api_token");
self.secret_metadata.remove("cloudflare_account");
}
if self.cloudflare_api_token.take().is_some() {
cleared.push("cloudflare_api_token");
self.secret_metadata.remove(SecretProvider::Cloudflare.as_key());
}
}
}
cleared
}
fn should_mirror_cloudflare_primary(&self, writing: CloudflareTokenKind) -> bool {
match self.cloudflare_primary_api_token() {
None => true,
Some(primary) => match writing {
CloudflareTokenKind::User => true,
CloudflareTokenKind::Account => matches!(
classify_cloudflare_token(primary),
CloudflareTokenKind::Account | CloudflareTokenKind::Empty
),
_ => classify_cloudflare_token(primary) == writing,
},
}
}
fn touch_secret_metadata(&mut self, key: &str) {
self.secret_metadata.insert(
key.to_string(),
SecretMetadata {
added_at: Some(Utc::now()),
},
);
}
pub fn cloudflare_config_token_entries(&self) -> Vec<(String, String)> {
let mut out = Vec::new();
if let Some(t) = self.cloudflare_user_api_token() {
out.push((
"global cloudflare_user_api_token (user)".into(),
t.to_string(),
));
}
if let Some(t) = self.cloudflare_primary_api_token() {
let kind = cloudflare_token_kind_label(classify_cloudflare_token(t));
out.push((
format!("global cloudflare_api_token (primary, {kind})"),
t.to_string(),
));
}
if let Some(t) = self.cloudflare_account_api_token() {
out.push((
"global cloudflare_account_api_token (account-owned)".into(),
t.to_string(),
));
}
out
}
}
pub fn cloudflare_api_token_candidates() -> Vec<(String, String)> {
cloudflare_api_token_candidates_ordered(false)
}
pub fn cloudflare_api_token_candidates_builds_first() -> Vec<(String, String)> {
cloudflare_api_token_candidates_ordered(true)
}
fn cloudflare_api_token_candidates_ordered(prefer_user_for_builds: bool) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let push = |out: &mut Vec<(String, String)>,
seen: &mut std::collections::BTreeSet<String>,
source: &str,
token: String| {
let token = token.trim().to_string();
if token.is_empty() || !seen.insert(token.clone()) {
return;
}
out.push((source.to_string(), token));
};
if let Ok(value) = env::var("CLOUDFLARE_API_TOKEN") {
push(
&mut out,
&mut seen,
"process env CLOUDFLARE_API_TOKEN",
value,
);
}
if let Ok(value) = env::var("CLOUDFLARE_USER_API_TOKEN") {
push(
&mut out,
&mut seen,
"process env CLOUDFLARE_USER_API_TOKEN",
value,
);
}
if let Ok(value) = env::var("CLOUDFLARE_ACCOUNT_API_TOKEN") {
push(
&mut out,
&mut seen,
"process env CLOUDFLARE_ACCOUNT_API_TOKEN",
value,
);
}
if let Ok(cfg) = SshConfig::load() {
for (source, token) in cfg.cloudflare_config_token_entries() {
push(&mut out, &mut seen, &source, token);
}
}
if prefer_user_for_builds {
out.sort_by_key(|(_, token)| {
match classify_cloudflare_token(token) {
CloudflareTokenKind::User => 0u8,
CloudflareTokenKind::Legacy => 1,
CloudflareTokenKind::Empty => 2,
CloudflareTokenKind::Account => 3,
CloudflareTokenKind::GlobalKey => 4,
}
});
}
out
}
pub fn cloudflare_env_token_shadows_config() -> bool {
let env_tok = env::var("CLOUDFLARE_API_TOKEN")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let cfg_tok = SshConfig::load().ok().and_then(|cfg| {
cfg.cloudflare_primary_api_token()
.or_else(|| cfg.cloudflare_user_api_token())
.or_else(|| cfg.cloudflare_account_api_token())
.map(str::to_string)
});
match (env_tok, cfg_tok) {
(Some(a), Some(b)) => a != b,
_ => false,
}
}
pub fn cloudflare_account_id_from_process_env() -> Option<String> {
CLOUDFLARE_ACCOUNT_ID_ENV_KEYS.iter().find_map(|key| {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
pub fn cloudflare_account_id_from_project_env() -> Option<String> {
let current_dir = env::current_dir().ok()?;
let found = find_xbp_config_upwards(¤t_dir)?;
let lookup = load_env_lookup(&found.project_root);
first_lookup_value(&lookup, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS)
}
pub fn cloudflare_account_id_from_global_config() -> Option<String> {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.cloudflare_account_id)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn resolve_cloudflare_account_id() -> Option<String> {
cloudflare_account_id_from_process_env()
.or_else(cloudflare_account_id_from_project_env)
.or_else(cloudflare_account_id_from_global_config)
}
pub fn resolve_linear_api_key() -> Option<String> {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub const NPM_TOKEN_ENV_KEYS: &[&str] = &["NPM_TOKEN", "NODE_AUTH_TOKEN", "NPM_CONFIG_TOKEN"];
pub const CRATES_TOKEN_ENV_KEYS: &[&str] = &["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"];
const REGISTRY_TOKEN_DOTENV_MAX_DEPTH: usize = 12;
pub fn resolve_npm_token() -> Option<String> {
let start = registry_token_search_start();
resolve_npm_token_from(start.as_deref())
}
pub fn resolve_npm_token_from(start_dir: Option<&Path>) -> Option<String> {
token_from_process_env(NPM_TOKEN_ENV_KEYS)
.or_else(|| {
start_dir.and_then(|dir| {
first_dotenv_value_upwards(dir, NPM_TOKEN_ENV_KEYS, REGISTRY_TOKEN_DOTENV_MAX_DEPTH)
.map(|(value, _)| value)
})
})
.or_else(npm_token_from_global_config)
}
pub fn resolve_crates_token() -> Option<String> {
let start = registry_token_search_start();
resolve_crates_token_from(start.as_deref())
}
pub fn resolve_crates_token_from(start_dir: Option<&Path>) -> Option<String> {
token_from_process_env(CRATES_TOKEN_ENV_KEYS)
.or_else(|| {
start_dir.and_then(|dir| {
first_dotenv_value_upwards(
dir,
CRATES_TOKEN_ENV_KEYS,
REGISTRY_TOKEN_DOTENV_MAX_DEPTH,
)
.map(|(value, _)| value)
})
})
.or_else(crates_token_from_global_config)
}
fn registry_token_search_start() -> Option<PathBuf> {
let cwd = env::current_dir().ok()?;
find_xbp_config_upwards(&cwd)
.map(|found| found.project_root)
.or(Some(cwd))
}
fn token_from_process_env(keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
fn npm_token_from_global_config() -> Option<String> {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn crates_token_from_global_config() -> Option<String> {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn mask_registry_token(token: &str) -> String {
let trimmed = token.trim();
if trimmed.len() <= 8 {
return "***".to_string();
}
let prefix_len = trimmed
.find('_')
.map(|idx| (idx + 1).min(6))
.unwrap_or(4)
.min(trimmed.len() / 2);
let prefix = &trimmed[..prefix_len];
let suffix = &trimmed[trimmed.len().saturating_sub(4)..];
format!("{prefix}…{suffix}")
}
pub fn describe_npm_token_sources(start_dir: Option<&Path>) -> Vec<String> {
let mut lines = Vec::new();
for key in NPM_TOKEN_ENV_KEYS {
match env::var(key) {
Ok(value) if !value.trim().is_empty() => {
lines.push(format!(
"process env {key}: set ({})",
mask_registry_token(value.trim())
));
}
_ => lines.push(format!("process env {key}: unset")),
}
}
if let Some(dir) = start_dir {
match first_dotenv_value_upwards(dir, NPM_TOKEN_ENV_KEYS, REGISTRY_TOKEN_DOTENV_MAX_DEPTH)
{
Some((value, path)) => lines.push(format!(
"dotenv {}: set ({})",
path.display(),
mask_registry_token(&value)
)),
None => lines.push(format!(
"dotenv under {}: no NPM_TOKEN / NODE_AUTH_TOKEN / NPM_CONFIG_TOKEN",
dir.display()
)),
}
}
match npm_token_from_global_config() {
Some(value) => lines.push(format!(
"global xbp config (npm_token): set ({})",
mask_registry_token(&value)
)),
None => lines.push("global xbp config (npm_token): unset".to_string()),
}
lines
}
pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
let mut config = SshConfig::load()?;
config.cloudflare_account_id = value;
config.save()
}
pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
Ok(SshConfig::load()?.cloudflare_account_id)
}
pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
}
pub fn resolve_device_identity() -> Result<DeviceIdentity, String> {
ensure_global_xbp_paths()?;
let mut config = SshConfig::load()?;
if let Some(device) = config.device.clone() {
if !device.hardware_id.trim().is_empty() {
return Ok(device);
}
}
let hardware_id = format!("xbp_hw_{}", uuid::Uuid::new_v4());
let device = DeviceIdentity {
hardware_id,
created_at: Some(Utc::now()),
};
config.device = Some(device.clone());
config.save()?;
Ok(device)
}
pub fn reserve_cursor_ingest_slot(
trigger: &str,
mode: &str,
min_interval: ChronoDuration,
) -> Result<bool, String> {
ensure_global_xbp_paths()?;
let mut config = SshConfig::load()?;
let now = Utc::now();
if let Some(state) = config.cursor_ingest.as_ref() {
if let Some(last_attempted_at) = state.last_attempted_at {
if now - last_attempted_at < min_interval {
return Ok(false);
}
}
}
let mut state = config.cursor_ingest.unwrap_or_default();
state.last_status = Some("scheduled".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(now);
state.last_error = None;
config.cursor_ingest = Some(state);
config.save()?;
Ok(true)
}
pub fn record_cursor_ingest_started(trigger: &str, mode: &str) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut state = config.cursor_ingest.unwrap_or_default();
state.last_status = Some("running".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(Utc::now());
state.last_error = None;
config.cursor_ingest = Some(state);
config.save()
}
pub fn record_cursor_ingest_success(
trigger: &str,
mode: &str,
workspace_count: usize,
entry_count: usize,
entries_skipped: usize,
) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut state = config.cursor_ingest.unwrap_or_default();
let completed_at = Utc::now();
state.last_status = Some("success".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(completed_at);
state.last_succeeded_at = Some(completed_at);
state.last_error = None;
state.last_workspace_count = Some(workspace_count);
state.last_entry_count = Some(entry_count);
state.last_entries_skipped = Some(entries_skipped);
config.cursor_ingest = Some(state);
config.save()
}
pub fn record_cursor_ingest_failure(trigger: &str, mode: &str, error: &str) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut state = config.cursor_ingest.unwrap_or_default();
state.last_status = Some("failed".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(Utc::now());
state.last_error = Some(error.chars().take(400).collect());
config.cursor_ingest = Some(state);
config.save()
}
pub fn reserve_system_inventory_refresh_slot(
trigger: &str,
mode: &str,
include_cursor: bool,
min_interval: ChronoDuration,
) -> Result<bool, String> {
ensure_global_xbp_paths()?;
let mut config = SshConfig::load()?;
let now = Utc::now();
if let Some(existing) = config.system_inventory.as_ref() {
if !system_inventory_needs_refresh(existing, include_cursor) {
return Ok(false);
}
}
if let Some(state) = config.system_inventory_refresh.as_ref() {
if let Some(last_attempted_at) = state.last_attempted_at {
if now - last_attempted_at < min_interval {
return Ok(false);
}
}
}
let mut state = config.system_inventory_refresh.unwrap_or_default();
state.last_status = Some("scheduled".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(now);
state.last_error = None;
state.include_cursor = Some(include_cursor);
config.system_inventory_refresh = Some(state);
config.save()?;
Ok(true)
}
pub fn record_system_inventory_refresh_started(
trigger: &str,
mode: &str,
include_cursor: bool,
) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut state = config.system_inventory_refresh.unwrap_or_default();
state.last_status = Some("running".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(Utc::now());
state.last_error = None;
state.include_cursor = Some(include_cursor);
config.system_inventory_refresh = Some(state);
config.save()
}
pub fn record_system_inventory_refresh_success(
trigger: &str,
mode: &str,
include_cursor: bool,
refreshed: bool,
) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut state = config.system_inventory_refresh.unwrap_or_default();
let completed_at = Utc::now();
state.last_status = Some("success".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(completed_at);
state.last_succeeded_at = Some(completed_at);
state.last_error = None;
state.include_cursor = Some(include_cursor);
state.refreshed = Some(refreshed);
config.system_inventory_refresh = Some(state);
config.save()
}
pub fn record_system_inventory_refresh_failure(
trigger: &str,
mode: &str,
include_cursor: bool,
error: &str,
) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
let mut state = config.system_inventory_refresh.unwrap_or_default();
state.last_status = Some("failed".to_string());
state.last_trigger = Some(trigger.to_string());
state.last_mode = Some(mode.to_string());
state.last_attempted_at = Some(Utc::now());
state.last_error = Some(error.chars().take(400).collect());
state.include_cursor = Some(include_cursor);
config.system_inventory_refresh = Some(state);
config.save()
}
pub fn update_dns_inventory_cache<F>(updater: F) -> Result<(), String>
where
F: FnOnce(&mut DnsInventoryCache),
{
ensure_global_xbp_paths()?;
let mut config = SshConfig::load()?;
let mut cache = config.dns_inventory.unwrap_or_default();
updater(&mut cache);
config.dns_inventory = Some(cache);
config.save()
}
pub fn sync_system_inventory(
force: bool,
include_cursor: bool,
current_dir: Option<&Path>,
) -> Result<SystemInventorySyncResult, String> {
let paths = ensure_global_xbp_paths()?;
let mut config = SshConfig::load()?;
if !force {
if let Some(existing) = config.system_inventory.clone() {
if !system_inventory_needs_refresh(&existing, include_cursor) {
return Ok(SystemInventorySyncResult {
inventory: existing,
config_path: paths.config_file,
refreshed: false,
});
}
}
}
let mut options = SystemInventoryOptions {
include_cursor,
current_dir: current_dir.map(Path::to_path_buf),
xbp_global_root: Some(paths.root_dir.clone()),
};
if options.current_dir.is_none() {
options.current_dir = env::current_dir().ok();
}
let mut inventory = collect_codetime_system_inventory(&options);
if !include_cursor {
if let Some(existing_cursor) = config
.system_inventory
.as_ref()
.and_then(|existing| existing.cursor.clone())
{
inventory.cursor = Some(existing_cursor);
}
}
config.system_inventory = Some(inventory.clone());
config.save()?;
Ok(SystemInventorySyncResult {
inventory,
config_path: paths.config_file,
refreshed: true,
})
}
fn system_inventory_needs_refresh(inventory: &SystemInventory, include_cursor: bool) -> bool {
if include_cursor && inventory.cursor.is_none() {
return true;
}
match inventory.collected_at {
Some(collected_at) => {
Utc::now() - collected_at >= ChronoDuration::hours(SYSTEM_INVENTORY_REFRESH_HOURS)
}
None => true,
}
}
pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
ensure_global_xbp_paths()
}
pub fn get_config_path() -> PathBuf {
ensure_global_xbp_paths()
.map(|paths| paths.config_file)
.unwrap_or_else(|_| legacy_config_path())
}
#[cfg(target_os = "windows")]
fn legacy_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("xbp")
.join("config.yaml")
}
#[cfg(not(target_os = "windows"))]
fn legacy_config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".xbp")
.join("config.yaml")
}
pub fn resolve_global_xbp_root_dir() -> PathBuf {
preferred_global_root_dir()
}
#[cfg(test)]
mod global_root_tests {
use super::{map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows, score_xbp_root};
use std::path::Path;
#[test]
fn maps_windows_and_wsl_paths_both_ways() {
assert_eq!(
map_windows_path_to_wsl_mnt(r"C:\Users\szymon\.xbp"),
Some("/mnt/c/Users/szymon/.xbp".to_string())
);
assert_eq!(
map_wsl_mnt_path_to_windows("/mnt/c/Users/szymon/.xbp"),
Some("C:/Users/szymon/.xbp".to_string())
);
}
#[test]
fn scores_missing_root_as_zero() {
assert_eq!(score_xbp_root(Path::new("/definitely/not/an/xbp/root")), 0);
}
}
fn preferred_global_root_dir() -> PathBuf {
if let Some(explicit) = explicit_xbp_home_from_env() {
return explicit;
}
let candidates = collect_global_xbp_root_candidates();
if let Some(best) = pick_established_xbp_root(&candidates) {
return best;
}
#[cfg(target_os = "windows")]
{
if let Some(home_dir) = resolve_windows_home_dir() {
let c_drive = Path::new(r"C:\");
if c_drive.exists() {
if windows_drive_letter(&home_dir) == Some('C') {
return home_dir.join(".xbp");
}
if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
let c_profile_candidate = c_drive.join(relative_profile_path);
if c_profile_candidate.exists() {
return c_profile_candidate.join(".xbp");
}
}
}
return home_dir.join(".xbp");
}
return dirs::config_dir()
.or_else(|| dirs::home_dir().map(|home| home.join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("xbp");
}
#[cfg(not(target_os = "windows"))]
{
if let Some(home) = dirs::home_dir() {
return home.join(".xbp");
}
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("xbp")
}
}
fn explicit_xbp_home_from_env() -> Option<PathBuf> {
for key in ["XBP_HOME", "XBP_CONFIG_HOME", "XBP_GLOBAL_ROOT"] {
if let Ok(value) = env::var(key) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
}
None
}
fn collect_global_xbp_root_candidates() -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
let mut push = |path: PathBuf| {
if candidates
.iter()
.any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
{
return;
}
candidates.push(path);
};
if let Some(explicit) = explicit_xbp_home_from_env() {
push(explicit);
}
#[cfg(target_os = "windows")]
{
if let Some(home_dir) = resolve_windows_home_dir() {
push(home_dir.join(".xbp"));
if let Some(relative) = windows_path_without_drive(&home_dir) {
push(Path::new(r"C:\").join(relative).join(".xbp"));
}
}
if let Some(config_dir) = dirs::config_dir() {
push(config_dir.join("xbp"));
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(home) = dirs::home_dir() {
push(home.join(".xbp"));
push(home.join(".config").join("xbp"));
}
if let Some(config_dir) = dirs::config_dir() {
push(config_dir.join("xbp"));
}
for windows_home in discover_windows_profile_homes_from_unix() {
push(windows_home.join(".xbp"));
}
}
candidates
}
fn pick_established_xbp_root(candidates: &[PathBuf]) -> Option<PathBuf> {
candidates
.iter()
.map(|path| (score_xbp_root(path), path.clone()))
.filter(|(score, _)| *score > 0)
.max_by(|left, right| {
left.0.cmp(&right.0).then_with(|| {
let left_windows = is_windows_drive_backed_path(&left.1);
let right_windows = is_windows_drive_backed_path(&right.1);
right_windows.cmp(&left_windows)
})
})
.map(|(_, path)| path)
}
fn score_xbp_root(root: &Path) -> u64 {
if !root.exists() {
return 0;
}
let mut score = 1u64;
if root.join("config.yaml").is_file() {
score += 100;
}
if root.join("config.yml").is_file() {
score += 80;
}
let mutations = root.join("mutations");
if mutations.is_dir() {
score += 50;
score += count_dir_entries_capped(&mutations, 40).saturating_mul(2);
}
if root.join("ssh").is_dir() {
score += 10;
}
if root.join("cache").is_dir() {
score += 5;
}
if is_windows_drive_backed_path(root) && root.join("config.yaml").is_file() {
score += 40;
}
score
}
fn count_dir_entries_capped(path: &Path, cap: u64) -> u64 {
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
let mut count = 0u64;
for _entry in entries.flatten() {
count += 1;
if count >= cap {
break;
}
}
count
}
fn is_windows_drive_backed_path(path: &Path) -> bool {
let normalized = path.to_string_lossy().replace('\\', "/");
if cfg!(windows) {
return windows_drive_letter(path).is_some();
}
let lower = normalized.to_ascii_lowercase();
lower.starts_with("/mnt/")
&& lower.len() >= 6
&& lower
.as_bytes()
.get(5)
.is_some_and(|b| b.is_ascii_alphabetic())
}
fn path_keys_equivalent(left: &Path, right: &Path) -> bool {
normalize_path_key(left) == normalize_path_key(right)
}
fn normalize_path_key(path: &Path) -> String {
let mut value = path.to_string_lossy().replace('\\', "/");
if let Some(mapped) = map_windows_path_to_wsl_mnt(&value) {
value = mapped;
} else if let Some(mapped) = map_wsl_mnt_path_to_windows(&value) {
if let Some(mnt) = map_windows_path_to_wsl_mnt(&mapped) {
value = mnt;
} else {
value = mapped;
}
}
if cfg!(windows) {
value.to_ascii_lowercase()
} else {
value
}
}
pub fn map_windows_path_to_wsl_mnt(path: &str) -> Option<String> {
let normalized = path.trim().replace('\\', "/");
let bytes = normalized.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
let drive = (bytes[0] as char).to_ascii_lowercase();
let rest = normalized.get(2..).unwrap_or("").trim_start_matches('/');
if rest.is_empty() {
return Some(format!("/mnt/{drive}"));
}
return Some(format!("/mnt/{drive}/{rest}"));
}
None
}
pub fn map_wsl_mnt_path_to_windows(path: &str) -> Option<String> {
let normalized = path.trim().replace('\\', "/");
let rest = normalized.strip_prefix("/mnt/")?;
let mut parts = rest.splitn(2, '/');
let drive = parts.next()?.chars().next()?;
if !drive.is_ascii_alphabetic() {
return None;
}
let tail = parts.next().unwrap_or("");
if tail.is_empty() {
Some(format!("{}:", drive.to_ascii_uppercase()))
} else {
Some(format!("{}:/{}", drive.to_ascii_uppercase(), tail))
}
}
#[cfg(not(target_os = "windows"))]
fn discover_windows_profile_homes_from_unix() -> Vec<PathBuf> {
let mut homes: Vec<PathBuf> = Vec::new();
let mut push_home = |path: PathBuf| {
if path.is_dir()
&& !homes
.iter()
.any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
{
homes.push(path);
}
};
for key in ["USERPROFILE", "WIN_HOME", "WINDOWS_HOME"] {
if let Ok(value) = env::var(key) {
let trimmed = value.trim();
if trimmed.is_empty() {
continue;
}
if let Some(mnt) = map_windows_path_to_wsl_mnt(trimmed) {
push_home(PathBuf::from(mnt));
}
if trimmed.starts_with("/mnt/") {
push_home(PathBuf::from(trimmed));
}
}
}
if let Some(profile) = query_windows_userprofile_via_cmd() {
if let Some(mnt) = map_windows_path_to_wsl_mnt(&profile) {
push_home(PathBuf::from(mnt));
}
}
if let Some(linux_user) = env::var_os("USER").map(|u| u.to_string_lossy().to_string()) {
for drive in b'c'..=b'z' {
let users_dir = PathBuf::from(format!("/mnt/{}/Users", drive as char));
if !users_dir.is_dir() {
continue;
}
let direct = users_dir.join(&linux_user);
if direct.is_dir() {
push_home(direct);
}
if let Ok(entries) = fs::read_dir(&users_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.eq_ignore_ascii_case(&linux_user) {
push_home(entry.path());
}
}
}
if drive == b'c' {
break;
}
}
}
homes
}
#[cfg(not(target_os = "windows"))]
fn query_windows_userprofile_via_cmd() -> Option<String> {
let is_wsl = env::var_os("WSL_DISTRO_NAME").is_some()
|| env::var_os("WSL_INTEROP").is_some()
|| fs::read_to_string("/proc/version")
.map(|content| {
let lower = content.to_ascii_lowercase();
lower.contains("microsoft") || lower.contains("wsl")
})
.unwrap_or(false);
if !is_wsl {
return None;
}
let output = std::process::Command::new("cmd.exe")
.args(["/C", "echo %USERPROFILE%"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let profile = stdout
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.eq_ignore_ascii_case("%USERPROFILE%"))?
.to_string();
if profile.is_empty() {
None
} else {
Some(profile)
}
}
#[cfg(target_os = "windows")]
fn resolve_windows_home_dir() -> Option<PathBuf> {
dirs::home_dir()
.or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
.or_else(|| {
let drive = env::var_os("HOMEDRIVE")?;
let path = env::var_os("HOMEPATH")?;
Some(PathBuf::from(drive).join(path))
})
}
fn windows_drive_letter(path: &Path) -> Option<char> {
let normalized = path.to_string_lossy().replace('/', "\\");
let bytes = normalized.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
Some((bytes[0] as char).to_ascii_uppercase())
} else {
None
}
}
#[cfg(target_os = "windows")]
fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
let normalized = path.to_string_lossy().replace('/', "\\");
let bytes = normalized.as_bytes();
if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
let tail = &normalized[3..];
if tail.is_empty() {
Some(PathBuf::new())
} else {
Some(PathBuf::from(tail))
}
} else {
None
}
}
#[cfg(target_os = "windows")]
fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
return Ok(());
};
migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
migrate_legacy_windows_file_if_missing(
&legacy_root.join("versioning-files.yaml"),
&paths.versioning_files_file,
)?;
migrate_legacy_windows_file_if_missing(
&legacy_root.join("package-name-files.yaml"),
&paths.package_name_files_file,
)?;
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "windows")]
fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
if to.exists() || !from.exists() {
return Ok(());
}
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create directory for migrated file {}: {}",
parent.display(),
e
)
})?;
}
fs::copy(from, to).map_err(|e| {
format!(
"Failed to migrate legacy config file {} -> {}: {}",
from.display(),
to.display(),
e
)
})?;
Ok(())
}
pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
let paths = global_xbp_paths()?;
Ok(vec![
("root".to_string(), paths.root_dir),
("config".to_string(), paths.config_file),
("ssh".to_string(), paths.ssh_dir),
("cache".to_string(), paths.cache_dir),
("logs".to_string(), paths.logs_dir),
("versioning".to_string(), paths.versioning_files_file),
("package-names".to_string(), paths.package_name_files_file),
("worktree-ignore".to_string(), paths.worktree_ignore_file),
])
}
pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
let paths = ensure_global_xbp_paths()?;
Ok(paths.versioning_files_file)
}
pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
let registry_path = sync_versioning_files_registry()?;
let content = fs::read_to_string(®istry_path).map_err(|e| {
format!(
"Failed to read versioning registry {}: {}",
registry_path.display(),
e
)
})?;
let config: VersioningFilesConfig = serde_yaml::from_str(&content)
.map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
Ok(config.files)
}
pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
let paths = ensure_global_xbp_paths()?;
Ok(paths.package_name_files_file)
}
pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
let registry_path = sync_package_name_files_registry()?;
let content = fs::read_to_string(®istry_path).map_err(|e| {
format!(
"Failed to read package-name registry {}: {}",
registry_path.display(),
e
)
})?;
let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
.map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
Ok(config.lookups)
}
fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
let mut config = if path.exists() {
let content = fs::read_to_string(path).map_err(|e| {
format!(
"Failed to read versioning registry {}: {}",
path.display(),
e
)
})?;
serde_yaml::from_str::<VersioningFilesConfig>(&content)
.unwrap_or_else(|_| VersioningFilesConfig::default())
} else {
VersioningFilesConfig::default()
};
let mut changed = false;
for default_file in default_versioning_files() {
if !config
.files
.iter()
.any(|existing| existing == &default_file)
{
config.files.push(default_file);
changed = true;
}
}
if changed || !path.exists() {
let content = serde_yaml::to_string(&config)
.map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
fs::write(path, content).map_err(|e| {
format!(
"Failed to write versioning registry {}: {}",
path.display(),
e
)
})?;
}
Ok(())
}
fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
let mut config = if path.exists() {
let content = fs::read_to_string(path).map_err(|e| {
format!(
"Failed to read package-name registry {}: {}",
path.display(),
e
)
})?;
serde_yaml::from_str::<PackageNameFilesConfig>(&content)
.unwrap_or_else(|_| PackageNameFilesConfig::default())
} else {
PackageNameFilesConfig::default()
};
let mut changed = false;
for default_lookup in default_package_name_lookups() {
if !config
.lookups
.iter()
.any(|existing| existing == &default_lookup)
{
config.lookups.push(default_lookup);
changed = true;
}
}
if changed || !path.exists() {
let content = serde_yaml::to_string(&config)
.map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
fs::write(path, content).map_err(|e| {
format!(
"Failed to write package-name registry {}: {}",
path.display(),
e
)
})?;
}
Ok(())
}
fn default_versioning_files() -> Vec<String> {
vec![
"README.md".to_string(),
"openapi.yaml".to_string(),
"openapi.yml".to_string(),
"openapi.json".to_string(),
"package.json".to_string(),
"package-lock.json".to_string(),
"Cargo.toml".to_string(),
"Cargo.lock".to_string(),
"pyproject.toml".to_string(),
"composer.json".to_string(),
"deno.json".to_string(),
"deno.jsonc".to_string(),
"Chart.yaml".to_string(),
"app.json".to_string(),
"manifest.json".to_string(),
"pom.xml".to_string(),
"build.gradle".to_string(),
"build.gradle.kts".to_string(),
"mix.exs".to_string(),
"xbp.toml".to_string(),
"xbp.jsonc".to_string(),
"xbp.yaml".to_string(),
"xbp.yml".to_string(),
"xbp.json".to_string(),
".xbp/xbp.toml".to_string(),
".xbp/xbp.jsonc".to_string(),
".xbp/xbp.json".to_string(),
".xbp/xbp.yaml".to_string(),
".xbp/xbp.yml".to_string(),
]
}
fn default_package_name_lookups() -> Vec<PackageNameLookup> {
vec![
PackageNameLookup {
file: "package.json".to_string(),
format: "json".to_string(),
key: "name".to_string(),
registry: "npm".to_string(),
},
PackageNameLookup {
file: "Cargo.toml".to_string(),
format: "toml".to_string(),
key: "package.name".to_string(),
registry: "crates.io".to_string(),
},
]
}
const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
#[derive(Debug, Clone)]
pub struct ApiConfig {
base_url: String,
}
impl ApiConfig {
pub fn load() -> Self {
let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
Self::from_base_url(&raw_url)
}
pub fn from_env() -> Self {
Self::load()
}
pub fn from_base_url(raw_url: &str) -> Self {
let base_url = Self::normalize_base_url(raw_url);
ApiConfig { base_url }
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn version_endpoint(&self, project_name: &str) -> String {
format!("{}/version?project_name={}", self.base_url, project_name)
}
pub fn increment_endpoint(&self) -> String {
format!("{}/version/increment", self.base_url)
}
pub fn web_base_url(&self) -> String {
Self::derive_web_base_url(&self.base_url)
}
pub fn cli_auth_request_endpoint(&self) -> String {
format!("{}/api/cli/auth/request", self.web_base_url())
}
pub fn cli_auth_poll_endpoint(&self) -> String {
format!("{}/api/cli/auth/poll", self.web_base_url())
}
pub fn cli_auth_session_endpoint(&self) -> String {
format!("{}/api/cli/auth/session", self.web_base_url())
}
pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
format!("{}/cli/login/{}", self.web_base_url(), flow_id)
}
pub fn cli_linear_key_endpoint(&self) -> String {
format!("{}/api/cli/linear/key", self.web_base_url())
}
pub fn cli_cloudflare_credentials_endpoint(&self) -> String {
format!("{}/api/cli/cloudflare/credentials", self.web_base_url())
}
pub fn cloudflare_settings_url(&self) -> String {
format!("{}/settings/cloudflare", self.web_base_url())
}
pub fn cli_version_activity_endpoint(&self) -> String {
format!("{}/api/cli/version/activity", self.web_base_url())
}
pub fn cli_error_report_endpoint(&self) -> String {
format!("{}/api/cli/errors/report", self.web_base_url())
}
pub fn cli_deploy_activity_endpoint(&self) -> String {
format!("{}/api/cli/deploy/activity", self.web_base_url())
}
pub fn cli_deploy_secrets_endpoint(&self) -> String {
format!("{}/api/cli/deploy/secrets", self.web_base_url())
}
pub fn cli_command_activity_endpoint(&self) -> String {
format!("{}/api/cli/command/activity", self.web_base_url())
}
pub fn cli_cursor_ingest_endpoint(&self) -> String {
format!("{}/api/cli/cursor/ingest", self.web_base_url())
}
pub fn cli_projects_register_endpoint(&self) -> String {
format!("{}/api/cli/projects/register", self.web_base_url())
}
pub fn cli_worktree_mutations_endpoint(&self) -> String {
format!("{}/api/cli/worktree-mutations/ingest", self.web_base_url())
}
pub fn cli_secrets_sync_endpoint(&self) -> String {
format!("{}/api/cli/secrets/sync", self.web_base_url())
}
pub fn cli_secrets_list_endpoint(&self) -> String {
format!("{}/api/cli/secrets", self.web_base_url())
}
pub fn cli_openapi_upload_endpoint(&self) -> String {
format!("{}/api/cli/openapi/upload", self.web_base_url())
}
pub fn cli_openapi_list_endpoint(&self) -> String {
format!("{}/api/cli/openapi", self.web_base_url())
}
fn normalize_base_url(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return DEFAULT_API_XBP_URL.to_string();
}
let trimmed = trimmed.trim_end_matches('/');
if trimmed.is_empty() {
return DEFAULT_API_XBP_URL.to_string();
}
trimmed.to_string()
}
fn derive_web_base_url(base_url: &str) -> String {
let Ok(mut url) = Url::parse(base_url) else {
return base_url.to_string();
};
if let Some(host) = url.host_str().map(str::to_string) {
if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
let _ = url.set_host(Some("xbp.app"));
} else if let Some(stripped) = host.strip_prefix("api.") {
let _ = url.set_host(Some(stripped));
}
}
url.set_path("");
url.set_query(None);
url.set_fragment(None);
url.to_string().trim_end_matches('/').to_string()
}
}
#[cfg(test)]
mod tests {
use super::{
classify_cloudflare_token, cloudflare_account_id_from_project_env,
cloudflare_token_kind_label, default_package_name_lookups, default_versioning_files,
format_cloudflare_token_preview, resolve_cloudflare_account_id, resolve_github_oauth2_key,
resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
CloudflareTokenKind, OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig,
VersioningFilesConfig,
};
use crate::openrouter::{
DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
};
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
}
#[test]
fn versioning_registry_defaults_include_core_files() {
let defaults = default_versioning_files();
assert!(defaults.contains(&"README.md".to_string()));
assert!(defaults.contains(&"Cargo.toml".to_string()));
assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
}
#[test]
fn versioning_registry_default_config_populates_files() {
let config = VersioningFilesConfig::default();
assert!(!config.files.is_empty());
}
#[test]
fn versioning_registry_defaults_do_not_contain_duplicates() {
let defaults = default_versioning_files();
let mut deduped = defaults.clone();
deduped.sort();
deduped.dedup();
assert_eq!(defaults.len(), deduped.len());
}
#[test]
fn syncing_registry_creates_file_with_defaults() {
let path = temp_path("defaults");
sync_versioning_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("README.md"));
assert!(content.contains("Cargo.toml"));
let _ = fs::remove_file(path);
}
#[test]
fn syncing_registry_preserves_user_added_entries() {
let path = temp_path("preserve");
fs::write(&path, "files:\n - custom.file\n").expect("write registry");
sync_versioning_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("custom.file"));
assert!(content.contains("README.md"));
let _ = fs::remove_file(path);
}
#[test]
fn classifies_cloudflare_scannable_token_prefixes() {
assert_eq!(
classify_cloudflare_token("cfut_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
CloudflareTokenKind::User
);
assert_eq!(
classify_cloudflare_token("cfat_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
CloudflareTokenKind::Account
);
assert_eq!(
classify_cloudflare_token("cfk_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
CloudflareTokenKind::GlobalKey
);
assert_eq!(
classify_cloudflare_token("a".repeat(40).as_str()),
CloudflareTokenKind::Legacy
);
assert_eq!(classify_cloudflare_token(" "), CloudflareTokenKind::Empty);
assert_eq!(
cloudflare_token_kind_label(CloudflareTokenKind::Account),
"account-owned token (cfat_)"
);
let preview = format_cloudflare_token_preview(
"cfat_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
);
assert!(preview.contains("account-owned"), "{preview}");
assert!(preview.starts_with("cfat"), "{preview}");
}
#[test]
fn stores_user_and_account_cloudflare_tokens_in_separate_slots() {
use super::CloudflareTokenSlot;
let user = "cfut_abcdefghijklmnopqrstuvwxyz0123456789USER";
let account = "cfat_abcdefghijklmnopqrstuvwxyz0123456789ACCT";
let mut cfg = SshConfig::new();
let r1 = cfg
.store_cloudflare_token(user, CloudflareTokenSlot::Auto)
.expect("store user");
assert_eq!(r1.kind, CloudflareTokenKind::User);
assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
assert_eq!(cfg.cloudflare_primary_api_token(), Some(user));
let r2 = cfg
.store_cloudflare_token(account, CloudflareTokenSlot::Auto)
.expect("store account");
assert_eq!(r2.kind, CloudflareTokenKind::Account);
assert_eq!(cfg.cloudflare_account_api_token(), Some(account));
assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
assert_eq!(cfg.cloudflare_primary_api_token(), Some(user));
let entries = cfg.cloudflare_config_token_entries();
assert!(
entries.iter().any(|(s, t)| s.contains("user") && t == user),
"{entries:?}"
);
assert!(
entries
.iter()
.any(|(s, t)| s.contains("account") && t == account),
"{entries:?}"
);
let cleared = cfg.clear_cloudflare_token_slot(CloudflareTokenSlot::Account);
assert!(cleared.contains(&"cloudflare_account_api_token"));
assert!(cfg.cloudflare_account_api_token().is_none());
assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
}
#[test]
fn api_config_normalizes_trailing_slashes() {
assert_eq!(
ApiConfig::normalize_base_url("https://api.xbp.app///"),
"https://api.xbp.app".to_string()
);
}
#[test]
fn api_config_uses_default_for_blank_values() {
assert_eq!(
ApiConfig::normalize_base_url(" "),
"https://api.xbp.app".to_string()
);
}
#[test]
fn api_config_builds_version_endpoints() {
let config = ApiConfig {
base_url: "https://api.test.xbp".to_string(),
};
let endpoint = config.version_endpoint("demo");
let increment = config.increment_endpoint();
assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
assert_eq!(increment, "https://api.test.xbp/version/increment");
}
#[test]
fn api_config_exposes_cli_projects_register_endpoint() {
let config = ApiConfig {
base_url: "https://api.xbp.app".to_string(),
};
assert_eq!(
config.cli_projects_register_endpoint(),
"https://xbp.app/api/cli/projects/register"
);
}
#[test]
fn api_config_derives_browser_base_url_from_api_subdomain() {
assert_eq!(
ApiConfig::derive_web_base_url("https://api.xbp.app"),
"https://xbp.app".to_string()
);
assert_eq!(
ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
"https://xbp.app".to_string()
);
assert_eq!(
ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
"https://xbp.app".to_string()
);
assert_eq!(
ApiConfig::derive_web_base_url("https://api.internal.example.com"),
"https://internal.example.com".to_string()
);
assert_eq!(
ApiConfig::derive_web_base_url("http://localhost:3000"),
"http://localhost:3000".to_string()
);
}
#[test]
fn package_lookup_defaults_include_npm_and_crates() {
let defaults = default_package_name_lookups();
assert!(defaults.iter().any(|entry| {
entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
}));
assert!(defaults.iter().any(|entry| {
entry.file == "Cargo.toml"
&& entry.registry == "crates.io"
&& entry.key == "package.name"
}));
}
#[test]
fn package_lookup_default_config_populates_entries() {
let config = PackageNameFilesConfig::default();
assert!(!config.lookups.is_empty());
}
#[test]
fn syncing_package_lookup_registry_creates_defaults() {
let path = temp_path("package-lookup-defaults");
sync_package_name_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("package.json"));
assert!(content.contains("Cargo.toml"));
let _ = fs::remove_file(path);
}
#[test]
fn syncing_package_lookup_registry_preserves_custom_entries() {
let path = temp_path("package-lookup-custom");
fs::write(
&path,
"lookups:\n - file: custom.yaml\n format: yaml\n key: app.name\n registry: npm\n",
)
.expect("write package lookup registry");
sync_package_name_files_registry_at(&path).expect("sync");
let content = fs::read_to_string(&path).expect("read");
assert!(content.contains("custom.yaml"));
assert!(content.contains("package.json"));
let _ = fs::remove_file(path);
}
#[test]
fn secret_provider_parses_supported_keys() {
assert_eq!(
SecretProvider::from_key("openrouter"),
Some(SecretProvider::OpenRouter)
);
assert_eq!(
SecretProvider::from_key("github"),
Some(SecretProvider::Github)
);
assert_eq!(
SecretProvider::from_key("linear"),
Some(SecretProvider::Linear)
);
assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
assert_eq!(
SecretProvider::from_key("crates"),
Some(SecretProvider::Crates)
);
assert_eq!(SecretProvider::from_key("unknown"), None);
}
#[test]
fn ssh_config_secret_get_set_roundtrip() {
let mut cfg = SshConfig::new();
cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
assert_eq!(
cfg.get_secret(SecretProvider::OpenRouter),
Some("or-test-123")
);
assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
assert_eq!(
cfg.get_secret(SecretProvider::Linear),
Some("lin_api_test_789")
);
assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
assert_eq!(
cfg.get_secret(SecretProvider::Crates),
Some("crate_test_token")
);
assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
}
#[test]
fn default_openrouter_config_populates_models_and_prompts() {
let config = OpenRouterConfig::default();
assert_eq!(config.commit_model, DEFAULT_MODEL);
assert_eq!(config.release_notes_model, DEFAULT_MODEL);
assert_eq!(
config.commit_system_prompt,
DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
);
assert_eq!(
config.release_notes_system_prompt,
DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
);
}
#[test]
fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
assert!(yaml.contains("openrouter:"));
assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
assert!(yaml.contains("commit_system_prompt"));
assert!(yaml.contains("release_notes_system_prompt"));
}
#[test]
fn ssh_config_new_uses_openrouter_defaults() {
let config = SshConfig::new();
assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
assert_eq!(
config.openrouter.commit_system_prompt,
DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
);
assert_eq!(
config.openrouter.release_notes_system_prompt,
DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
);
}
#[test]
fn syncing_global_config_backfills_openrouter_generation_defaults() {
let path = temp_path("global-config");
fs::write(
&path,
"password: null\nusername: null\nhost: null\nproject_dir: null\nxbp_api_token: null\nopenrouter_api_key: null\ngithub_oauth2_key: null\ncloudflare_api_token: null\ncloudflare_account_id: null\nlinear_api_key: null\nnpm_token: null\ncrates_token: null\nlinear: null\ncli_auth: null\nsecret_metadata: {}\n",
)
.expect("write config");
sync_global_config_defaults_at(&path).expect("sync config defaults");
let content = fs::read_to_string(&path).expect("read config");
assert!(content.contains("openrouter:"));
assert!(content.contains("commit_model: openai/gpt-4o-mini"));
assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
assert!(content.contains("commit_system_prompt"));
assert!(content.contains("release_notes_system_prompt"));
let _ = fs::remove_file(path);
}
#[test]
fn resolve_secret_prefers_environment_value() {
std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
std::env::set_var("GITHUB_TOKEN", "env-github");
std::env::set_var("XBP_API_TOKEN", "env-xbp");
assert_eq!(
resolve_openrouter_api_key(),
Some("env-openrouter".to_string())
);
assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
std::env::remove_var("OPENROUTER_API_KEY");
std::env::remove_var("GITHUB_TOKEN");
std::env::remove_var("XBP_API_TOKEN");
}
#[test]
fn resolve_cloudflare_account_id_reads_project_env_files() {
use crate::utils::CLOUDFLARE_ACCOUNT_ID_ENV_KEYS;
let dir = temp_path("cf-account-env");
fs::create_dir_all(dir.join(".xbp")).expect("mkdir");
fs::write(dir.join(".xbp/xbp.yaml"), "project_name: demo\n").expect("write xbp");
fs::write(
dir.join(".env"),
"XBP_CLOUDFLARE_ACCOUNT_ID=acc-from-project-env\n",
)
.expect("write env");
let previous = env::current_dir().expect("cwd");
env::set_current_dir(&dir).expect("chdir");
for key in CLOUDFLARE_ACCOUNT_ID_ENV_KEYS {
env::remove_var(key);
}
assert_eq!(
cloudflare_account_id_from_project_env().as_deref(),
Some("acc-from-project-env")
);
assert_eq!(
resolve_cloudflare_account_id().as_deref(),
Some("acc-from-project-env")
);
env::set_current_dir(previous).expect("restore cwd");
let _ = fs::remove_dir_all(dir);
}
}