1use crate::codetime::{
8 collect_system_inventory as collect_codetime_system_inventory, SystemInventory,
9 SystemInventoryOptions,
10};
11use crate::dns_inventory_cache::DnsInventoryCache;
12use crate::openrouter::{
13 DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
14};
15use crate::utils::{
16 find_xbp_config_upwards, first_dotenv_value_upwards, first_lookup_value, load_env_lookup,
17 load_global_worktree_ignore_from_path, sync_global_worktree_ignore_file, XbpIgnoreSet,
18 CLOUDFLARE_ACCOUNT_ID_ENV_KEYS, GLOBAL_WORKTREE_IGNORE_FILENAME,
19};
20use chrono::{DateTime, Duration as ChronoDuration, Utc};
21use reqwest::Url;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24use std::env;
25use std::fs;
26use std::path::Path;
27use std::path::PathBuf;
28use xbp_core::{RunnerExecutionRuntime, RunnerPlatform};
29
30#[derive(Debug, Clone)]
31pub struct GlobalXbpPaths {
32 pub root_dir: PathBuf,
33 pub config_file: PathBuf,
34 pub ssh_dir: PathBuf,
35 pub cache_dir: PathBuf,
36 pub logs_dir: PathBuf,
37 pub versioning_files_file: PathBuf,
38 pub package_name_files_file: PathBuf,
39 pub worktree_ignore_file: PathBuf,
40}
41
42#[derive(Debug, Clone)]
43pub struct SystemInventorySyncResult {
44 pub inventory: SystemInventory,
45 pub config_path: PathBuf,
46 pub refreshed: bool,
47}
48
49#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
50pub struct DeviceIdentity {
51 #[serde(default)]
52 pub hardware_id: String,
53 #[serde(default)]
54 pub created_at: Option<DateTime<Utc>>,
55}
56
57#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
58pub struct LinearReleaseConfig {
59 #[serde(default)]
60 pub enabled: Option<bool>,
61 #[serde(default)]
62 pub initiative_ids: Option<Vec<String>>,
63 #[serde(default, alias = "org_name")]
64 pub organization_name: Option<String>,
65 #[serde(default)]
66 pub health: Option<String>,
67}
68
69#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
70pub struct LinearConfig {
71 #[serde(default)]
72 pub release: Option<LinearReleaseConfig>,
73 #[serde(default)]
75 pub default_team_id: Option<String>,
76 #[serde(default)]
78 pub default_team_key: Option<String>,
79 #[serde(default, alias = "org_name")]
81 pub organization_name: Option<String>,
82}
83
84#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
105pub struct DiscordEventsConfig {
106 #[serde(default)]
108 pub version_release: Option<bool>,
109 #[serde(default, alias = "tag", alias = "git_tag")]
111 pub github_tag: Option<bool>,
112 #[serde(default, alias = "gh_release", alias = "release_created")]
114 pub github_release: Option<bool>,
115 #[serde(default, alias = "crates", alias = "crates-io")]
117 pub crates_io: Option<bool>,
118 #[serde(default, alias = "npmjs")]
120 pub npm: Option<bool>,
121 #[serde(default, alias = "linear_post")]
123 pub linear: Option<bool>,
124 #[serde(default, alias = "dist", alias = "cargo-dist")]
126 pub cargo_dist: Option<bool>,
127 #[serde(default, alias = "oci_registry", alias = "docker")]
129 pub oci: Option<bool>,
130 #[serde(default, alias = "workers", alias = "worker_deploy")]
132 pub workers_deploy: Option<bool>,
133 #[serde(default, alias = "k8s", alias = "kubernetes")]
135 pub kubernetes_deploy: Option<bool>,
136 #[serde(default, alias = "deployment")]
138 pub deploy: Option<bool>,
139 #[serde(default, alias = "openapi_gen", alias = "openapi_generate")]
141 pub openapi: Option<bool>,
142 #[serde(default, alias = "issue", alias = "todos", alias = "todo_issues")]
144 pub issues: Option<bool>,
145 #[serde(default, alias = "commits", alias = "git_commit")]
147 pub commit: Option<bool>,
148}
149
150impl DiscordEventsConfig {
151 pub fn is_enabled(&self, event: DiscordNotifyEvent) -> bool {
152 match event {
153 DiscordNotifyEvent::VersionRelease => self.version_release.unwrap_or(true),
154 DiscordNotifyEvent::GitHubTag => self.github_tag.unwrap_or(false),
156 DiscordNotifyEvent::GitHubRelease => self.github_release.unwrap_or(false),
157 DiscordNotifyEvent::CratesIo => self.crates_io.unwrap_or(true),
158 DiscordNotifyEvent::Npm => self.npm.unwrap_or(true),
159 DiscordNotifyEvent::Linear => self.linear.unwrap_or(true),
160 DiscordNotifyEvent::CargoDist => self.cargo_dist.unwrap_or(true),
161 DiscordNotifyEvent::Oci => self.oci.unwrap_or(true),
162 DiscordNotifyEvent::WorkersDeploy => self.workers_deploy.unwrap_or(true),
163 DiscordNotifyEvent::KubernetesDeploy => self.kubernetes_deploy.unwrap_or(true),
164 DiscordNotifyEvent::Deploy => self.deploy.unwrap_or(true),
165 DiscordNotifyEvent::OpenApi => self.openapi.unwrap_or(true),
166 DiscordNotifyEvent::Issues => self.issues.unwrap_or(true),
167 DiscordNotifyEvent::Commit => self.commit.unwrap_or(true),
168 }
169 }
170
171 pub fn set_event(&mut self, event: DiscordNotifyEvent, enabled: bool) {
172 match event {
173 DiscordNotifyEvent::VersionRelease => self.version_release = Some(enabled),
174 DiscordNotifyEvent::GitHubTag => self.github_tag = Some(enabled),
175 DiscordNotifyEvent::GitHubRelease => self.github_release = Some(enabled),
176 DiscordNotifyEvent::CratesIo => self.crates_io = Some(enabled),
177 DiscordNotifyEvent::Npm => self.npm = Some(enabled),
178 DiscordNotifyEvent::Linear => self.linear = Some(enabled),
179 DiscordNotifyEvent::CargoDist => self.cargo_dist = Some(enabled),
180 DiscordNotifyEvent::Oci => self.oci = Some(enabled),
181 DiscordNotifyEvent::WorkersDeploy => self.workers_deploy = Some(enabled),
182 DiscordNotifyEvent::KubernetesDeploy => self.kubernetes_deploy = Some(enabled),
183 DiscordNotifyEvent::Deploy => self.deploy = Some(enabled),
184 DiscordNotifyEvent::OpenApi => self.openapi = Some(enabled),
185 DiscordNotifyEvent::Issues => self.issues = Some(enabled),
186 DiscordNotifyEvent::Commit => self.commit = Some(enabled),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193pub enum DiscordNotifyEvent {
194 VersionRelease,
195 GitHubTag,
196 GitHubRelease,
197 CratesIo,
198 Npm,
199 Linear,
200 CargoDist,
201 Oci,
202 WorkersDeploy,
203 KubernetesDeploy,
204 Deploy,
205 OpenApi,
206 Issues,
207 Commit,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum DiscordEventGroup {
213 ReleasePublish,
214 Deploy,
215 Automation,
216}
217
218impl DiscordEventGroup {
219 pub fn all() -> &'static [DiscordEventGroup] {
220 &[Self::ReleasePublish, Self::Deploy, Self::Automation]
221 }
222
223 pub fn label(self) -> &'static str {
224 match self {
225 Self::ReleasePublish => "Release & publish",
226 Self::Deploy => "Deploy",
227 Self::Automation => "Automation",
228 }
229 }
230}
231
232impl DiscordNotifyEvent {
233 pub fn as_key(self) -> &'static str {
234 match self {
235 Self::VersionRelease => "version_release",
236 Self::GitHubTag => "github_tag",
237 Self::GitHubRelease => "github_release",
238 Self::CratesIo => "crates_io",
239 Self::Npm => "npm",
240 Self::Linear => "linear",
241 Self::CargoDist => "cargo_dist",
242 Self::Oci => "oci",
243 Self::WorkersDeploy => "workers_deploy",
244 Self::KubernetesDeploy => "kubernetes_deploy",
245 Self::Deploy => "deploy",
246 Self::OpenApi => "openapi",
247 Self::Issues => "issues",
248 Self::Commit => "commit",
249 }
250 }
251
252 pub fn label(self) -> &'static str {
253 match self {
254 Self::VersionRelease => "Release summary (title + notes excerpt)",
255 Self::GitHubTag => "Git tag created/pushed (intermediate)",
256 Self::GitHubRelease => "GitHub Release created (intermediate)",
257 Self::CratesIo => "crates.io publish",
258 Self::Npm => "npm publish",
259 Self::Linear => "Linear release post",
260 Self::CargoDist => "cargo-dist build/status",
261 Self::Oci => "OCI registry publish",
262 Self::WorkersDeploy => {
263 "Cloudflare Workers / Containers deploy (batched; includes wrangler + containers)"
264 }
265 Self::KubernetesDeploy => "Kubernetes deploy (batched)",
266 Self::Deploy => "Other deploy outcomes (mixed providers)",
267 Self::OpenApi => "OpenAPI generation",
268 Self::Issues => "Issues created (Linear/GitHub, grouped)",
269 Self::Commit => "xbp commit / short commit messages",
270 }
271 }
272
273 pub fn group(self) -> DiscordEventGroup {
274 match self {
275 Self::VersionRelease
276 | Self::GitHubTag
277 | Self::GitHubRelease
278 | Self::CratesIo
279 | Self::Npm
280 | Self::Linear
281 | Self::CargoDist => DiscordEventGroup::ReleasePublish,
282 Self::Oci | Self::WorkersDeploy | Self::KubernetesDeploy | Self::Deploy => {
283 DiscordEventGroup::Deploy
284 }
285 Self::OpenApi | Self::Issues | Self::Commit => DiscordEventGroup::Automation,
286 }
287 }
288
289 pub fn all() -> &'static [DiscordNotifyEvent] {
290 &[
291 Self::VersionRelease,
292 Self::GitHubTag,
293 Self::GitHubRelease,
294 Self::CratesIo,
295 Self::Npm,
296 Self::Linear,
297 Self::CargoDist,
298 Self::Oci,
299 Self::WorkersDeploy,
300 Self::KubernetesDeploy,
301 Self::Deploy,
302 Self::OpenApi,
303 Self::Issues,
304 Self::Commit,
305 ]
306 }
307
308 pub fn parse(raw: &str) -> Option<Self> {
309 match raw.trim().to_ascii_lowercase().as_str() {
310 "version_release" | "version" | "release" | "release_summary" => {
311 Some(Self::VersionRelease)
312 }
313 "github_tag" | "tag" | "git_tag" => Some(Self::GitHubTag),
314 "github_release" | "gh_release" | "release_created" => Some(Self::GitHubRelease),
315 "crates_io" | "crates" | "crates-io" | "cratesio" => Some(Self::CratesIo),
316 "npm" | "npmjs" => Some(Self::Npm),
317 "linear" | "linear_post" => Some(Self::Linear),
318 "cargo_dist" | "dist" | "cargo-dist" => Some(Self::CargoDist),
319 "oci" | "oci_registry" | "docker" | "ghcr" => Some(Self::Oci),
320 "workers_deploy"
321 | "workers"
322 | "worker_deploy"
323 | "worker"
324 | "cloudflare"
325 | "cloudflare_deploy"
326 | "containers"
327 | "cf_containers" => Some(Self::WorkersDeploy),
328 "kubernetes_deploy" | "k8s" | "kubernetes" => Some(Self::KubernetesDeploy),
329 "deploy" | "deployment" => Some(Self::Deploy),
330 "openapi" | "openapi_gen" | "openapi_generate" => Some(Self::OpenApi),
331 "issues" | "issue" | "todos" | "todo_issues" => Some(Self::Issues),
332 "commit" | "commits" | "git_commit" => Some(Self::Commit),
333 _ => None,
334 }
335 }
336}
337
338#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
343pub struct DiscordConfig {
344 #[serde(default)]
346 pub enabled: Option<bool>,
347 #[serde(default, alias = "url", alias = "webhook")]
349 pub webhook_url: Option<String>,
350 #[serde(default)]
352 pub username: Option<String>,
353 #[serde(default)]
355 pub avatar_url: Option<String>,
356 #[serde(default)]
358 pub events: Option<DiscordEventsConfig>,
359}
360
361impl DiscordConfig {
362 pub fn is_master_enabled(&self) -> bool {
363 self.enabled.unwrap_or(true)
364 }
365
366 pub fn event_enabled(&self, event: DiscordNotifyEvent) -> bool {
367 if !self.is_master_enabled() {
368 return false;
369 }
370 self.events
371 .as_ref()
372 .map(|events| events.is_enabled(event))
373 .unwrap_or(true)
374 }
375}
376
377#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
382pub struct ServiceDiscordConfig {
383 #[serde(default)]
384 pub enabled: Option<bool>,
385 #[serde(default)]
386 pub events: Option<DiscordEventsConfig>,
387}
388
389impl ServiceDiscordConfig {
390 pub fn event_enabled(&self, event: DiscordNotifyEvent, project_default: bool) -> bool {
391 if self.enabled == Some(false) {
392 return false;
393 }
394 self.events
395 .as_ref()
396 .map(|events| events.is_enabled(event))
397 .unwrap_or(project_default)
398 }
399}
400
401#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
430pub struct TodosConfig {
431 #[serde(default)]
433 pub default_to: Option<String>,
434 #[serde(default)]
436 pub auto_yes: Option<bool>,
437 #[serde(default)]
439 pub prompt_sync_after_scan: Option<bool>,
440 #[serde(default)]
442 pub linear_labels: Option<Vec<String>>,
443 #[serde(default)]
445 pub github_labels: Option<Vec<String>>,
446 #[serde(default)]
448 pub linear_assignee: Option<String>,
449 #[serde(default)]
451 pub linear_project: Option<String>,
452 #[serde(default)]
454 pub watch_paths: Option<Vec<String>>,
455 #[serde(default)]
457 pub linear_link_wait_secs: Option<u64>,
458 #[serde(default)]
460 pub linear_link_poll_ms: Option<u64>,
461 #[serde(default)]
463 pub purge_duplicate_linear: Option<bool>,
464 #[serde(default)]
466 pub kinds: Option<Vec<String>>,
467 #[serde(default)]
469 pub priority_by_kind: Option<BTreeMap<String, i32>>,
470 #[serde(default)]
472 pub annotate_source: Option<bool>,
473 #[serde(default)]
476 pub openrouter_enrich: Option<bool>,
477 #[serde(default)]
479 pub openrouter_enrich_model: Option<String>,
480}
481
482#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
517pub struct WorktreeWatchConfig {
518 #[serde(default, alias = "exclude_paths", alias = "forbidden_path")]
521 pub forbidden_paths: Vec<String>,
522 #[serde(default, alias = "exclude_folders", alias = "forbidden_folder")]
524 pub forbidden_folders: Vec<String>,
525 #[serde(default, alias = "forbidden_words", alias = "banned_word")]
527 pub banned_words: Vec<String>,
528
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub idle_after_seconds: Option<u64>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub cold_after_seconds: Option<u64>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub hot_commit_check_seconds: Option<u64>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub warm_commit_check_seconds: Option<u64>,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub cold_commit_check_seconds: Option<u64>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub max_commit_checks_per_tick: Option<usize>,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub parent_rescan_seconds: Option<u64>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub rank_persist_seconds: Option<u64>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub max_hot_repos: Option<usize>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub max_fs_watches: Option<usize>,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub spool_retention_days: Option<u64>,
564 #[serde(default, skip_serializing_if = "Vec::is_empty")]
566 pub repo_ranks: Vec<WorktreeWatchRepoRank>,
567}
568
569#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
571pub struct WorktreeWatchRepoRank {
572 pub root: String,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub owner: Option<String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub name: Option<String>,
579 #[serde(default)]
581 pub score: f64,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub last_event_at: Option<String>,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub rank: Option<u32>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub tier: Option<String>,
591}
592
593impl WorktreeWatchConfig {
594 pub fn idle_after_seconds(&self) -> u64 {
595 self.idle_after_seconds.unwrap_or(180).max(30)
596 }
597 pub fn cold_after_seconds(&self) -> u64 {
598 self.cold_after_seconds
599 .unwrap_or(600)
600 .max(self.idle_after_seconds())
601 }
602 pub fn hot_commit_check_seconds(&self) -> u64 {
603 self.hot_commit_check_seconds.unwrap_or(12).max(5)
604 }
605 pub fn warm_commit_check_seconds(&self) -> u64 {
606 self.warm_commit_check_seconds.unwrap_or(120).max(30)
607 }
608 pub fn cold_commit_check_seconds(&self) -> u64 {
609 self.cold_commit_check_seconds.unwrap_or(300).max(60)
610 }
611 pub fn parent_rescan_seconds(&self) -> u64 {
612 self.parent_rescan_seconds.unwrap_or(300).max(60)
613 }
614 pub fn rank_persist_seconds(&self) -> u64 {
615 self.rank_persist_seconds.unwrap_or(120).max(30)
616 }
617 pub fn max_hot_repos(&self) -> usize {
618 self.max_hot_repos.unwrap_or(4).max(1)
619 }
620 pub fn max_fs_watches(&self) -> usize {
622 self.max_fs_watches
623 .unwrap_or_else(|| self.max_hot_repos().saturating_add(2).max(6))
624 .max(1)
625 }
626 pub fn max_commit_checks_per_tick(&self) -> usize {
628 self.max_commit_checks_per_tick.unwrap_or(6).max(1)
629 }
630 pub fn spool_retention_days(&self) -> u64 {
631 self.spool_retention_days.unwrap_or(7).max(1)
632 }
633}
634
635#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
636pub struct SecretMetadata {
637 #[serde(default)]
638 pub added_at: Option<DateTime<Utc>>,
639}
640
641#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
642pub struct CliAuthState {
643 #[serde(default)]
644 pub user_id: Option<String>,
645 #[serde(default)]
646 pub user_name: Option<String>,
647 #[serde(default)]
648 pub user_email: Option<String>,
649 #[serde(default)]
650 pub token_label: Option<String>,
651 #[serde(default)]
652 pub token_prefix: Option<String>,
653 #[serde(default)]
654 pub token_created_at: Option<DateTime<Utc>>,
655 #[serde(default)]
656 pub token_expires_at: Option<DateTime<Utc>>,
657 #[serde(default)]
658 pub last_verified_at: Option<DateTime<Utc>>,
659}
660
661#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
662pub struct CursorIngestState {
663 #[serde(default)]
664 pub last_status: Option<String>,
665 #[serde(default)]
666 pub last_trigger: Option<String>,
667 #[serde(default)]
668 pub last_mode: Option<String>,
669 #[serde(default)]
670 pub last_attempted_at: Option<DateTime<Utc>>,
671 #[serde(default)]
672 pub last_succeeded_at: Option<DateTime<Utc>>,
673 #[serde(default)]
674 pub last_error: Option<String>,
675 #[serde(default)]
676 pub last_workspace_count: Option<usize>,
677 #[serde(default)]
678 pub last_entry_count: Option<usize>,
679 #[serde(default)]
680 pub last_entries_skipped: Option<usize>,
681}
682
683#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
684pub struct SystemInventoryRefreshState {
685 #[serde(default)]
686 pub last_status: Option<String>,
687 #[serde(default)]
688 pub last_trigger: Option<String>,
689 #[serde(default)]
690 pub last_mode: Option<String>,
691 #[serde(default)]
692 pub last_attempted_at: Option<DateTime<Utc>>,
693 #[serde(default)]
694 pub last_succeeded_at: Option<DateTime<Utc>>,
695 #[serde(default)]
696 pub last_error: Option<String>,
697 #[serde(default)]
698 pub include_cursor: Option<bool>,
699 #[serde(default)]
700 pub refreshed: Option<bool>,
701}
702
703#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
704pub struct ConfiguredRunnerHost {
705 pub runner_host_id: String,
706 #[serde(default)]
707 pub organization_id: Option<String>,
708 #[serde(default)]
709 pub hostname: Option<String>,
710 #[serde(default)]
711 pub platform: Option<RunnerPlatform>,
712 #[serde(default)]
713 pub runtime: Option<RunnerExecutionRuntime>,
714 #[serde(default)]
715 pub labels: Vec<String>,
716 #[serde(default)]
717 pub updated_at: Option<DateTime<Utc>>,
718}
719
720#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
721pub struct RunnerMachineConfig {
722 #[serde(default)]
723 pub runtime_root: Option<String>,
724 #[serde(default)]
725 pub preferred_runtime: Option<RunnerExecutionRuntime>,
726 #[serde(default)]
727 pub hosts: Vec<ConfiguredRunnerHost>,
728}
729
730#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
731pub struct OpenRouterConfig {
732 #[serde(default = "default_openrouter_commit_model")]
733 pub commit_model: String,
734 #[serde(default = "default_openrouter_release_notes_model")]
735 pub release_notes_model: String,
736 #[serde(default = "default_openrouter_commit_system_prompt")]
737 pub commit_system_prompt: String,
738 #[serde(default = "default_openrouter_release_notes_system_prompt")]
739 pub release_notes_system_prompt: String,
740}
741
742impl Default for OpenRouterConfig {
743 fn default() -> Self {
744 Self {
745 commit_model: default_openrouter_commit_model(),
746 release_notes_model: default_openrouter_release_notes_model(),
747 commit_system_prompt: default_openrouter_commit_system_prompt(),
748 release_notes_system_prompt: default_openrouter_release_notes_system_prompt(),
749 }
750 }
751}
752
753#[derive(Debug, Serialize, Deserialize, Clone)]
754pub struct SshConfig {
755 pub password: Option<String>,
756 pub username: Option<String>,
757 pub host: Option<String>,
758 pub project_dir: Option<String>,
759 #[serde(default)]
760 pub xbp_api_token: Option<String>,
761 pub openrouter_api_key: Option<String>,
762 pub github_oauth2_key: Option<String>,
763 #[serde(default)]
765 pub cloudflare_api_token: Option<String>,
766 #[serde(default)]
768 pub cloudflare_user_api_token: Option<String>,
769 #[serde(default)]
771 pub cloudflare_account_api_token: Option<String>,
772 #[serde(default)]
773 pub cloudflare_account_id: Option<String>,
774 pub linear_api_key: Option<String>,
775 #[serde(default)]
776 pub npm_token: Option<String>,
777 #[serde(default)]
778 pub crates_token: Option<String>,
779 #[serde(default)]
780 pub openrouter: OpenRouterConfig,
781 #[serde(default)]
782 pub linear: Option<LinearConfig>,
783 #[serde(default)]
784 pub cli_auth: Option<CliAuthState>,
785 #[serde(default)]
786 pub device: Option<DeviceIdentity>,
787 #[serde(default)]
788 pub secret_metadata: BTreeMap<String, SecretMetadata>,
789 #[serde(default)]
790 pub system_inventory: Option<SystemInventory>,
791 #[serde(default)]
792 pub cursor_ingest: Option<CursorIngestState>,
793 #[serde(default)]
794 pub system_inventory_refresh: Option<SystemInventoryRefreshState>,
795 #[serde(default)]
796 pub dns_inventory: Option<DnsInventoryCache>,
797 #[serde(default)]
798 pub runners: Option<RunnerMachineConfig>,
799 #[serde(default)]
801 pub worktree_watch: Option<WorktreeWatchConfig>,
802 #[serde(default)]
804 pub issues: Option<TodosConfig>,
805 #[serde(default)]
807 pub todos: Option<TodosConfig>,
808 #[serde(default)]
810 pub discord: Option<DiscordConfig>,
811 #[serde(default)]
813 pub oci: Option<GlobalOciConfig>,
814}
815
816#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
818pub struct GlobalOciConfig {
819 #[serde(default)]
820 pub registries: BTreeMap<String, GlobalOciRegistryCred>,
821}
822
823#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
824pub struct GlobalOciRegistryCred {
825 #[serde(default)]
826 pub username: Option<String>,
827 #[serde(default)]
828 pub token: Option<String>,
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
832pub enum SecretProvider {
833 OpenRouter,
834 Github,
835 Cloudflare,
836 Linear,
837 Npm,
838 Crates,
839}
840
841impl SecretProvider {
842 pub fn from_key(key: &str) -> Option<Self> {
843 match key.trim().to_ascii_lowercase().as_str() {
844 "openrouter" => Some(Self::OpenRouter),
845 "github" => Some(Self::Github),
846 "cloudflare" => Some(Self::Cloudflare),
847 "linear" => Some(Self::Linear),
848 "npm" | "npmjs" => Some(Self::Npm),
849 "crates" | "crates-io" | "cratesio" => Some(Self::Crates),
850 _ => None,
851 }
852 }
853
854 pub fn as_key(&self) -> &'static str {
855 match self {
856 SecretProvider::OpenRouter => "openrouter",
857 SecretProvider::Github => "github",
858 SecretProvider::Cloudflare => "cloudflare",
859 SecretProvider::Linear => "linear",
860 SecretProvider::Npm => "npm",
861 SecretProvider::Crates => "crates",
862 }
863 }
864
865 pub fn config_field(&self) -> &'static str {
866 match self {
867 SecretProvider::OpenRouter => "openrouter_api_key",
868 SecretProvider::Github => "github_oauth2_key",
869 SecretProvider::Cloudflare => "cloudflare_api_token",
870 SecretProvider::Linear => "linear_api_key",
871 SecretProvider::Npm => "npm_token",
872 SecretProvider::Crates => "crates_token",
873 }
874 }
875}
876
877impl Default for SshConfig {
878 fn default() -> Self {
879 Self::new()
880 }
881}
882
883impl SshConfig {
884 pub fn new() -> Self {
885 SshConfig {
886 password: None,
887 username: None,
888 host: None,
889 project_dir: None,
890 xbp_api_token: None,
891 openrouter_api_key: None,
892 github_oauth2_key: None,
893 cloudflare_api_token: None,
894 cloudflare_user_api_token: None,
895 cloudflare_account_api_token: None,
896 cloudflare_account_id: None,
897 linear_api_key: None,
898 npm_token: None,
899 crates_token: None,
900 openrouter: OpenRouterConfig::default(),
901 linear: None,
902 cli_auth: None,
903 device: None,
904 secret_metadata: BTreeMap::new(),
905 system_inventory: None,
906 cursor_ingest: None,
907 system_inventory_refresh: None,
908 dns_inventory: None,
909 runners: None,
910 worktree_watch: None,
911 issues: None,
912 todos: None,
913 discord: None,
914 oci: None,
915 }
916 }
917
918 pub fn worktree_watch_config(&self) -> WorktreeWatchConfig {
920 self.worktree_watch.clone().unwrap_or_default()
921 }
922
923 pub fn get_secret(&self, provider: SecretProvider) -> Option<&str> {
924 match provider {
925 SecretProvider::OpenRouter => self.openrouter_api_key.as_deref(),
926 SecretProvider::Github => self.github_oauth2_key.as_deref(),
927 SecretProvider::Cloudflare => self.cloudflare_api_token.as_deref(),
928 SecretProvider::Linear => self.linear_api_key.as_deref(),
929 SecretProvider::Npm => self.npm_token.as_deref(),
930 SecretProvider::Crates => self.crates_token.as_deref(),
931 }
932 }
933
934 pub fn set_secret(&mut self, provider: SecretProvider, value: Option<String>) {
935 match provider {
936 SecretProvider::OpenRouter => self.openrouter_api_key = value,
937 SecretProvider::Github => self.github_oauth2_key = value,
938 SecretProvider::Cloudflare => self.cloudflare_api_token = value,
939 SecretProvider::Linear => self.linear_api_key = value,
940 SecretProvider::Npm => self.npm_token = value,
941 SecretProvider::Crates => self.crates_token = value,
942 }
943
944 match self.get_secret(provider) {
945 Some(_) => {
946 self.secret_metadata.insert(
947 provider.as_key().to_string(),
948 SecretMetadata {
949 added_at: Some(Utc::now()),
950 },
951 );
952 }
953 None => {
954 self.secret_metadata.remove(provider.as_key());
955 }
956 }
957 }
958
959 pub fn get_secret_metadata(&self, provider: SecretProvider) -> Option<&SecretMetadata> {
960 self.secret_metadata.get(provider.as_key())
961 }
962
963 pub fn load() -> Result<Self, String> {
964 let config_path = get_config_path();
965 let legacy_path = legacy_config_path();
966 let path_to_read = if config_path.exists() {
967 config_path
968 } else if legacy_path.exists() {
969 legacy_path
970 } else {
971 return Ok(SshConfig::new());
972 };
973
974 let content = fs::read_to_string(&path_to_read)
975 .map_err(|e| format!("Failed to read config file: {}", e))?;
976 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
977 }
978
979 pub fn save(&self) -> Result<(), String> {
980 let config_path = get_config_path();
981 let config_dir = config_path.parent().ok_or("Invalid config path")?;
982 fs::create_dir_all(config_dir)
983 .map_err(|e| format!("Failed to create config directory: {}", e))?;
984
985 let content = serde_yaml::to_string(self)
986 .map_err(|e| format!("Failed to serialize config: {}", e))?;
987 fs::write(&config_path, content).map_err(|e| format!("Failed to write config file: {}", e))
988 }
989}
990
991pub fn resolve_worktree_watch_config() -> WorktreeWatchConfig {
993 SshConfig::load()
994 .ok()
995 .map(|cfg| cfg.worktree_watch_config())
996 .unwrap_or_default()
997}
998
999pub fn global_runner_root_dir() -> Result<PathBuf, String> {
1000 let paths = ensure_global_xbp_paths()?;
1001 let config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1002 if let Some(path) = config
1003 .runners
1004 .as_ref()
1005 .and_then(|runners| runners.runtime_root.as_deref())
1006 .map(str::trim)
1007 .filter(|value| !value.is_empty())
1008 {
1009 let candidate = PathBuf::from(path);
1010 fs::create_dir_all(&candidate).map_err(|error| {
1011 format!(
1012 "Failed to create runner runtime root {}: {}",
1013 candidate.display(),
1014 error
1015 )
1016 })?;
1017 return Ok(candidate);
1018 }
1019
1020 let default_root = paths.root_dir.join("github").join("runners");
1021 fs::create_dir_all(&default_root).map_err(|error| {
1022 format!(
1023 "Failed to create runner runtime root {}: {}",
1024 default_root.display(),
1025 error
1026 )
1027 })?;
1028 Ok(default_root)
1029}
1030
1031#[derive(Debug, Serialize, Deserialize, Clone)]
1032pub struct VersioningFilesConfig {
1033 #[serde(default = "default_versioning_files")]
1034 pub files: Vec<String>,
1035}
1036
1037impl Default for VersioningFilesConfig {
1038 fn default() -> Self {
1039 Self {
1040 files: default_versioning_files(),
1041 }
1042 }
1043}
1044
1045#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
1046pub struct PackageNameLookup {
1047 pub file: String,
1048 pub format: String,
1049 pub key: String,
1050 pub registry: String,
1051}
1052
1053#[derive(Debug, Serialize, Deserialize, Clone)]
1054pub struct PackageNameFilesConfig {
1055 #[serde(default = "default_package_name_lookups")]
1056 pub lookups: Vec<PackageNameLookup>,
1057}
1058
1059impl Default for PackageNameFilesConfig {
1060 fn default() -> Self {
1061 Self {
1062 lookups: default_package_name_lookups(),
1063 }
1064 }
1065}
1066
1067pub fn ensure_global_xbp_paths() -> Result<GlobalXbpPaths, String> {
1068 let root_dir = preferred_global_root_dir();
1069
1070 let paths = GlobalXbpPaths {
1071 config_file: root_dir.join("config.yaml"),
1072 ssh_dir: root_dir.join("ssh"),
1073 cache_dir: root_dir.join("cache"),
1074 logs_dir: root_dir.join("logs"),
1075 versioning_files_file: root_dir.join("versioning-files.yaml"),
1076 package_name_files_file: root_dir.join("package-name-files.yaml"),
1077 worktree_ignore_file: root_dir.join(GLOBAL_WORKTREE_IGNORE_FILENAME),
1078 root_dir,
1079 };
1080
1081 for dir in [
1082 &paths.root_dir,
1083 &paths.ssh_dir,
1084 &paths.cache_dir,
1085 &paths.logs_dir,
1086 ] {
1087 fs::create_dir_all(dir)
1088 .map_err(|e| format!("Failed to create XBP directory {}: {}", dir.display(), e))?;
1089 }
1090
1091 maybe_migrate_legacy_windows_files(&paths)?;
1092
1093 if !paths.config_file.exists() {
1094 let default_config = serde_yaml::to_string(&SshConfig::new())
1095 .map_err(|e| format!("Failed to serialize default config: {}", e))?;
1096 fs::write(&paths.config_file, default_config).map_err(|e| {
1097 format!(
1098 "Failed to initialize config file {}: {}",
1099 paths.config_file.display(),
1100 e
1101 )
1102 })?;
1103 }
1104 sync_global_config_defaults_at(&paths.config_file)?;
1105
1106 sync_versioning_files_registry_at(&paths.versioning_files_file)?;
1107 sync_package_name_files_registry_at(&paths.package_name_files_file)?;
1108 sync_global_worktree_ignore_file(&paths.worktree_ignore_file)?;
1109
1110 Ok(paths)
1111}
1112
1113pub fn load_global_worktree_ignore() -> XbpIgnoreSet {
1115 match ensure_global_xbp_paths() {
1116 Ok(paths) => load_global_worktree_ignore_from_path(&paths.worktree_ignore_file),
1117 Err(_) => XbpIgnoreSet::empty(),
1118 }
1119}
1120
1121fn default_openrouter_commit_model() -> String {
1122 DEFAULT_MODEL.to_string()
1123}
1124
1125fn default_openrouter_release_notes_model() -> String {
1126 DEFAULT_MODEL.to_string()
1127}
1128
1129fn default_openrouter_commit_system_prompt() -> String {
1130 DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
1131}
1132
1133fn default_openrouter_release_notes_system_prompt() -> String {
1134 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
1135}
1136
1137const SYSTEM_INVENTORY_REFRESH_HOURS: i64 = 24;
1138
1139fn resolve_openrouter_config_value<F>(selector: F, fallback: &str) -> String
1140where
1141 F: FnOnce(&OpenRouterConfig) -> &str,
1142{
1143 SshConfig::load()
1144 .ok()
1145 .map(|cfg| selector(&cfg.openrouter).trim().to_string())
1146 .filter(|value| !value.is_empty())
1147 .unwrap_or_else(|| fallback.to_string())
1148}
1149
1150fn sync_global_config_defaults_at(config_path: &PathBuf) -> Result<(), String> {
1151 let content = fs::read_to_string(config_path).map_err(|e| {
1152 format!(
1153 "Failed to read config file {}: {}",
1154 config_path.display(),
1155 e
1156 )
1157 })?;
1158
1159 if content.contains("openrouter:")
1160 && content.contains("commit_model:")
1161 && content.contains("release_notes_model:")
1162 && content.contains("commit_system_prompt:")
1163 && content.contains("release_notes_system_prompt:")
1164 {
1165 return Ok(());
1166 }
1167
1168 let config: SshConfig = serde_yaml::from_str(&content)
1169 .map_err(|e| format!("Failed to parse config file: {}", e))?;
1170 let normalized =
1171 serde_yaml::to_string(&config).map_err(|e| format!("Failed to serialize config: {}", e))?;
1172
1173 if normalized != content {
1174 fs::write(config_path, normalized).map_err(|e| {
1175 format!(
1176 "Failed to write config file {}: {}",
1177 config_path.display(),
1178 e
1179 )
1180 })?;
1181 }
1182
1183 Ok(())
1184}
1185
1186pub fn resolve_openrouter_api_key() -> Option<String> {
1187 env::var("OPENROUTER_API_KEY")
1188 .ok()
1189 .map(|value| value.trim().to_string())
1190 .filter(|value| !value.is_empty())
1191 .or_else(|| {
1192 SshConfig::load()
1193 .ok()
1194 .and_then(|cfg| {
1195 cfg.get_secret(SecretProvider::OpenRouter)
1196 .map(str::to_string)
1197 })
1198 .map(|value| value.trim().to_string())
1199 .filter(|value| !value.is_empty())
1200 })
1201}
1202
1203pub fn resolve_openrouter_commit_model() -> String {
1204 resolve_openrouter_config_value(|cfg| cfg.commit_model.as_str(), DEFAULT_MODEL)
1205}
1206
1207pub fn resolve_openrouter_release_notes_model() -> String {
1208 resolve_openrouter_config_value(|cfg| cfg.release_notes_model.as_str(), DEFAULT_MODEL)
1209}
1210
1211pub fn resolve_openrouter_commit_system_prompt() -> String {
1212 resolve_openrouter_config_value(
1213 |cfg| cfg.commit_system_prompt.as_str(),
1214 DEFAULT_COMMIT_SYSTEM_PROMPT,
1215 )
1216}
1217
1218pub fn resolve_openrouter_release_notes_system_prompt() -> String {
1219 resolve_openrouter_config_value(
1220 |cfg| cfg.release_notes_system_prompt.as_str(),
1221 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
1222 )
1223}
1224
1225pub fn resolve_xbp_api_token() -> Option<String> {
1226 env::var("XBP_API_TOKEN")
1227 .ok()
1228 .map(|value| value.trim().to_string())
1229 .filter(|value| !value.is_empty())
1230 .or_else(|| {
1231 SshConfig::load()
1232 .ok()
1233 .and_then(|cfg| cfg.xbp_api_token)
1234 .map(|value| value.trim().to_string())
1235 .filter(|value| !value.is_empty())
1236 })
1237}
1238
1239pub fn resolve_github_oauth2_key() -> Option<String> {
1240 for env_var in [
1241 "GITHUB_TOKEN",
1242 "GITHUB_OAUTH2_KEY",
1243 "GITHUB_OAUTH2_TOKEN",
1244 "GITHUB_OAUTH_TOKEN",
1245 ] {
1246 if let Ok(value) = env::var(env_var) {
1247 let token = value.trim();
1248 if !token.is_empty() {
1249 return Some(token.to_string());
1250 }
1251 }
1252 }
1253
1254 SshConfig::load()
1255 .ok()
1256 .and_then(|cfg| cfg.get_secret(SecretProvider::Github).map(str::to_string))
1257 .map(|value| value.trim().to_string())
1258 .filter(|value| !value.is_empty())
1259}
1260
1261pub fn resolve_cloudflare_api_token() -> Option<String> {
1262 cloudflare_api_token_candidates()
1263 .into_iter()
1264 .next()
1265 .map(|(_, token)| token)
1266}
1267
1268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1273pub enum CloudflareTokenKind {
1274 User,
1276 Account,
1278 GlobalKey,
1280 Legacy,
1282 Empty,
1284}
1285
1286pub fn classify_cloudflare_token(token: &str) -> CloudflareTokenKind {
1288 let token = token.trim();
1289 if token.is_empty() {
1290 return CloudflareTokenKind::Empty;
1291 }
1292 if token.starts_with("cfut_") {
1293 CloudflareTokenKind::User
1294 } else if token.starts_with("cfat_") {
1295 CloudflareTokenKind::Account
1296 } else if token.starts_with("cfk_") {
1297 CloudflareTokenKind::GlobalKey
1298 } else {
1299 CloudflareTokenKind::Legacy
1300 }
1301}
1302
1303pub fn cloudflare_token_kind_label(kind: CloudflareTokenKind) -> &'static str {
1305 match kind {
1306 CloudflareTokenKind::User => "user token (cfut_)",
1307 CloudflareTokenKind::Account => "account-owned token (cfat_)",
1308 CloudflareTokenKind::GlobalKey => "global API key (cfk_)",
1309 CloudflareTokenKind::Legacy => "legacy unprefixed token",
1310 CloudflareTokenKind::Empty => "missing",
1311 }
1312}
1313
1314pub fn format_cloudflare_token_preview(token: &str) -> String {
1316 let t = token.trim();
1317 if t.is_empty() {
1318 return "missing".to_string();
1319 }
1320 let kind = classify_cloudflare_token(t);
1321 let kind_hint = match kind {
1322 CloudflareTokenKind::User => "user",
1323 CloudflareTokenKind::Account => "account-owned",
1324 CloudflareTokenKind::GlobalKey => "global-key",
1325 CloudflareTokenKind::Legacy => "legacy",
1326 CloudflareTokenKind::Empty => "missing",
1327 };
1328 if t.len() <= 8 {
1329 return format!("******** ({kind_hint})");
1330 }
1331 format!(
1332 "{}…{} (len={}, {kind_hint})",
1333 &t[..4.min(t.len())],
1334 &t[t.len().saturating_sub(4)..],
1335 t.len()
1336 )
1337}
1338
1339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1341pub enum CloudflareTokenSlot {
1342 #[default]
1344 Auto,
1345 User,
1347 Account,
1349 Primary,
1351 All,
1353}
1354
1355impl CloudflareTokenSlot {
1356 pub fn parse(raw: &str) -> Result<Self, String> {
1357 match raw.trim().to_ascii_lowercase().as_str() {
1358 "auto" => Ok(Self::Auto),
1359 "user" | "cfut" | "cfut_" => Ok(Self::User),
1360 "account" | "account-owned" | "cfat" | "cfat_" => Ok(Self::Account),
1361 "primary" | "default" | "legacy" => Ok(Self::Primary),
1362 "all" => Ok(Self::All),
1363 other => Err(format!(
1364 "Unknown Cloudflare token kind `{other}`. Use auto, user, account, primary, or all."
1365 )),
1366 }
1367 }
1368
1369 pub fn as_str(self) -> &'static str {
1370 match self {
1371 Self::Auto => "auto",
1372 Self::User => "user",
1373 Self::Account => "account",
1374 Self::Primary => "primary",
1375 Self::All => "all",
1376 }
1377 }
1378}
1379
1380#[derive(Debug, Clone)]
1382pub struct CloudflareTokenStoreReport {
1383 pub kind: CloudflareTokenKind,
1384 pub slots_written: Vec<&'static str>,
1385 pub notes: Vec<String>,
1386}
1387
1388impl SshConfig {
1389 pub fn cloudflare_user_api_token(&self) -> Option<&str> {
1390 self.cloudflare_user_api_token
1391 .as_deref()
1392 .map(str::trim)
1393 .filter(|v| !v.is_empty())
1394 }
1395
1396 pub fn cloudflare_account_api_token(&self) -> Option<&str> {
1397 self.cloudflare_account_api_token
1398 .as_deref()
1399 .map(str::trim)
1400 .filter(|v| !v.is_empty())
1401 }
1402
1403 pub fn cloudflare_primary_api_token(&self) -> Option<&str> {
1404 self.cloudflare_api_token
1405 .as_deref()
1406 .map(str::trim)
1407 .filter(|v| !v.is_empty())
1408 }
1409
1410 pub fn store_cloudflare_token(
1413 &mut self,
1414 token: impl Into<String>,
1415 slot: CloudflareTokenSlot,
1416 ) -> Result<CloudflareTokenStoreReport, String> {
1417 let token = token.into().trim().to_string();
1418 if token.is_empty() {
1419 return Err("Refusing to store an empty Cloudflare API token.".into());
1420 }
1421 if matches!(slot, CloudflareTokenSlot::All) {
1422 return Err(
1423 "Cannot store into kind `all` — use auto, user, account, or primary.".into(),
1424 );
1425 }
1426
1427 let detected = classify_cloudflare_token(&token);
1428 let mut slots_written = Vec::new();
1429 let mut notes = Vec::new();
1430
1431 let target = match slot {
1432 CloudflareTokenSlot::Auto => match detected {
1433 CloudflareTokenKind::User => CloudflareTokenSlot::User,
1434 CloudflareTokenKind::Account => CloudflareTokenSlot::Account,
1435 CloudflareTokenKind::GlobalKey => {
1436 notes.push(
1437 "Global API keys (`cfk_`) are not recommended; stored in primary only."
1438 .into(),
1439 );
1440 CloudflareTokenSlot::Primary
1441 }
1442 CloudflareTokenKind::Legacy | CloudflareTokenKind::Empty => {
1443 notes.push(
1444 "Unprefixed/legacy token — stored in primary only. Prefer scannable \
1445 `cfut_` (user) or `cfat_` (account) tokens."
1446 .into(),
1447 );
1448 CloudflareTokenSlot::Primary
1449 }
1450 },
1451 other => other,
1452 };
1453
1454 if slot != CloudflareTokenSlot::Auto {
1455 match (slot, detected) {
1456 (CloudflareTokenSlot::User, CloudflareTokenKind::Account) => notes.push(
1457 "Warning: value looks like an account-owned token (`cfat_`) but was forced \
1458 into the user slot."
1459 .into(),
1460 ),
1461 (CloudflareTokenSlot::Account, CloudflareTokenKind::User) => notes.push(
1462 "Warning: value looks like a user token (`cfut_`) but was forced into the \
1463 account slot."
1464 .into(),
1465 ),
1466 _ => {}
1467 }
1468 }
1469
1470 match target {
1471 CloudflareTokenSlot::User => {
1472 self.cloudflare_user_api_token = Some(token.clone());
1473 slots_written.push("cloudflare_user_api_token");
1474 self.touch_secret_metadata("cloudflare_user");
1475 if self.should_mirror_cloudflare_primary(CloudflareTokenKind::User) {
1476 self.cloudflare_api_token = Some(token.clone());
1477 slots_written.push("cloudflare_api_token (primary)");
1478 self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
1479 notes.push(
1480 "Also set primary `cloudflare_api_token` for Wrangler/default resolve."
1481 .into(),
1482 );
1483 }
1484 if detected == CloudflareTokenKind::User {
1485 notes.push(
1486 "User token (`cfut_`) — suitable for Workers Builds and Wrangler.".into(),
1487 );
1488 }
1489 }
1490 CloudflareTokenSlot::Account => {
1491 self.cloudflare_account_api_token = Some(token.clone());
1492 slots_written.push("cloudflare_account_api_token");
1493 self.touch_secret_metadata("cloudflare_account");
1494 if self.should_mirror_cloudflare_primary(CloudflareTokenKind::Account) {
1495 self.cloudflare_api_token = Some(token.clone());
1496 slots_written.push("cloudflare_api_token (primary)");
1497 self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
1498 notes.push(
1499 "Also set primary `cloudflare_api_token` for Wrangler/default resolve."
1500 .into(),
1501 );
1502 }
1503 if detected == CloudflareTokenKind::Account {
1504 notes.push(
1505 "Account-owned token (`cfat_`) — Wrangler OK; Workers Builds needs a \
1506 separate user token (`cfut_`) in `cloudflare_user_api_token`."
1507 .into(),
1508 );
1509 }
1510 }
1511 CloudflareTokenSlot::Primary => {
1512 self.cloudflare_api_token = Some(token);
1513 slots_written.push("cloudflare_api_token (primary)");
1514 self.touch_secret_metadata(SecretProvider::Cloudflare.as_key());
1515 }
1516 CloudflareTokenSlot::Auto | CloudflareTokenSlot::All => unreachable!(),
1517 }
1518
1519 Ok(CloudflareTokenStoreReport {
1520 kind: detected,
1521 slots_written,
1522 notes,
1523 })
1524 }
1525
1526 pub fn clear_cloudflare_token_slot(&mut self, slot: CloudflareTokenSlot) -> Vec<&'static str> {
1528 let mut cleared = Vec::new();
1529 let clear_primary = || {};
1530 let _ = clear_primary;
1531 match slot {
1532 CloudflareTokenSlot::User => {
1533 if self.cloudflare_user_api_token.take().is_some() {
1534 cleared.push("cloudflare_user_api_token");
1535 self.secret_metadata.remove("cloudflare_user");
1536 }
1537 }
1538 CloudflareTokenSlot::Account => {
1539 if self.cloudflare_account_api_token.take().is_some() {
1540 cleared.push("cloudflare_account_api_token");
1541 self.secret_metadata.remove("cloudflare_account");
1542 }
1543 }
1544 CloudflareTokenSlot::Primary => {
1545 if self.cloudflare_api_token.take().is_some() {
1546 cleared.push("cloudflare_api_token");
1547 self.secret_metadata.remove(SecretProvider::Cloudflare.as_key());
1548 }
1549 }
1550 CloudflareTokenSlot::Auto | CloudflareTokenSlot::All => {
1551 if self.cloudflare_user_api_token.take().is_some() {
1552 cleared.push("cloudflare_user_api_token");
1553 self.secret_metadata.remove("cloudflare_user");
1554 }
1555 if self.cloudflare_account_api_token.take().is_some() {
1556 cleared.push("cloudflare_account_api_token");
1557 self.secret_metadata.remove("cloudflare_account");
1558 }
1559 if self.cloudflare_api_token.take().is_some() {
1560 cleared.push("cloudflare_api_token");
1561 self.secret_metadata.remove(SecretProvider::Cloudflare.as_key());
1562 }
1563 }
1564 }
1565 cleared
1566 }
1567
1568 fn should_mirror_cloudflare_primary(&self, writing: CloudflareTokenKind) -> bool {
1569 match self.cloudflare_primary_api_token() {
1570 None => true,
1571 Some(primary) => match writing {
1572 CloudflareTokenKind::User => true,
1574 CloudflareTokenKind::Account => matches!(
1575 classify_cloudflare_token(primary),
1576 CloudflareTokenKind::Account | CloudflareTokenKind::Empty
1577 ),
1578 _ => classify_cloudflare_token(primary) == writing,
1579 },
1580 }
1581 }
1582
1583 fn touch_secret_metadata(&mut self, key: &str) {
1584 self.secret_metadata.insert(
1585 key.to_string(),
1586 SecretMetadata {
1587 added_at: Some(Utc::now()),
1588 },
1589 );
1590 }
1591
1592 pub fn cloudflare_config_token_entries(&self) -> Vec<(String, String)> {
1594 let mut out = Vec::new();
1595 if let Some(t) = self.cloudflare_user_api_token() {
1596 out.push((
1597 "global cloudflare_user_api_token (user)".into(),
1598 t.to_string(),
1599 ));
1600 }
1601 if let Some(t) = self.cloudflare_primary_api_token() {
1602 let kind = cloudflare_token_kind_label(classify_cloudflare_token(t));
1603 out.push((
1604 format!("global cloudflare_api_token (primary, {kind})"),
1605 t.to_string(),
1606 ));
1607 }
1608 if let Some(t) = self.cloudflare_account_api_token() {
1609 out.push((
1610 "global cloudflare_account_api_token (account-owned)".into(),
1611 t.to_string(),
1612 ));
1613 }
1614 out
1615 }
1616}
1617
1618pub fn cloudflare_api_token_candidates() -> Vec<(String, String)> {
1623 cloudflare_api_token_candidates_ordered(false)
1624}
1625
1626pub fn cloudflare_api_token_candidates_builds_first() -> Vec<(String, String)> {
1629 cloudflare_api_token_candidates_ordered(true)
1630}
1631
1632fn cloudflare_api_token_candidates_ordered(prefer_user_for_builds: bool) -> Vec<(String, String)> {
1633 let mut out = Vec::new();
1634 let mut seen = std::collections::BTreeSet::new();
1635
1636 let push = |out: &mut Vec<(String, String)>,
1637 seen: &mut std::collections::BTreeSet<String>,
1638 source: &str,
1639 token: String| {
1640 let token = token.trim().to_string();
1641 if token.is_empty() || !seen.insert(token.clone()) {
1642 return;
1643 }
1644 out.push((source.to_string(), token));
1645 };
1646
1647 if let Ok(value) = env::var("CLOUDFLARE_API_TOKEN") {
1649 push(
1650 &mut out,
1651 &mut seen,
1652 "process env CLOUDFLARE_API_TOKEN",
1653 value,
1654 );
1655 }
1656 if let Ok(value) = env::var("CLOUDFLARE_USER_API_TOKEN") {
1657 push(
1658 &mut out,
1659 &mut seen,
1660 "process env CLOUDFLARE_USER_API_TOKEN",
1661 value,
1662 );
1663 }
1664 if let Ok(value) = env::var("CLOUDFLARE_ACCOUNT_API_TOKEN") {
1665 push(
1666 &mut out,
1667 &mut seen,
1668 "process env CLOUDFLARE_ACCOUNT_API_TOKEN",
1669 value,
1670 );
1671 }
1672
1673 if let Ok(cfg) = SshConfig::load() {
1674 for (source, token) in cfg.cloudflare_config_token_entries() {
1675 push(&mut out, &mut seen, &source, token);
1676 }
1677 }
1678
1679 if prefer_user_for_builds {
1680 out.sort_by_key(|(_, token)| {
1681 match classify_cloudflare_token(token) {
1683 CloudflareTokenKind::User => 0u8,
1684 CloudflareTokenKind::Legacy => 1,
1685 CloudflareTokenKind::Empty => 2,
1686 CloudflareTokenKind::Account => 3,
1687 CloudflareTokenKind::GlobalKey => 4,
1688 }
1689 });
1690 }
1691
1692 out
1693}
1694
1695pub fn cloudflare_env_token_shadows_config() -> bool {
1697 let env_tok = env::var("CLOUDFLARE_API_TOKEN")
1698 .ok()
1699 .map(|v| v.trim().to_string())
1700 .filter(|v| !v.is_empty());
1701 let cfg_tok = SshConfig::load().ok().and_then(|cfg| {
1702 cfg.cloudflare_primary_api_token()
1703 .or_else(|| cfg.cloudflare_user_api_token())
1704 .or_else(|| cfg.cloudflare_account_api_token())
1705 .map(str::to_string)
1706 });
1707 match (env_tok, cfg_tok) {
1708 (Some(a), Some(b)) => a != b,
1709 _ => false,
1710 }
1711}
1712
1713pub fn cloudflare_account_id_from_process_env() -> Option<String> {
1714 CLOUDFLARE_ACCOUNT_ID_ENV_KEYS.iter().find_map(|key| {
1715 env::var(key)
1716 .ok()
1717 .map(|value| value.trim().to_string())
1718 .filter(|value| !value.is_empty())
1719 })
1720}
1721
1722pub fn cloudflare_account_id_from_project_env() -> Option<String> {
1723 let current_dir = env::current_dir().ok()?;
1724 let found = find_xbp_config_upwards(¤t_dir)?;
1725 let lookup = load_env_lookup(&found.project_root);
1726 first_lookup_value(&lookup, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS)
1727}
1728
1729pub fn cloudflare_account_id_from_global_config() -> Option<String> {
1730 SshConfig::load()
1731 .ok()
1732 .and_then(|cfg| cfg.cloudflare_account_id)
1733 .map(|value| value.trim().to_string())
1734 .filter(|value| !value.is_empty())
1735}
1736
1737pub fn resolve_cloudflare_account_id() -> Option<String> {
1738 cloudflare_account_id_from_process_env()
1739 .or_else(cloudflare_account_id_from_project_env)
1740 .or_else(cloudflare_account_id_from_global_config)
1741}
1742
1743pub fn resolve_linear_api_key() -> Option<String> {
1744 SshConfig::load()
1745 .ok()
1746 .and_then(|cfg| cfg.get_secret(SecretProvider::Linear).map(str::to_string))
1747 .map(|value| value.trim().to_string())
1748 .filter(|value| !value.is_empty())
1749}
1750
1751pub const NPM_TOKEN_ENV_KEYS: &[&str] = &["NPM_TOKEN", "NODE_AUTH_TOKEN", "NPM_CONFIG_TOKEN"];
1753
1754pub const CRATES_TOKEN_ENV_KEYS: &[&str] = &["CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN"];
1756
1757const REGISTRY_TOKEN_DOTENV_MAX_DEPTH: usize = 12;
1759
1760pub fn resolve_npm_token() -> Option<String> {
1771 let start = registry_token_search_start();
1772 resolve_npm_token_from(start.as_deref())
1773}
1774
1775pub fn resolve_npm_token_from(start_dir: Option<&Path>) -> Option<String> {
1778 token_from_process_env(NPM_TOKEN_ENV_KEYS)
1779 .or_else(|| {
1780 start_dir.and_then(|dir| {
1781 first_dotenv_value_upwards(dir, NPM_TOKEN_ENV_KEYS, REGISTRY_TOKEN_DOTENV_MAX_DEPTH)
1782 .map(|(value, _)| value)
1783 })
1784 })
1785 .or_else(npm_token_from_global_config)
1786}
1787
1788pub fn resolve_crates_token() -> Option<String> {
1789 let start = registry_token_search_start();
1790 resolve_crates_token_from(start.as_deref())
1791}
1792
1793pub fn resolve_crates_token_from(start_dir: Option<&Path>) -> Option<String> {
1794 token_from_process_env(CRATES_TOKEN_ENV_KEYS)
1795 .or_else(|| {
1796 start_dir.and_then(|dir| {
1797 first_dotenv_value_upwards(
1798 dir,
1799 CRATES_TOKEN_ENV_KEYS,
1800 REGISTRY_TOKEN_DOTENV_MAX_DEPTH,
1801 )
1802 .map(|(value, _)| value)
1803 })
1804 })
1805 .or_else(crates_token_from_global_config)
1806}
1807
1808fn registry_token_search_start() -> Option<PathBuf> {
1809 let cwd = env::current_dir().ok()?;
1810 find_xbp_config_upwards(&cwd)
1811 .map(|found| found.project_root)
1812 .or(Some(cwd))
1813}
1814
1815fn token_from_process_env(keys: &[&str]) -> Option<String> {
1816 keys.iter().find_map(|key| {
1817 env::var(key)
1818 .ok()
1819 .map(|value| value.trim().to_string())
1820 .filter(|value| !value.is_empty())
1821 })
1822}
1823
1824fn npm_token_from_global_config() -> Option<String> {
1825 SshConfig::load()
1826 .ok()
1827 .and_then(|cfg| cfg.get_secret(SecretProvider::Npm).map(str::to_string))
1828 .map(|value| value.trim().to_string())
1829 .filter(|value| !value.is_empty())
1830}
1831
1832fn crates_token_from_global_config() -> Option<String> {
1833 SshConfig::load()
1834 .ok()
1835 .and_then(|cfg| cfg.get_secret(SecretProvider::Crates).map(str::to_string))
1836 .map(|value| value.trim().to_string())
1837 .filter(|value| !value.is_empty())
1838}
1839
1840pub fn mask_registry_token(token: &str) -> String {
1842 let trimmed = token.trim();
1843 if trimmed.len() <= 8 {
1844 return "***".to_string();
1845 }
1846 let prefix_len = trimmed
1847 .find('_')
1848 .map(|idx| (idx + 1).min(6))
1849 .unwrap_or(4)
1850 .min(trimmed.len() / 2);
1851 let prefix = &trimmed[..prefix_len];
1852 let suffix = &trimmed[trimmed.len().saturating_sub(4)..];
1853 format!("{prefix}…{suffix}")
1854}
1855
1856pub fn describe_npm_token_sources(start_dir: Option<&Path>) -> Vec<String> {
1858 let mut lines = Vec::new();
1859 for key in NPM_TOKEN_ENV_KEYS {
1860 match env::var(key) {
1861 Ok(value) if !value.trim().is_empty() => {
1862 lines.push(format!(
1863 "process env {key}: set ({})",
1864 mask_registry_token(value.trim())
1865 ));
1866 }
1867 _ => lines.push(format!("process env {key}: unset")),
1868 }
1869 }
1870 if let Some(dir) = start_dir {
1871 match first_dotenv_value_upwards(dir, NPM_TOKEN_ENV_KEYS, REGISTRY_TOKEN_DOTENV_MAX_DEPTH)
1872 {
1873 Some((value, path)) => lines.push(format!(
1874 "dotenv {}: set ({})",
1875 path.display(),
1876 mask_registry_token(&value)
1877 )),
1878 None => lines.push(format!(
1879 "dotenv under {}: no NPM_TOKEN / NODE_AUTH_TOKEN / NPM_CONFIG_TOKEN",
1880 dir.display()
1881 )),
1882 }
1883 }
1884 match npm_token_from_global_config() {
1885 Some(value) => lines.push(format!(
1886 "global xbp config (npm_token): set ({})",
1887 mask_registry_token(&value)
1888 )),
1889 None => lines.push("global xbp config (npm_token): unset".to_string()),
1890 }
1891 lines
1892}
1893
1894pub fn set_cloudflare_account_id(value: Option<String>) -> Result<(), String> {
1895 let mut config = SshConfig::load()?;
1896 config.cloudflare_account_id = value;
1897 config.save()
1898}
1899
1900pub fn get_cloudflare_account_id() -> Result<Option<String>, String> {
1901 Ok(SshConfig::load()?.cloudflare_account_id)
1902}
1903
1904pub fn resolve_global_linear_release_config() -> Option<LinearReleaseConfig> {
1905 SshConfig::load()
1906 .ok()
1907 .and_then(|cfg| cfg.linear.and_then(|linear| linear.release))
1908}
1909
1910pub fn resolve_device_identity() -> Result<DeviceIdentity, String> {
1911 ensure_global_xbp_paths()?;
1912 let mut config = SshConfig::load()?;
1913 if let Some(device) = config.device.clone() {
1914 if !device.hardware_id.trim().is_empty() {
1915 return Ok(device);
1916 }
1917 }
1918
1919 let hardware_id = format!("xbp_hw_{}", uuid::Uuid::new_v4());
1920 let device = DeviceIdentity {
1921 hardware_id,
1922 created_at: Some(Utc::now()),
1923 };
1924 config.device = Some(device.clone());
1925 config.save()?;
1926 Ok(device)
1927}
1928
1929pub fn reserve_cursor_ingest_slot(
1930 trigger: &str,
1931 mode: &str,
1932 min_interval: ChronoDuration,
1933) -> Result<bool, String> {
1934 ensure_global_xbp_paths()?;
1935 let mut config = SshConfig::load()?;
1936 let now = Utc::now();
1937
1938 if let Some(state) = config.cursor_ingest.as_ref() {
1939 if let Some(last_attempted_at) = state.last_attempted_at {
1940 if now - last_attempted_at < min_interval {
1941 return Ok(false);
1942 }
1943 }
1944 }
1945
1946 let mut state = config.cursor_ingest.unwrap_or_default();
1947 state.last_status = Some("scheduled".to_string());
1948 state.last_trigger = Some(trigger.to_string());
1949 state.last_mode = Some(mode.to_string());
1950 state.last_attempted_at = Some(now);
1951 state.last_error = None;
1952 config.cursor_ingest = Some(state);
1953 config.save()?;
1954 Ok(true)
1955}
1956
1957pub fn record_cursor_ingest_started(trigger: &str, mode: &str) -> Result<(), String> {
1958 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1959 let mut state = config.cursor_ingest.unwrap_or_default();
1960 state.last_status = Some("running".to_string());
1961 state.last_trigger = Some(trigger.to_string());
1962 state.last_mode = Some(mode.to_string());
1963 state.last_attempted_at = Some(Utc::now());
1964 state.last_error = None;
1965 config.cursor_ingest = Some(state);
1966 config.save()
1967}
1968
1969pub fn record_cursor_ingest_success(
1970 trigger: &str,
1971 mode: &str,
1972 workspace_count: usize,
1973 entry_count: usize,
1974 entries_skipped: usize,
1975) -> Result<(), String> {
1976 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1977 let mut state = config.cursor_ingest.unwrap_or_default();
1978 let completed_at = Utc::now();
1979 state.last_status = Some("success".to_string());
1980 state.last_trigger = Some(trigger.to_string());
1981 state.last_mode = Some(mode.to_string());
1982 state.last_attempted_at = Some(completed_at);
1983 state.last_succeeded_at = Some(completed_at);
1984 state.last_error = None;
1985 state.last_workspace_count = Some(workspace_count);
1986 state.last_entry_count = Some(entry_count);
1987 state.last_entries_skipped = Some(entries_skipped);
1988 config.cursor_ingest = Some(state);
1989 config.save()
1990}
1991
1992pub fn record_cursor_ingest_failure(trigger: &str, mode: &str, error: &str) -> Result<(), String> {
1993 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
1994 let mut state = config.cursor_ingest.unwrap_or_default();
1995 state.last_status = Some("failed".to_string());
1996 state.last_trigger = Some(trigger.to_string());
1997 state.last_mode = Some(mode.to_string());
1998 state.last_attempted_at = Some(Utc::now());
1999 state.last_error = Some(error.chars().take(400).collect());
2000 config.cursor_ingest = Some(state);
2001 config.save()
2002}
2003
2004pub fn reserve_system_inventory_refresh_slot(
2005 trigger: &str,
2006 mode: &str,
2007 include_cursor: bool,
2008 min_interval: ChronoDuration,
2009) -> Result<bool, String> {
2010 ensure_global_xbp_paths()?;
2011 let mut config = SshConfig::load()?;
2012 let now = Utc::now();
2013
2014 if let Some(existing) = config.system_inventory.as_ref() {
2015 if !system_inventory_needs_refresh(existing, include_cursor) {
2016 return Ok(false);
2017 }
2018 }
2019
2020 if let Some(state) = config.system_inventory_refresh.as_ref() {
2021 if let Some(last_attempted_at) = state.last_attempted_at {
2022 if now - last_attempted_at < min_interval {
2023 return Ok(false);
2024 }
2025 }
2026 }
2027
2028 let mut state = config.system_inventory_refresh.unwrap_or_default();
2029 state.last_status = Some("scheduled".to_string());
2030 state.last_trigger = Some(trigger.to_string());
2031 state.last_mode = Some(mode.to_string());
2032 state.last_attempted_at = Some(now);
2033 state.last_error = None;
2034 state.include_cursor = Some(include_cursor);
2035 config.system_inventory_refresh = Some(state);
2036 config.save()?;
2037 Ok(true)
2038}
2039
2040pub fn record_system_inventory_refresh_started(
2041 trigger: &str,
2042 mode: &str,
2043 include_cursor: bool,
2044) -> Result<(), String> {
2045 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
2046 let mut state = config.system_inventory_refresh.unwrap_or_default();
2047 state.last_status = Some("running".to_string());
2048 state.last_trigger = Some(trigger.to_string());
2049 state.last_mode = Some(mode.to_string());
2050 state.last_attempted_at = Some(Utc::now());
2051 state.last_error = None;
2052 state.include_cursor = Some(include_cursor);
2053 config.system_inventory_refresh = Some(state);
2054 config.save()
2055}
2056
2057pub fn record_system_inventory_refresh_success(
2058 trigger: &str,
2059 mode: &str,
2060 include_cursor: bool,
2061 refreshed: bool,
2062) -> Result<(), String> {
2063 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
2064 let mut state = config.system_inventory_refresh.unwrap_or_default();
2065 let completed_at = Utc::now();
2066 state.last_status = Some("success".to_string());
2067 state.last_trigger = Some(trigger.to_string());
2068 state.last_mode = Some(mode.to_string());
2069 state.last_attempted_at = Some(completed_at);
2070 state.last_succeeded_at = Some(completed_at);
2071 state.last_error = None;
2072 state.include_cursor = Some(include_cursor);
2073 state.refreshed = Some(refreshed);
2074 config.system_inventory_refresh = Some(state);
2075 config.save()
2076}
2077
2078pub fn record_system_inventory_refresh_failure(
2079 trigger: &str,
2080 mode: &str,
2081 include_cursor: bool,
2082 error: &str,
2083) -> Result<(), String> {
2084 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
2085 let mut state = config.system_inventory_refresh.unwrap_or_default();
2086 state.last_status = Some("failed".to_string());
2087 state.last_trigger = Some(trigger.to_string());
2088 state.last_mode = Some(mode.to_string());
2089 state.last_attempted_at = Some(Utc::now());
2090 state.last_error = Some(error.chars().take(400).collect());
2091 state.include_cursor = Some(include_cursor);
2092 config.system_inventory_refresh = Some(state);
2093 config.save()
2094}
2095
2096pub fn update_dns_inventory_cache<F>(updater: F) -> Result<(), String>
2097where
2098 F: FnOnce(&mut DnsInventoryCache),
2099{
2100 ensure_global_xbp_paths()?;
2101 let mut config = SshConfig::load()?;
2102 let mut cache = config.dns_inventory.unwrap_or_default();
2103 updater(&mut cache);
2104 config.dns_inventory = Some(cache);
2105 config.save()
2106}
2107
2108pub fn sync_system_inventory(
2109 force: bool,
2110 include_cursor: bool,
2111 current_dir: Option<&Path>,
2112) -> Result<SystemInventorySyncResult, String> {
2113 let paths = ensure_global_xbp_paths()?;
2114 let mut config = SshConfig::load()?;
2115
2116 if !force {
2117 if let Some(existing) = config.system_inventory.clone() {
2118 if !system_inventory_needs_refresh(&existing, include_cursor) {
2119 return Ok(SystemInventorySyncResult {
2120 inventory: existing,
2121 config_path: paths.config_file,
2122 refreshed: false,
2123 });
2124 }
2125 }
2126 }
2127
2128 let mut options = SystemInventoryOptions {
2129 include_cursor,
2130 current_dir: current_dir.map(Path::to_path_buf),
2131 xbp_global_root: Some(paths.root_dir.clone()),
2132 };
2133 if options.current_dir.is_none() {
2134 options.current_dir = env::current_dir().ok();
2135 }
2136
2137 let mut inventory = collect_codetime_system_inventory(&options);
2138 if !include_cursor {
2139 if let Some(existing_cursor) = config
2140 .system_inventory
2141 .as_ref()
2142 .and_then(|existing| existing.cursor.clone())
2143 {
2144 inventory.cursor = Some(existing_cursor);
2145 }
2146 }
2147
2148 config.system_inventory = Some(inventory.clone());
2149 config.save()?;
2150
2151 Ok(SystemInventorySyncResult {
2152 inventory,
2153 config_path: paths.config_file,
2154 refreshed: true,
2155 })
2156}
2157
2158fn system_inventory_needs_refresh(inventory: &SystemInventory, include_cursor: bool) -> bool {
2159 if include_cursor && inventory.cursor.is_none() {
2160 return true;
2161 }
2162
2163 match inventory.collected_at {
2164 Some(collected_at) => {
2165 Utc::now() - collected_at >= ChronoDuration::hours(SYSTEM_INVENTORY_REFRESH_HOURS)
2166 }
2167 None => true,
2168 }
2169}
2170
2171pub fn global_xbp_paths() -> Result<GlobalXbpPaths, String> {
2172 ensure_global_xbp_paths()
2173}
2174
2175pub fn get_config_path() -> PathBuf {
2176 ensure_global_xbp_paths()
2177 .map(|paths| paths.config_file)
2178 .unwrap_or_else(|_| legacy_config_path())
2179}
2180
2181#[cfg(target_os = "windows")]
2182fn legacy_config_path() -> PathBuf {
2183 dirs::config_dir()
2184 .unwrap_or_else(|| PathBuf::from("."))
2185 .join("xbp")
2186 .join("config.yaml")
2187}
2188
2189#[cfg(not(target_os = "windows"))]
2190fn legacy_config_path() -> PathBuf {
2191 dirs::home_dir()
2192 .unwrap_or_else(|| PathBuf::from("."))
2193 .join(".xbp")
2194 .join("config.yaml")
2195}
2196
2197pub fn resolve_global_xbp_root_dir() -> PathBuf {
2203 preferred_global_root_dir()
2204}
2205
2206#[cfg(test)]
2207mod global_root_tests {
2208 use super::{map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows, score_xbp_root};
2209 use std::path::Path;
2210
2211 #[test]
2212 fn maps_windows_and_wsl_paths_both_ways() {
2213 assert_eq!(
2214 map_windows_path_to_wsl_mnt(r"C:\Users\szymon\.xbp"),
2215 Some("/mnt/c/Users/szymon/.xbp".to_string())
2216 );
2217 assert_eq!(
2218 map_wsl_mnt_path_to_windows("/mnt/c/Users/szymon/.xbp"),
2219 Some("C:/Users/szymon/.xbp".to_string())
2220 );
2221 }
2222
2223 #[test]
2224 fn scores_missing_root_as_zero() {
2225 assert_eq!(score_xbp_root(Path::new("/definitely/not/an/xbp/root")), 0);
2226 }
2227}
2228
2229fn preferred_global_root_dir() -> PathBuf {
2230 if let Some(explicit) = explicit_xbp_home_from_env() {
2231 return explicit;
2232 }
2233
2234 let candidates = collect_global_xbp_root_candidates();
2235 if let Some(best) = pick_established_xbp_root(&candidates) {
2236 return best;
2237 }
2238
2239 #[cfg(target_os = "windows")]
2241 {
2242 if let Some(home_dir) = resolve_windows_home_dir() {
2243 let c_drive = Path::new(r"C:\");
2244 if c_drive.exists() {
2245 if windows_drive_letter(&home_dir) == Some('C') {
2246 return home_dir.join(".xbp");
2247 }
2248 if let Some(relative_profile_path) = windows_path_without_drive(&home_dir) {
2249 let c_profile_candidate = c_drive.join(relative_profile_path);
2250 if c_profile_candidate.exists() {
2251 return c_profile_candidate.join(".xbp");
2252 }
2253 }
2254 }
2255 return home_dir.join(".xbp");
2256 }
2257 return dirs::config_dir()
2258 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
2259 .unwrap_or_else(|| PathBuf::from("."))
2260 .join("xbp");
2261 }
2262
2263 #[cfg(not(target_os = "windows"))]
2264 {
2265 if let Some(home) = dirs::home_dir() {
2268 return home.join(".xbp");
2269 }
2270 dirs::config_dir()
2271 .unwrap_or_else(|| PathBuf::from("."))
2272 .join("xbp")
2273 }
2274}
2275
2276fn explicit_xbp_home_from_env() -> Option<PathBuf> {
2277 for key in ["XBP_HOME", "XBP_CONFIG_HOME", "XBP_GLOBAL_ROOT"] {
2278 if let Ok(value) = env::var(key) {
2279 let trimmed = value.trim();
2280 if !trimmed.is_empty() {
2281 return Some(PathBuf::from(trimmed));
2282 }
2283 }
2284 }
2285 None
2286}
2287
2288fn collect_global_xbp_root_candidates() -> Vec<PathBuf> {
2289 let mut candidates: Vec<PathBuf> = Vec::new();
2290 let mut push = |path: PathBuf| {
2291 if candidates
2292 .iter()
2293 .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
2294 {
2295 return;
2296 }
2297 candidates.push(path);
2298 };
2299
2300 if let Some(explicit) = explicit_xbp_home_from_env() {
2301 push(explicit);
2302 }
2303
2304 #[cfg(target_os = "windows")]
2305 {
2306 if let Some(home_dir) = resolve_windows_home_dir() {
2307 push(home_dir.join(".xbp"));
2308 if let Some(relative) = windows_path_without_drive(&home_dir) {
2309 push(Path::new(r"C:\").join(relative).join(".xbp"));
2310 }
2311 }
2312 if let Some(config_dir) = dirs::config_dir() {
2313 push(config_dir.join("xbp"));
2314 }
2315 }
2316
2317 #[cfg(not(target_os = "windows"))]
2318 {
2319 if let Some(home) = dirs::home_dir() {
2320 push(home.join(".xbp"));
2321 push(home.join(".config").join("xbp"));
2322 }
2323 if let Some(config_dir) = dirs::config_dir() {
2324 push(config_dir.join("xbp"));
2325 }
2326
2327 for windows_home in discover_windows_profile_homes_from_unix() {
2329 push(windows_home.join(".xbp"));
2330 }
2331 }
2332
2333 candidates
2334}
2335
2336fn pick_established_xbp_root(candidates: &[PathBuf]) -> Option<PathBuf> {
2337 candidates
2338 .iter()
2339 .map(|path| (score_xbp_root(path), path.clone()))
2340 .filter(|(score, _)| *score > 0)
2341 .max_by(|left, right| {
2342 left.0.cmp(&right.0).then_with(|| {
2343 let left_windows = is_windows_drive_backed_path(&left.1);
2345 let right_windows = is_windows_drive_backed_path(&right.1);
2346 right_windows.cmp(&left_windows)
2347 })
2348 })
2349 .map(|(_, path)| path)
2350}
2351
2352fn score_xbp_root(root: &Path) -> u64 {
2353 if !root.exists() {
2354 return 0;
2355 }
2356
2357 let mut score = 1u64;
2358 if root.join("config.yaml").is_file() {
2359 score += 100;
2360 }
2361 if root.join("config.yml").is_file() {
2362 score += 80;
2363 }
2364 let mutations = root.join("mutations");
2365 if mutations.is_dir() {
2366 score += 50;
2367 score += count_dir_entries_capped(&mutations, 40).saturating_mul(2);
2368 }
2369 if root.join("ssh").is_dir() {
2370 score += 10;
2371 }
2372 if root.join("cache").is_dir() {
2373 score += 5;
2374 }
2375 if is_windows_drive_backed_path(root) && root.join("config.yaml").is_file() {
2377 score += 40;
2378 }
2379 score
2380}
2381
2382fn count_dir_entries_capped(path: &Path, cap: u64) -> u64 {
2383 let Ok(entries) = fs::read_dir(path) else {
2384 return 0;
2385 };
2386 let mut count = 0u64;
2387 for _entry in entries.flatten() {
2388 count += 1;
2389 if count >= cap {
2390 break;
2391 }
2392 }
2393 count
2394}
2395
2396fn is_windows_drive_backed_path(path: &Path) -> bool {
2397 let normalized = path.to_string_lossy().replace('\\', "/");
2398 if cfg!(windows) {
2399 return windows_drive_letter(path).is_some();
2400 }
2401 let lower = normalized.to_ascii_lowercase();
2403 lower.starts_with("/mnt/")
2404 && lower.len() >= 6
2405 && lower
2406 .as_bytes()
2407 .get(5)
2408 .is_some_and(|b| b.is_ascii_alphabetic())
2409}
2410
2411fn path_keys_equivalent(left: &Path, right: &Path) -> bool {
2412 normalize_path_key(left) == normalize_path_key(right)
2413}
2414
2415fn normalize_path_key(path: &Path) -> String {
2416 let mut value = path.to_string_lossy().replace('\\', "/");
2417 if let Some(mapped) = map_windows_path_to_wsl_mnt(&value) {
2419 value = mapped;
2420 } else if let Some(mapped) = map_wsl_mnt_path_to_windows(&value) {
2421 if let Some(mnt) = map_windows_path_to_wsl_mnt(&mapped) {
2423 value = mnt;
2424 } else {
2425 value = mapped;
2426 }
2427 }
2428 if cfg!(windows) {
2429 value.to_ascii_lowercase()
2430 } else {
2431 value
2432 }
2433}
2434
2435pub fn map_windows_path_to_wsl_mnt(path: &str) -> Option<String> {
2437 let normalized = path.trim().replace('\\', "/");
2438 let bytes = normalized.as_bytes();
2439 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
2440 let drive = (bytes[0] as char).to_ascii_lowercase();
2441 let rest = normalized.get(2..).unwrap_or("").trim_start_matches('/');
2442 if rest.is_empty() {
2443 return Some(format!("/mnt/{drive}"));
2444 }
2445 return Some(format!("/mnt/{drive}/{rest}"));
2446 }
2447 None
2448}
2449
2450pub fn map_wsl_mnt_path_to_windows(path: &str) -> Option<String> {
2452 let normalized = path.trim().replace('\\', "/");
2453 let rest = normalized.strip_prefix("/mnt/")?;
2454 let mut parts = rest.splitn(2, '/');
2455 let drive = parts.next()?.chars().next()?;
2456 if !drive.is_ascii_alphabetic() {
2457 return None;
2458 }
2459 let tail = parts.next().unwrap_or("");
2460 if tail.is_empty() {
2461 Some(format!("{}:", drive.to_ascii_uppercase()))
2462 } else {
2463 Some(format!("{}:/{}", drive.to_ascii_uppercase(), tail))
2464 }
2465}
2466
2467#[cfg(not(target_os = "windows"))]
2468fn discover_windows_profile_homes_from_unix() -> Vec<PathBuf> {
2469 let mut homes: Vec<PathBuf> = Vec::new();
2470 let mut push_home = |path: PathBuf| {
2471 if path.is_dir()
2472 && !homes
2473 .iter()
2474 .any(|existing| path_keys_equivalent(existing.as_path(), path.as_path()))
2475 {
2476 homes.push(path);
2477 }
2478 };
2479
2480 for key in ["USERPROFILE", "WIN_HOME", "WINDOWS_HOME"] {
2482 if let Ok(value) = env::var(key) {
2483 let trimmed = value.trim();
2484 if trimmed.is_empty() {
2485 continue;
2486 }
2487 if let Some(mnt) = map_windows_path_to_wsl_mnt(trimmed) {
2488 push_home(PathBuf::from(mnt));
2489 }
2490 if trimmed.starts_with("/mnt/") {
2492 push_home(PathBuf::from(trimmed));
2493 }
2494 }
2495 }
2496
2497 if let Some(profile) = query_windows_userprofile_via_cmd() {
2499 if let Some(mnt) = map_windows_path_to_wsl_mnt(&profile) {
2500 push_home(PathBuf::from(mnt));
2501 }
2502 }
2503
2504 if let Some(linux_user) = env::var_os("USER").map(|u| u.to_string_lossy().to_string()) {
2506 for drive in b'c'..=b'z' {
2507 let users_dir = PathBuf::from(format!("/mnt/{}/Users", drive as char));
2508 if !users_dir.is_dir() {
2509 continue;
2510 }
2511 let direct = users_dir.join(&linux_user);
2512 if direct.is_dir() {
2513 push_home(direct);
2514 }
2515 if let Ok(entries) = fs::read_dir(&users_dir) {
2518 for entry in entries.flatten() {
2519 let name = entry.file_name().to_string_lossy().to_string();
2520 if name.eq_ignore_ascii_case(&linux_user) {
2521 push_home(entry.path());
2522 }
2523 }
2524 }
2525 if drive == b'c' {
2527 break;
2528 }
2529 }
2530 }
2531
2532 homes
2533}
2534
2535#[cfg(not(target_os = "windows"))]
2536fn query_windows_userprofile_via_cmd() -> Option<String> {
2537 let is_wsl = env::var_os("WSL_DISTRO_NAME").is_some()
2539 || env::var_os("WSL_INTEROP").is_some()
2540 || fs::read_to_string("/proc/version")
2541 .map(|content| {
2542 let lower = content.to_ascii_lowercase();
2543 lower.contains("microsoft") || lower.contains("wsl")
2544 })
2545 .unwrap_or(false);
2546 if !is_wsl {
2547 return None;
2548 }
2549
2550 let output = std::process::Command::new("cmd.exe")
2551 .args(["/C", "echo %USERPROFILE%"])
2552 .output()
2553 .ok()?;
2554 if !output.status.success() {
2555 return None;
2556 }
2557 let stdout = String::from_utf8_lossy(&output.stdout);
2558 let profile = stdout
2559 .lines()
2560 .map(str::trim)
2561 .find(|line| !line.is_empty() && !line.eq_ignore_ascii_case("%USERPROFILE%"))?
2562 .to_string();
2563 if profile.is_empty() {
2564 None
2565 } else {
2566 Some(profile)
2567 }
2568}
2569
2570#[cfg(target_os = "windows")]
2571fn resolve_windows_home_dir() -> Option<PathBuf> {
2572 dirs::home_dir()
2573 .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
2574 .or_else(|| {
2575 let drive = env::var_os("HOMEDRIVE")?;
2576 let path = env::var_os("HOMEPATH")?;
2577 Some(PathBuf::from(drive).join(path))
2578 })
2579}
2580
2581fn windows_drive_letter(path: &Path) -> Option<char> {
2582 let normalized = path.to_string_lossy().replace('/', "\\");
2583 let bytes = normalized.as_bytes();
2584 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
2585 Some((bytes[0] as char).to_ascii_uppercase())
2586 } else {
2587 None
2588 }
2589}
2590
2591#[cfg(target_os = "windows")]
2592fn windows_path_without_drive(path: &Path) -> Option<PathBuf> {
2593 let normalized = path.to_string_lossy().replace('/', "\\");
2594 let bytes = normalized.as_bytes();
2595 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
2596 let tail = &normalized[3..];
2597 if tail.is_empty() {
2598 Some(PathBuf::new())
2599 } else {
2600 Some(PathBuf::from(tail))
2601 }
2602 } else {
2603 None
2604 }
2605}
2606
2607#[cfg(target_os = "windows")]
2608fn maybe_migrate_legacy_windows_files(paths: &GlobalXbpPaths) -> Result<(), String> {
2609 let Some(legacy_root) = dirs::config_dir().map(|dir| dir.join("xbp")) else {
2610 return Ok(());
2611 };
2612
2613 migrate_legacy_windows_file_if_missing(&legacy_root.join("config.yaml"), &paths.config_file)?;
2614 migrate_legacy_windows_file_if_missing(
2615 &legacy_root.join("versioning-files.yaml"),
2616 &paths.versioning_files_file,
2617 )?;
2618 migrate_legacy_windows_file_if_missing(
2619 &legacy_root.join("package-name-files.yaml"),
2620 &paths.package_name_files_file,
2621 )?;
2622
2623 Ok(())
2624}
2625
2626#[cfg(not(target_os = "windows"))]
2627fn maybe_migrate_legacy_windows_files(_paths: &GlobalXbpPaths) -> Result<(), String> {
2628 Ok(())
2629}
2630
2631#[cfg(target_os = "windows")]
2632fn migrate_legacy_windows_file_if_missing(from: &Path, to: &Path) -> Result<(), String> {
2633 if to.exists() || !from.exists() {
2634 return Ok(());
2635 }
2636
2637 if let Some(parent) = to.parent() {
2638 fs::create_dir_all(parent).map_err(|e| {
2639 format!(
2640 "Failed to create directory for migrated file {}: {}",
2641 parent.display(),
2642 e
2643 )
2644 })?;
2645 }
2646
2647 fs::copy(from, to).map_err(|e| {
2648 format!(
2649 "Failed to migrate legacy config file {} -> {}: {}",
2650 from.display(),
2651 to.display(),
2652 e
2653 )
2654 })?;
2655
2656 Ok(())
2657}
2658
2659pub fn describe_global_xbp_paths() -> Result<Vec<(String, PathBuf)>, String> {
2660 let paths = global_xbp_paths()?;
2661 Ok(vec![
2662 ("root".to_string(), paths.root_dir),
2663 ("config".to_string(), paths.config_file),
2664 ("ssh".to_string(), paths.ssh_dir),
2665 ("cache".to_string(), paths.cache_dir),
2666 ("logs".to_string(), paths.logs_dir),
2667 ("versioning".to_string(), paths.versioning_files_file),
2668 ("package-names".to_string(), paths.package_name_files_file),
2669 ("worktree-ignore".to_string(), paths.worktree_ignore_file),
2670 ])
2671}
2672
2673pub fn sync_versioning_files_registry() -> Result<PathBuf, String> {
2674 let paths = ensure_global_xbp_paths()?;
2675 Ok(paths.versioning_files_file)
2676}
2677
2678pub fn load_versioning_files_registry() -> Result<Vec<String>, String> {
2679 let registry_path = sync_versioning_files_registry()?;
2680 let content = fs::read_to_string(®istry_path).map_err(|e| {
2681 format!(
2682 "Failed to read versioning registry {}: {}",
2683 registry_path.display(),
2684 e
2685 )
2686 })?;
2687
2688 let config: VersioningFilesConfig = serde_yaml::from_str(&content)
2689 .map_err(|e| format!("Failed to parse versioning registry: {}", e))?;
2690
2691 Ok(config.files)
2692}
2693
2694pub fn sync_package_name_files_registry() -> Result<PathBuf, String> {
2695 let paths = ensure_global_xbp_paths()?;
2696 Ok(paths.package_name_files_file)
2697}
2698
2699pub fn load_package_name_files_registry() -> Result<Vec<PackageNameLookup>, String> {
2700 let registry_path = sync_package_name_files_registry()?;
2701 let content = fs::read_to_string(®istry_path).map_err(|e| {
2702 format!(
2703 "Failed to read package-name registry {}: {}",
2704 registry_path.display(),
2705 e
2706 )
2707 })?;
2708
2709 let config: PackageNameFilesConfig = serde_yaml::from_str(&content)
2710 .map_err(|e| format!("Failed to parse package-name registry: {}", e))?;
2711
2712 Ok(config.lookups)
2713}
2714
2715fn sync_versioning_files_registry_at(path: &PathBuf) -> Result<(), String> {
2716 let mut config = if path.exists() {
2717 let content = fs::read_to_string(path).map_err(|e| {
2718 format!(
2719 "Failed to read versioning registry {}: {}",
2720 path.display(),
2721 e
2722 )
2723 })?;
2724 serde_yaml::from_str::<VersioningFilesConfig>(&content)
2725 .unwrap_or_else(|_| VersioningFilesConfig::default())
2726 } else {
2727 VersioningFilesConfig::default()
2728 };
2729
2730 let mut changed = false;
2731 for default_file in default_versioning_files() {
2732 if !config
2733 .files
2734 .iter()
2735 .any(|existing| existing == &default_file)
2736 {
2737 config.files.push(default_file);
2738 changed = true;
2739 }
2740 }
2741
2742 if changed || !path.exists() {
2743 let content = serde_yaml::to_string(&config)
2744 .map_err(|e| format!("Failed to serialize versioning registry: {}", e))?;
2745 fs::write(path, content).map_err(|e| {
2746 format!(
2747 "Failed to write versioning registry {}: {}",
2748 path.display(),
2749 e
2750 )
2751 })?;
2752 }
2753
2754 Ok(())
2755}
2756
2757fn sync_package_name_files_registry_at(path: &PathBuf) -> Result<(), String> {
2758 let mut config = if path.exists() {
2759 let content = fs::read_to_string(path).map_err(|e| {
2760 format!(
2761 "Failed to read package-name registry {}: {}",
2762 path.display(),
2763 e
2764 )
2765 })?;
2766 serde_yaml::from_str::<PackageNameFilesConfig>(&content)
2767 .unwrap_or_else(|_| PackageNameFilesConfig::default())
2768 } else {
2769 PackageNameFilesConfig::default()
2770 };
2771
2772 let mut changed = false;
2773 for default_lookup in default_package_name_lookups() {
2774 if !config
2775 .lookups
2776 .iter()
2777 .any(|existing| existing == &default_lookup)
2778 {
2779 config.lookups.push(default_lookup);
2780 changed = true;
2781 }
2782 }
2783
2784 if changed || !path.exists() {
2785 let content = serde_yaml::to_string(&config)
2786 .map_err(|e| format!("Failed to serialize package-name registry: {}", e))?;
2787 fs::write(path, content).map_err(|e| {
2788 format!(
2789 "Failed to write package-name registry {}: {}",
2790 path.display(),
2791 e
2792 )
2793 })?;
2794 }
2795
2796 Ok(())
2797}
2798
2799fn default_versioning_files() -> Vec<String> {
2800 vec![
2801 "README.md".to_string(),
2802 "openapi.yaml".to_string(),
2803 "openapi.yml".to_string(),
2804 "openapi.json".to_string(),
2805 "package.json".to_string(),
2806 "package-lock.json".to_string(),
2807 "Cargo.toml".to_string(),
2808 "Cargo.lock".to_string(),
2809 "pyproject.toml".to_string(),
2810 "composer.json".to_string(),
2811 "deno.json".to_string(),
2812 "deno.jsonc".to_string(),
2813 "Chart.yaml".to_string(),
2814 "app.json".to_string(),
2815 "manifest.json".to_string(),
2816 "pom.xml".to_string(),
2817 "build.gradle".to_string(),
2818 "build.gradle.kts".to_string(),
2819 "mix.exs".to_string(),
2820 "xbp.toml".to_string(),
2821 "xbp.jsonc".to_string(),
2822 "xbp.yaml".to_string(),
2823 "xbp.yml".to_string(),
2824 "xbp.json".to_string(),
2825 ".xbp/xbp.toml".to_string(),
2826 ".xbp/xbp.jsonc".to_string(),
2827 ".xbp/xbp.json".to_string(),
2828 ".xbp/xbp.yaml".to_string(),
2829 ".xbp/xbp.yml".to_string(),
2830 ]
2831}
2832
2833fn default_package_name_lookups() -> Vec<PackageNameLookup> {
2834 vec![
2835 PackageNameLookup {
2836 file: "package.json".to_string(),
2837 format: "json".to_string(),
2838 key: "name".to_string(),
2839 registry: "npm".to_string(),
2840 },
2841 PackageNameLookup {
2842 file: "Cargo.toml".to_string(),
2843 format: "toml".to_string(),
2844 key: "package.name".to_string(),
2845 registry: "crates.io".to_string(),
2846 },
2847 ]
2848}
2849
2850const DEFAULT_API_XBP_URL: &str = "https://api.xbp.app";
2851
2852#[derive(Debug, Clone)]
2854pub struct ApiConfig {
2855 base_url: String,
2856}
2857
2858impl ApiConfig {
2859 pub fn load() -> Self {
2861 let raw_url = env::var("API_XBP_URL").unwrap_or_else(|_| DEFAULT_API_XBP_URL.to_string());
2862 Self::from_base_url(&raw_url)
2863 }
2864
2865 pub fn from_env() -> Self {
2866 Self::load()
2867 }
2868
2869 pub fn from_base_url(raw_url: &str) -> Self {
2870 let base_url = Self::normalize_base_url(raw_url);
2871 ApiConfig { base_url }
2872 }
2873
2874 pub fn base_url(&self) -> &str {
2876 &self.base_url
2877 }
2878
2879 pub fn version_endpoint(&self, project_name: &str) -> String {
2881 format!("{}/version?project_name={}", self.base_url, project_name)
2882 }
2883
2884 pub fn increment_endpoint(&self) -> String {
2886 format!("{}/version/increment", self.base_url)
2887 }
2888
2889 pub fn web_base_url(&self) -> String {
2890 Self::derive_web_base_url(&self.base_url)
2891 }
2892
2893 pub fn cli_auth_request_endpoint(&self) -> String {
2894 format!("{}/api/cli/auth/request", self.web_base_url())
2895 }
2896
2897 pub fn cli_auth_poll_endpoint(&self) -> String {
2898 format!("{}/api/cli/auth/poll", self.web_base_url())
2899 }
2900
2901 pub fn cli_auth_session_endpoint(&self) -> String {
2902 format!("{}/api/cli/auth/session", self.web_base_url())
2903 }
2904
2905 pub fn cli_auth_browser_url(&self, flow_id: &str) -> String {
2906 format!("{}/cli/login/{}", self.web_base_url(), flow_id)
2907 }
2908
2909 pub fn cli_linear_key_endpoint(&self) -> String {
2910 format!("{}/api/cli/linear/key", self.web_base_url())
2911 }
2912
2913 pub fn cli_cloudflare_credentials_endpoint(&self) -> String {
2914 format!("{}/api/cli/cloudflare/credentials", self.web_base_url())
2915 }
2916
2917 pub fn cloudflare_settings_url(&self) -> String {
2918 format!("{}/settings/cloudflare", self.web_base_url())
2919 }
2920
2921 pub fn cli_version_activity_endpoint(&self) -> String {
2922 format!("{}/api/cli/version/activity", self.web_base_url())
2923 }
2924
2925 pub fn cli_error_report_endpoint(&self) -> String {
2926 format!("{}/api/cli/errors/report", self.web_base_url())
2927 }
2928
2929 pub fn cli_deploy_activity_endpoint(&self) -> String {
2930 format!("{}/api/cli/deploy/activity", self.web_base_url())
2931 }
2932
2933 pub fn cli_deploy_secrets_endpoint(&self) -> String {
2934 format!("{}/api/cli/deploy/secrets", self.web_base_url())
2935 }
2936
2937 pub fn cli_command_activity_endpoint(&self) -> String {
2938 format!("{}/api/cli/command/activity", self.web_base_url())
2939 }
2940
2941 pub fn cli_cursor_ingest_endpoint(&self) -> String {
2942 format!("{}/api/cli/cursor/ingest", self.web_base_url())
2943 }
2944
2945 pub fn cli_projects_register_endpoint(&self) -> String {
2946 format!("{}/api/cli/projects/register", self.web_base_url())
2947 }
2948
2949 pub fn cli_worktree_mutations_endpoint(&self) -> String {
2950 format!("{}/api/cli/worktree-mutations/ingest", self.web_base_url())
2951 }
2952
2953 pub fn cli_secrets_sync_endpoint(&self) -> String {
2954 format!("{}/api/cli/secrets/sync", self.web_base_url())
2955 }
2956
2957 pub fn cli_secrets_list_endpoint(&self) -> String {
2958 format!("{}/api/cli/secrets", self.web_base_url())
2959 }
2960
2961 pub fn cli_openapi_upload_endpoint(&self) -> String {
2962 format!("{}/api/cli/openapi/upload", self.web_base_url())
2963 }
2964
2965 pub fn cli_openapi_list_endpoint(&self) -> String {
2966 format!("{}/api/cli/openapi", self.web_base_url())
2967 }
2968
2969 fn normalize_base_url(raw: &str) -> String {
2970 let trimmed = raw.trim();
2971 if trimmed.is_empty() {
2972 return DEFAULT_API_XBP_URL.to_string();
2973 }
2974
2975 let trimmed = trimmed.trim_end_matches('/');
2976 if trimmed.is_empty() {
2977 return DEFAULT_API_XBP_URL.to_string();
2978 }
2979
2980 trimmed.to_string()
2981 }
2982
2983 fn derive_web_base_url(base_url: &str) -> String {
2984 let Ok(mut url) = Url::parse(base_url) else {
2985 return base_url.to_string();
2986 };
2987
2988 if let Some(host) = url.host_str().map(str::to_string) {
2989 if host == "api.xbp.app" || (host.starts_with("api.") && host.ends_with(".xbp.app")) {
2990 let _ = url.set_host(Some("xbp.app"));
2991 } else if let Some(stripped) = host.strip_prefix("api.") {
2992 let _ = url.set_host(Some(stripped));
2993 }
2994 }
2995
2996 url.set_path("");
2997 url.set_query(None);
2998 url.set_fragment(None);
2999
3000 url.to_string().trim_end_matches('/').to_string()
3001 }
3002}
3003
3004#[cfg(test)]
3005mod tests {
3006 use super::{
3007 classify_cloudflare_token, cloudflare_account_id_from_project_env,
3008 cloudflare_token_kind_label, default_package_name_lookups, default_versioning_files,
3009 format_cloudflare_token_preview, resolve_cloudflare_account_id, resolve_github_oauth2_key,
3010 resolve_openrouter_api_key, resolve_xbp_api_token, sync_global_config_defaults_at,
3011 sync_package_name_files_registry_at, sync_versioning_files_registry_at, ApiConfig,
3012 CloudflareTokenKind, OpenRouterConfig, PackageNameFilesConfig, SecretProvider, SshConfig,
3013 VersioningFilesConfig,
3014 };
3015 use crate::openrouter::{
3016 DEFAULT_COMMIT_SYSTEM_PROMPT, DEFAULT_MODEL, DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT,
3017 };
3018 use std::env;
3019 use std::fs;
3020 use std::path::PathBuf;
3021 use std::time::{SystemTime, UNIX_EPOCH};
3022
3023 fn temp_path(label: &str) -> PathBuf {
3024 let nanos = SystemTime::now()
3025 .duration_since(UNIX_EPOCH)
3026 .expect("time")
3027 .as_nanos();
3028 std::env::temp_dir().join(format!("xbp-config-{}-{}.yaml", label, nanos))
3029 }
3030
3031 #[test]
3032 fn versioning_registry_defaults_include_core_files() {
3033 let defaults = default_versioning_files();
3034 assert!(defaults.contains(&"README.md".to_string()));
3035 assert!(defaults.contains(&"Cargo.toml".to_string()));
3036 assert!(defaults.contains(&".xbp/xbp.yaml".to_string()));
3037 }
3038
3039 #[test]
3040 fn versioning_registry_default_config_populates_files() {
3041 let config = VersioningFilesConfig::default();
3042 assert!(!config.files.is_empty());
3043 }
3044
3045 #[test]
3046 fn versioning_registry_defaults_do_not_contain_duplicates() {
3047 let defaults = default_versioning_files();
3048 let mut deduped = defaults.clone();
3049 deduped.sort();
3050 deduped.dedup();
3051 assert_eq!(defaults.len(), deduped.len());
3052 }
3053
3054 #[test]
3055 fn syncing_registry_creates_file_with_defaults() {
3056 let path = temp_path("defaults");
3057 sync_versioning_files_registry_at(&path).expect("sync");
3058
3059 let content = fs::read_to_string(&path).expect("read");
3060 assert!(content.contains("README.md"));
3061 assert!(content.contains("Cargo.toml"));
3062
3063 let _ = fs::remove_file(path);
3064 }
3065
3066 #[test]
3067 fn syncing_registry_preserves_user_added_entries() {
3068 let path = temp_path("preserve");
3069 fs::write(&path, "files:\n - custom.file\n").expect("write registry");
3070
3071 sync_versioning_files_registry_at(&path).expect("sync");
3072
3073 let content = fs::read_to_string(&path).expect("read");
3074 assert!(content.contains("custom.file"));
3075 assert!(content.contains("README.md"));
3076
3077 let _ = fs::remove_file(path);
3078 }
3079
3080 #[test]
3081 fn classifies_cloudflare_scannable_token_prefixes() {
3082 assert_eq!(
3083 classify_cloudflare_token("cfut_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
3084 CloudflareTokenKind::User
3085 );
3086 assert_eq!(
3087 classify_cloudflare_token("cfat_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
3088 CloudflareTokenKind::Account
3089 );
3090 assert_eq!(
3091 classify_cloudflare_token("cfk_abcdefghijklmnopqrstuvwxyz0123456789ABCD"),
3092 CloudflareTokenKind::GlobalKey
3093 );
3094 assert_eq!(
3095 classify_cloudflare_token("a".repeat(40).as_str()),
3096 CloudflareTokenKind::Legacy
3097 );
3098 assert_eq!(classify_cloudflare_token(" "), CloudflareTokenKind::Empty);
3099 assert_eq!(
3100 cloudflare_token_kind_label(CloudflareTokenKind::Account),
3101 "account-owned token (cfat_)"
3102 );
3103 let preview = format_cloudflare_token_preview(
3104 "cfat_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
3105 );
3106 assert!(preview.contains("account-owned"), "{preview}");
3107 assert!(preview.starts_with("cfat"), "{preview}");
3108 }
3109
3110 #[test]
3111 fn stores_user_and_account_cloudflare_tokens_in_separate_slots() {
3112 use super::CloudflareTokenSlot;
3113
3114 let user = "cfut_abcdefghijklmnopqrstuvwxyz0123456789USER";
3115 let account = "cfat_abcdefghijklmnopqrstuvwxyz0123456789ACCT";
3116 let mut cfg = SshConfig::new();
3117
3118 let r1 = cfg
3119 .store_cloudflare_token(user, CloudflareTokenSlot::Auto)
3120 .expect("store user");
3121 assert_eq!(r1.kind, CloudflareTokenKind::User);
3122 assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
3123 assert_eq!(cfg.cloudflare_primary_api_token(), Some(user));
3124
3125 let r2 = cfg
3126 .store_cloudflare_token(account, CloudflareTokenSlot::Auto)
3127 .expect("store account");
3128 assert_eq!(r2.kind, CloudflareTokenKind::Account);
3129 assert_eq!(cfg.cloudflare_account_api_token(), Some(account));
3130 assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
3132 assert_eq!(cfg.cloudflare_primary_api_token(), Some(user));
3133
3134 let entries = cfg.cloudflare_config_token_entries();
3135 assert!(
3136 entries.iter().any(|(s, t)| s.contains("user") && t == user),
3137 "{entries:?}"
3138 );
3139 assert!(
3140 entries
3141 .iter()
3142 .any(|(s, t)| s.contains("account") && t == account),
3143 "{entries:?}"
3144 );
3145
3146 let cleared = cfg.clear_cloudflare_token_slot(CloudflareTokenSlot::Account);
3147 assert!(cleared.contains(&"cloudflare_account_api_token"));
3148 assert!(cfg.cloudflare_account_api_token().is_none());
3149 assert_eq!(cfg.cloudflare_user_api_token(), Some(user));
3150 }
3151
3152 #[test]
3153 fn api_config_normalizes_trailing_slashes() {
3154 assert_eq!(
3155 ApiConfig::normalize_base_url("https://api.xbp.app///"),
3156 "https://api.xbp.app".to_string()
3157 );
3158 }
3159
3160 #[test]
3161 fn api_config_uses_default_for_blank_values() {
3162 assert_eq!(
3163 ApiConfig::normalize_base_url(" "),
3164 "https://api.xbp.app".to_string()
3165 );
3166 }
3167
3168 #[test]
3169 fn api_config_builds_version_endpoints() {
3170 let config = ApiConfig {
3171 base_url: "https://api.test.xbp".to_string(),
3172 };
3173 let endpoint = config.version_endpoint("demo");
3174 let increment = config.increment_endpoint();
3175
3176 assert_eq!(endpoint, "https://api.test.xbp/version?project_name=demo");
3177 assert_eq!(increment, "https://api.test.xbp/version/increment");
3178 }
3179
3180 #[test]
3181 fn api_config_exposes_cli_projects_register_endpoint() {
3182 let config = ApiConfig {
3183 base_url: "https://api.xbp.app".to_string(),
3184 };
3185 assert_eq!(
3186 config.cli_projects_register_endpoint(),
3187 "https://xbp.app/api/cli/projects/register"
3188 );
3189 }
3190
3191 #[test]
3192 fn api_config_derives_browser_base_url_from_api_subdomain() {
3193 assert_eq!(
3194 ApiConfig::derive_web_base_url("https://api.xbp.app"),
3195 "https://xbp.app".to_string()
3196 );
3197 assert_eq!(
3198 ApiConfig::derive_web_base_url("https://api.eu-de2.xbp.app"),
3199 "https://xbp.app".to_string()
3200 );
3201 assert_eq!(
3202 ApiConfig::derive_web_base_url("https://api.staging.xbp.app"),
3203 "https://xbp.app".to_string()
3204 );
3205 assert_eq!(
3206 ApiConfig::derive_web_base_url("https://api.internal.example.com"),
3207 "https://internal.example.com".to_string()
3208 );
3209 assert_eq!(
3210 ApiConfig::derive_web_base_url("http://localhost:3000"),
3211 "http://localhost:3000".to_string()
3212 );
3213 }
3214
3215 #[test]
3216 fn package_lookup_defaults_include_npm_and_crates() {
3217 let defaults = default_package_name_lookups();
3218 assert!(defaults.iter().any(|entry| {
3219 entry.file == "package.json" && entry.registry == "npm" && entry.key == "name"
3220 }));
3221 assert!(defaults.iter().any(|entry| {
3222 entry.file == "Cargo.toml"
3223 && entry.registry == "crates.io"
3224 && entry.key == "package.name"
3225 }));
3226 }
3227
3228 #[test]
3229 fn package_lookup_default_config_populates_entries() {
3230 let config = PackageNameFilesConfig::default();
3231 assert!(!config.lookups.is_empty());
3232 }
3233
3234 #[test]
3235 fn syncing_package_lookup_registry_creates_defaults() {
3236 let path = temp_path("package-lookup-defaults");
3237 sync_package_name_files_registry_at(&path).expect("sync");
3238
3239 let content = fs::read_to_string(&path).expect("read");
3240 assert!(content.contains("package.json"));
3241 assert!(content.contains("Cargo.toml"));
3242
3243 let _ = fs::remove_file(path);
3244 }
3245
3246 #[test]
3247 fn syncing_package_lookup_registry_preserves_custom_entries() {
3248 let path = temp_path("package-lookup-custom");
3249 fs::write(
3250 &path,
3251 "lookups:\n - file: custom.yaml\n format: yaml\n key: app.name\n registry: npm\n",
3252 )
3253 .expect("write package lookup registry");
3254
3255 sync_package_name_files_registry_at(&path).expect("sync");
3256 let content = fs::read_to_string(&path).expect("read");
3257 assert!(content.contains("custom.yaml"));
3258 assert!(content.contains("package.json"));
3259
3260 let _ = fs::remove_file(path);
3261 }
3262
3263 #[test]
3264 fn secret_provider_parses_supported_keys() {
3265 assert_eq!(
3266 SecretProvider::from_key("openrouter"),
3267 Some(SecretProvider::OpenRouter)
3268 );
3269 assert_eq!(
3270 SecretProvider::from_key("github"),
3271 Some(SecretProvider::Github)
3272 );
3273 assert_eq!(
3274 SecretProvider::from_key("linear"),
3275 Some(SecretProvider::Linear)
3276 );
3277 assert_eq!(SecretProvider::from_key("npm"), Some(SecretProvider::Npm));
3278 assert_eq!(
3279 SecretProvider::from_key("crates"),
3280 Some(SecretProvider::Crates)
3281 );
3282 assert_eq!(SecretProvider::from_key("unknown"), None);
3283 }
3284
3285 #[test]
3286 fn ssh_config_secret_get_set_roundtrip() {
3287 let mut cfg = SshConfig::new();
3288 cfg.set_secret(SecretProvider::OpenRouter, Some("or-test-123".to_string()));
3289 cfg.set_secret(SecretProvider::Github, Some("gho_test_456".to_string()));
3290 cfg.set_secret(SecretProvider::Linear, Some("lin_api_test_789".to_string()));
3291 cfg.set_secret(SecretProvider::Npm, Some("npm_test_token".to_string()));
3292 cfg.set_secret(SecretProvider::Crates, Some("crate_test_token".to_string()));
3293
3294 assert_eq!(
3295 cfg.get_secret(SecretProvider::OpenRouter),
3296 Some("or-test-123")
3297 );
3298 assert_eq!(cfg.get_secret(SecretProvider::Github), Some("gho_test_456"));
3299 assert_eq!(
3300 cfg.get_secret(SecretProvider::Linear),
3301 Some("lin_api_test_789")
3302 );
3303 assert_eq!(cfg.get_secret(SecretProvider::Npm), Some("npm_test_token"));
3304 assert_eq!(
3305 cfg.get_secret(SecretProvider::Crates),
3306 Some("crate_test_token")
3307 );
3308 assert!(cfg.get_secret_metadata(SecretProvider::Npm).is_some());
3309 }
3310
3311 #[test]
3312 fn default_openrouter_config_populates_models_and_prompts() {
3313 let config = OpenRouterConfig::default();
3314
3315 assert_eq!(config.commit_model, DEFAULT_MODEL);
3316 assert_eq!(config.release_notes_model, DEFAULT_MODEL);
3317 assert_eq!(
3318 config.commit_system_prompt,
3319 DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
3320 );
3321 assert_eq!(
3322 config.release_notes_system_prompt,
3323 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
3324 );
3325 }
3326
3327 #[test]
3328 fn default_ssh_config_serialization_includes_openrouter_generation_settings() {
3329 let yaml = serde_yaml::to_string(&SshConfig::new()).expect("serialize default config");
3330
3331 assert!(yaml.contains("openrouter:"));
3332 assert!(yaml.contains("commit_model: openai/gpt-4o-mini"));
3333 assert!(yaml.contains("release_notes_model: openai/gpt-4o-mini"));
3334 assert!(yaml.contains("commit_system_prompt"));
3335 assert!(yaml.contains("release_notes_system_prompt"));
3336 }
3337
3338 #[test]
3339 fn ssh_config_new_uses_openrouter_defaults() {
3340 let config = SshConfig::new();
3341
3342 assert_eq!(config.openrouter.commit_model, DEFAULT_MODEL);
3343 assert_eq!(config.openrouter.release_notes_model, DEFAULT_MODEL);
3344 assert_eq!(
3345 config.openrouter.commit_system_prompt,
3346 DEFAULT_COMMIT_SYSTEM_PROMPT.to_string()
3347 );
3348 assert_eq!(
3349 config.openrouter.release_notes_system_prompt,
3350 DEFAULT_RELEASE_NOTES_SYSTEM_PROMPT.to_string()
3351 );
3352 }
3353
3354 #[test]
3355 fn syncing_global_config_backfills_openrouter_generation_defaults() {
3356 let path = temp_path("global-config");
3357 fs::write(
3358 &path,
3359 "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",
3360 )
3361 .expect("write config");
3362
3363 sync_global_config_defaults_at(&path).expect("sync config defaults");
3364
3365 let content = fs::read_to_string(&path).expect("read config");
3366 assert!(content.contains("openrouter:"));
3367 assert!(content.contains("commit_model: openai/gpt-4o-mini"));
3368 assert!(content.contains("release_notes_model: openai/gpt-4o-mini"));
3369 assert!(content.contains("commit_system_prompt"));
3370 assert!(content.contains("release_notes_system_prompt"));
3371
3372 let _ = fs::remove_file(path);
3373 }
3374
3375 #[test]
3376 fn resolve_secret_prefers_environment_value() {
3377 std::env::set_var("OPENROUTER_API_KEY", "env-openrouter");
3378 std::env::set_var("GITHUB_TOKEN", "env-github");
3379 std::env::set_var("XBP_API_TOKEN", "env-xbp");
3380
3381 assert_eq!(
3382 resolve_openrouter_api_key(),
3383 Some("env-openrouter".to_string())
3384 );
3385 assert_eq!(resolve_github_oauth2_key(), Some("env-github".to_string()));
3386 assert_eq!(resolve_xbp_api_token(), Some("env-xbp".to_string()));
3387
3388 std::env::remove_var("OPENROUTER_API_KEY");
3389 std::env::remove_var("GITHUB_TOKEN");
3390 std::env::remove_var("XBP_API_TOKEN");
3391 }
3392
3393 #[test]
3394 fn resolve_cloudflare_account_id_reads_project_env_files() {
3395 use crate::utils::CLOUDFLARE_ACCOUNT_ID_ENV_KEYS;
3396
3397 let dir = temp_path("cf-account-env");
3398 fs::create_dir_all(dir.join(".xbp")).expect("mkdir");
3399 fs::write(dir.join(".xbp/xbp.yaml"), "project_name: demo\n").expect("write xbp");
3400 fs::write(
3401 dir.join(".env"),
3402 "XBP_CLOUDFLARE_ACCOUNT_ID=acc-from-project-env\n",
3403 )
3404 .expect("write env");
3405
3406 let previous = env::current_dir().expect("cwd");
3407 env::set_current_dir(&dir).expect("chdir");
3408 for key in CLOUDFLARE_ACCOUNT_ID_ENV_KEYS {
3409 env::remove_var(key);
3410 }
3411
3412 assert_eq!(
3413 cloudflare_account_id_from_project_env().as_deref(),
3414 Some("acc-from-project-env")
3415 );
3416 assert_eq!(
3417 resolve_cloudflare_account_id().as_deref(),
3418 Some("acc-from-project-env")
3419 );
3420
3421 env::set_current_dir(previous).expect("restore cwd");
3422 let _ = fs::remove_dir_all(dir);
3423 }
3424}