1pub mod container;
13pub mod host;
14pub mod picker;
15pub mod provider;
16pub mod snippet;
17pub mod tunnel;
18pub mod vault;
19
20pub use container::*;
21pub use host::*;
22pub use picker::*;
23pub use provider::*;
24pub use snippet::*;
25pub use tunnel::*;
26pub use vault::*;
27
28pub const FAILED_TO_SAVE: &str = "Failed to save";
31pub fn failed_to_save(e: &impl std::fmt::Display) -> String {
32 format!("{}: {}", FAILED_TO_SAVE, e)
33}
34
35pub const CONFIG_CHANGED_EXTERNALLY: &str =
36 "Config changed externally. Press Esc and re-open to pick up changes.";
37
38pub const DEMO_CONNECTION_DISABLED: &str = "Demo mode. Connection disabled.";
41pub const DEMO_SYNC_DISABLED: &str = "Demo mode. Sync disabled.";
42pub const DEMO_TUNNELS_DISABLED: &str = "Demo mode. Tunnels disabled.";
43pub const DEMO_VAULT_SIGNING_DISABLED: &str = "Demo mode. Vault SSH signing disabled.";
44pub const DEMO_FILE_BROWSER_DISABLED: &str = "Demo mode. File browser disabled.";
45pub const DEMO_CONTAINER_REFRESH_DISABLED: &str = "Demo mode. Container refresh disabled.";
46pub const DEMO_CONTAINER_ACTIONS_DISABLED: &str = "Demo mode. Container actions disabled.";
47pub const DEMO_EXECUTION_DISABLED: &str = "Demo mode. Execution disabled.";
48pub const DEMO_PROVIDER_CHANGES_DISABLED: &str = "Demo mode. Provider config changes disabled.";
49
50pub fn pinging_host(alias: &str, show_hint: bool) -> String {
53 if show_hint {
54 format!("Pinging {}... (Shift+P pings all)", alias)
55 } else {
56 format!("Pinging {}...", alias)
57 }
58}
59
60pub fn bastion_not_found(alias: &str) -> String {
61 format!("Bastion {} not found in config.", alias)
62}
63
64pub fn clipboard_run_failed(cmd: &str) -> String {
70 format!("Failed to run {}.", cmd)
71}
72
73pub fn clipboard_write_failed(cmd: &str) -> String {
74 format!("Failed to write to {}.", cmd)
75}
76
77pub fn clipboard_wait_failed(cmd: &str) -> String {
78 format!("Failed to wait for {}.", cmd)
79}
80
81pub fn clipboard_exited_error(cmd: &str) -> String {
82 format!("{} exited with error.", cmd)
83}
84
85pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
91 format!("Can't open {}: {}", path, e)
92}
93
94pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
95 format!("Can't open known_hosts: {}", e)
96}
97
98pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
99pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
100
101pub fn imported_hosts(imported: usize, skipped: usize) -> String {
104 format!(
105 "Imported {} host{}, skipped {} duplicate{}.",
106 imported,
107 if imported == 1 { "" } else { "s" },
108 skipped,
109 if skipped == 1 { "" } else { "s" }
110 )
111}
112
113pub fn all_hosts_exist(skipped: usize) -> String {
114 if skipped == 1 {
115 "Host already exists.".to_string()
116 } else {
117 format!("All {} hosts already exist.", skipped)
118 }
119}
120
121pub fn opened_in_tmux(alias: &str) -> String {
124 format!("Opened {} in new tmux window.", alias)
125}
126
127pub fn tmux_error(e: &impl std::fmt::Display) -> String {
128 format!("tmux: {}", e)
129}
130
131pub fn connection_failed(alias: &str) -> String {
132 format!("Connection to {} failed.", alias)
133}
134
135pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
139 format!("Connection failed: {}", e)
140}
141
142pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
145 format!("SSH to {} failed. {}", alias, reason)
146}
147
148pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
151 format!("SSH to {} exited with code {}.", alias, code)
152}
153
154pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
157
158pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
167 format!("{} {}: {}", spinner, name, message)
168}
169
170pub const AGE_JUST_NOW: &str = "just now";
173
174pub fn relative_age(elapsed: std::time::Duration) -> String {
178 let secs = elapsed.as_secs();
179 if secs < 5 {
180 AGE_JUST_NOW.to_string()
181 } else if secs < 60 {
182 format!("{}s ago", secs)
183 } else if secs < 3600 {
184 format!("{}m ago", secs / 60)
185 } else if secs < 86400 {
186 format!("{}h ago", secs / 3600)
187 } else {
188 format!("{}d ago", secs / 86400)
189 }
190}
191
192pub fn config_reloaded(count: usize) -> String {
195 format!("Config reloaded. {} hosts.", count)
196}
197
198pub fn synced_progress(
214 spinner: &str,
215 active_names: &str,
216 done: usize,
217 total: usize,
218 added: usize,
219 updated: usize,
220 stale: usize,
221) -> String {
222 debug_assert!(
223 !active_names.is_empty(),
224 "synced_progress must only be called while a provider is still in flight"
225 );
226 let diff = sync_diff_suffix(added, updated, stale);
227 format!(
228 "{} Syncing {} \u{00B7} {}/{}{}",
229 spinner, active_names, done, total, diff
230 )
231}
232
233pub fn synced_done(
238 done: usize,
239 total: usize,
240 names: &str,
241 added: usize,
242 updated: usize,
243 stale: usize,
244) -> String {
245 let diff = sync_diff_suffix(added, updated, stale);
246 format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
247}
248
249fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
250 let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
251 .iter()
252 .filter(|(n, _)| *n > 0)
253 .map(|(n, sign)| format!("{}{}", sign, n))
254 .collect();
255 if parts.is_empty() {
256 String::new()
257 } else {
258 format!(" ({})", parts.join(" "))
259 }
260}
261
262pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
263
264pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
265
266pub fn sync_skipped_external_change() -> &'static str {
267 "Config changed on disk during sync. Re-run sync after reviewing your edits."
268}
269
270pub const NO_CLIPBOARD_TOOL: &str =
273 "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
274
275pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
278
279pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
283 format!(
284 "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
285 path, e
286 )
287}
288
289pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
291 format!("Failed to write MCP audit entry: {}", e)
292}
293
294pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
298 format!("SSH config file not found: {}", path)
299}
300
301pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
305
306pub const PALETTE_PLACEHOLDER: &str = "Find anything";
310pub const PALETTE_NO_RESULTS: &str = "No matches.";
312pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
315 "Pick a host first, then run a snippet from the jump bar.";
316pub fn jump_more_rows(n: usize) -> String {
319 format!("+{n} more (scroll down)")
320}
321
322#[path = "messages/cli.rs"]
325pub mod cli;
326pub mod footer;
327
328pub mod update {
331 pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
332 pub const DONE: &str = "done.";
333 pub const CHECKSUM_OK: &str = "ok.";
334 pub const SUDO_WARNING: &str =
335 "Running via sudo. Consider fixing directory permissions instead.";
336
337 pub const STEP_CHECKING: &str = " Checking for updates... ";
342 pub const STEP_VERIFYING_CHECKSUM: &str = " Verifying checksum... ";
343 pub const STEP_INSTALLING: &str = " Installing... ";
344
345 pub fn already_on(current: &str) -> String {
346 format!("already on v{} (latest).", current)
347 }
348
349 pub fn available(latest: &str, current: &str) -> String {
350 format!("v{} available (current: v{}).", latest, current)
351 }
352
353 pub fn step_downloading(version: &str) -> String {
357 format!(" Downloading v{}... ", version)
358 }
359
360 pub fn sudo_warning_line(bold_bang: &str) -> String {
364 format!(" {} {}", bold_bang, SUDO_WARNING)
365 }
366
367 pub fn header(bold_name: &str) -> String {
368 format!("\n {} updater\n", bold_name)
369 }
370
371 pub fn binary_path(path: &std::path::Path) -> String {
372 format!(" Binary: {}", path.display())
373 }
374
375 pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
376 format!("\n {} installed at {}.", bold_version, path.display())
377 }
378
379 pub fn whats_new_hint_indented() -> String {
380 format!("\n {}", WHATS_NEW_HINT)
381 }
382}
383
384pub mod askpass {
387 pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
388 pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
389 pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
390 pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
391
392 pub fn read_failed(e: &impl std::fmt::Display) -> String {
393 format!("Failed to read password: {}", e)
394 }
395
396 pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
397 format!("Unlock failed: {}. Try again.", e)
398 }
399
400 pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
401 format!("Unlock failed: {}. SSH will prompt for password.", e)
402 }
403
404 pub fn password_prompt(alias: &str) -> String {
408 format!("Password for {}: ", alias)
409 }
410
411 pub fn keychain_password_prompt(alias: &str) -> String {
414 format!("Password for {} (stored in keychain): ", alias)
415 }
416
417 pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
420 format!(
421 "Failed to store in keychain: {}. SSH will prompt for password.",
422 e
423 )
424 }
425
426 pub const PROTON_NOT_FOUND: &str =
427 "Proton Pass CLI (pass-cli) not found. SSH will prompt for password.";
428
429 pub const PROTON_LOGIN_PROMPT: &str = "Proton Pass PAT: ";
430
431 pub const PROTON_LOGIN_SUCCESS: &str = "Logged in to Proton Pass.";
432
433 pub fn proton_login_failed_retry(e: &impl std::fmt::Display) -> String {
434 format!("Proton Pass login failed: {}. Try again.", e)
435 }
436
437 pub fn proton_login_failed_prompt(e: &impl std::fmt::Display) -> String {
438 format!(
439 "Proton Pass login failed: {}. SSH will prompt for password.",
440 e
441 )
442 }
443}
444
445pub mod logging {
448 pub fn init_failed(e: &impl std::fmt::Display) -> String {
449 format!("[purple] Failed to initialize logger: {}", e)
450 }
451
452 pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
453}
454
455pub mod hints {
461 pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
466 pub const DEFAULT_SSH_USER: &str = "root";
467
468 pub const HOST_ALIAS: &str = "e.g. prod or db-01";
470 pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
471 pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
472 pub const HOST_PORT: &str = "22";
473 pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
474 pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
475 pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
476 pub const HOST_VAULT_ADDR: &str =
477 "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
478 pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
479 pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
480
481 pub fn askpass_default(default: &str) -> String {
482 format!("default: {}", default)
483 }
484
485 pub fn inherits_from(value: &str, provider: &str) -> String {
486 format!("inherits {} from {}", value, provider)
487 }
488
489 pub const TUNNEL_BIND_PORT: &str = "8080";
491 pub const TUNNEL_REMOTE_HOST: &str = "localhost";
492 pub const TUNNEL_REMOTE_PORT: &str = "80";
493
494 pub const SNIPPET_NAME: &str = "check-disk";
496 pub const SNIPPET_COMMAND: &str = "df -h";
497 pub const SNIPPET_OPTIONAL: &str = "(optional)";
498
499 pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
501 pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
502 pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
503 pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret (or use Profile)";
504 pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
505 pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
506 pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
507 pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
508 pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
509 pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
510 pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
511 pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
512 pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
513 pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
514 pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
515 pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
516 pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
518 pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
519 pub const PROVIDER_USER_AWS: &str = "ec2-user";
520 pub const PROVIDER_USER_GCP: &str = "ubuntu";
521 pub const PROVIDER_USER_AZURE: &str = "azureuser";
522 pub const PROVIDER_USER_ORACLE: &str = "opc";
523 pub const PROVIDER_USER_OVH: &str = "ubuntu";
524 pub const PROVIDER_VAULT_ROLE: &str =
525 "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
526 pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
527 pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
528 pub const PROVIDER_LABEL: &str = "short name, e.g. server1 or work";
529}
530
531#[cfg(test)]
532mod hints_tests {
533 use super::hints;
534
535 #[test]
536 fn askpass_default_formats() {
537 assert_eq!(hints::askpass_default("keychain"), "default: keychain");
538 }
539
540 #[test]
541 fn askpass_default_formats_empty() {
542 assert_eq!(hints::askpass_default(""), "default: ");
543 }
544
545 #[test]
546 fn inherits_from_formats() {
547 assert_eq!(
548 hints::inherits_from("role/x", "aws"),
549 "inherits role/x from aws"
550 );
551 }
552
553 #[test]
554 fn picker_hints_mention_space_not_enter() {
555 for s in [
558 hints::IDENTITY_FILE_PICK,
559 hints::HOST_PROXY_JUMP,
560 hints::HOST_VAULT_SSH_PICKER,
561 hints::HOST_ASKPASS_PICK,
562 hints::PROVIDER_REGIONS_DEFAULT,
563 hints::PROVIDER_REGIONS_GCP,
564 hints::PROVIDER_REGIONS_SCALEWAY,
565 hints::PROVIDER_REGIONS_OVH,
566 ] {
567 assert!(
568 s.starts_with("Space "),
569 "picker hint must mention Space: {s}"
570 );
571 assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
572 }
573 }
574}
575
576#[path = "messages/whats_new.rs"]
577pub mod whats_new;
578
579#[path = "messages/whats_new_toast.rs"]
580pub mod whats_new_toast;
581
582#[cfg(test)]
583mod relative_age_tests {
584 use super::relative_age;
585 use std::time::Duration;
586
587 #[test]
588 fn relative_age_boundaries() {
589 assert_eq!(relative_age(Duration::from_secs(0)), "just now");
590 assert_eq!(relative_age(Duration::from_secs(4)), "just now");
591 assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
592 assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
593 assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
594 assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
595 assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
596 assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
597 assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
598 assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
599 }
600}