Skip to main content

purple_ssh/messages/
cli.rs

1// ── Add host validation ─────────────────────────────────────────
2
3pub const ALIAS_EMPTY: &str = "Alias can't be empty. Use --alias to specify one.";
4pub const ALIAS_WHITESPACE: &str =
5    "Alias can't contain whitespace. Use --alias to pick a simpler name.";
6pub const ALIAS_PATTERN_CHARS: &str =
7    "Alias can't contain pattern characters. Use --alias to pick a different name.";
8pub const HOSTNAME_WHITESPACE: &str = "Hostname can't contain whitespace.";
9pub const USER_WHITESPACE: &str = "User can't contain whitespace.";
10pub const PASSWORD_EMPTY: &str = "Password can't be empty.";
11pub const CANCELLED: &str = "Cancelled.";
12// Snippet description control-char rejection: defined once in the snippet module
13// and re-exported here so the CLI add path and the TUI form share one string.
14pub use super::SNIPPET_DESCRIPTION_CONTROL_CHARS as DESCRIPTION_CONTROL_CHARS;
15
16pub use super::contains_control_chars as control_chars;
17
18// Session Manager copy: one string each for the form and for `provider add`,
19// since the consequence is the same on both routes.
20pub use super::AWS_SSM_WITHOUT_PROFILE;
21pub use super::aws_ssm_profile_unsafe;
22
23pub use super::welcome_aboard as welcome;
24
25// ── Asset generation (internal) ─────────────────────────────────
26
27/// Confirmation printed after `purple gen-assets` writes the SVG set.
28pub fn gen_assets_done(count: usize, dir: &str) -> String {
29    format!("Wrote {count} SVG asset(s) to {dir}.")
30}
31
32/// Confirmation printed after `purple gen-assets --hero-out` writes the
33/// animated hero SVG.
34pub fn gen_assets_hero_done(path: &str) -> String {
35    format!("Wrote animated hero SVG to {path}.")
36}
37
38// ── Import ──────────────────────────────────────────────────────
39
40pub const IMPORT_NO_FILE: &str =
41    "Provide a file or use --known-hosts. Run 'purple import --help' for details.";
42
43// ── Provider CLI ────────────────────────────────────────────────
44
45pub const NO_PROVIDERS: &str = "No providers configured. Run 'purple provider add' to set one up.";
46
47/// All supported provider slugs as a comma-separated string. Surfaced in
48/// `unknown_provider` and `skipping_unknown_provider`. Kept as a single
49/// const so adding a new provider only updates one place.
50pub const PROVIDER_LIST: &str = "digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, \
51     scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip, teleport, netbox";
52
53/// Stderr line when the user passed `--provider X` for an unknown slug.
54/// Different from `skipping_unknown_provider` so each call site can
55/// evolve its wording without breaking the other.
56pub fn unknown_provider(name: &str) -> String {
57    format!("Never heard of '{}'. Try: {}.", name, PROVIDER_LIST)
58}
59
60/// Stderr line when iterating configured providers and one is unknown
61/// (e.g. config file references a since-removed provider). The sync
62/// continues with the remaining providers.
63pub fn skipping_unknown_provider(name: &str) -> String {
64    format!(
65        "Skipping unknown provider '{}'. Try: {}.",
66        name, PROVIDER_LIST
67    )
68}
69
70/// Stderr line printed by `purple add` when the alias already exists.
71/// Tells the user the exact flag to fix it instead of just complaining.
72pub fn alias_already_exists(alias: &str) -> String {
73    format!(
74        "'{}' already exists. Use --alias to pick a different name.",
75        alias
76    )
77}
78
79/// Stderr lines printed after `purple import` when some lines failed
80/// to parse or read. Use the singular/plural form via the count.
81pub fn import_parse_failures(count: usize) -> String {
82    let s = if count == 1 { "" } else { "s" };
83    format!(
84        "! {} line{} could not be parsed (invalid format).",
85        count, s
86    )
87}
88
89pub fn import_read_errors(count: usize) -> String {
90    let s = if count == 1 { "" } else { "s" };
91    format!("! {} line{} could not be read (encoding error).", count, s)
92}
93
94pub fn no_config_for(provider: &str) -> String {
95    format!(
96        "No configuration for {}. Run 'purple provider add {}' first.",
97        provider, provider
98    )
99}
100
101pub fn saved_config(provider: &str) -> String {
102    format!("Saved {} configuration.", provider)
103}
104
105pub fn no_config_to_remove(provider: &str) -> String {
106    format!("No configuration for '{}'. Nothing to remove.", provider)
107}
108
109pub fn removed_config(provider: &str) -> String {
110    format!("Removed {} configuration.", provider)
111}
112
113pub fn removed_configs(provider: &str, count: usize) -> String {
114    format!("Removed {} {} configurations.", count, provider)
115}
116
117pub fn invalid_label_flag(reason: &str) -> String {
118    format!("Invalid --label: {}", reason)
119}
120
121pub fn add_requires_label(provider: &str) -> String {
122    format!(
123        "Provider '{}' already has labeled configs. Pass --label to add another.",
124        provider
125    )
126}
127
128pub fn add_label_collides_with_bare(provider: &str) -> String {
129    format!(
130        "Provider '{}' has a bare config. Remove it first or use the TUI add flow which prompts for labels.",
131        provider
132    )
133}
134
135// ── Tunnel CLI ──────────────────────────────────────────────────
136
137pub fn no_tunnels_for(alias: &str) -> String {
138    format!("No tunnels configured for {}.", alias)
139}
140
141pub fn tunnels_for(alias: &str) -> String {
142    format!("Tunnels for {}:", alias)
143}
144
145pub const NO_TUNNELS: &str = "No tunnels configured.";
146
147pub fn starting_tunnel(alias: &str) -> String {
148    format!("Starting tunnel for {}... (Ctrl+C to stop)", alias)
149}
150
151pub fn host_not_found(alias: &str) -> String {
152    format!("No host '{}' found.", alias)
153}
154
155pub fn added_forward(forward: &str, alias: &str) -> String {
156    format!("Added {} to {}.", forward, alias)
157}
158
159pub fn forward_exists(forward: &str, alias: &str) -> String {
160    format!("Forward {} already exists on {}.", forward, alias)
161}
162
163pub fn forward_not_found(forward: &str, alias: &str) -> String {
164    format!("No matching forward {} found on {}.", forward, alias)
165}
166
167pub fn removed_forward(forward: &str, alias: &str) -> String {
168    format!("Removed {} from {}.", forward, alias)
169}
170
171pub fn no_forwards(alias: &str) -> String {
172    format!("No forwarding directives configured for '{}'.", alias)
173}
174
175pub fn save_config_failed(e: &impl std::fmt::Display) -> String {
176    format!("Failed to save config: {}", e)
177}
178
179pub fn included_host_read_only(alias: &str) -> String {
180    format!(
181        "Host '{}' is from an included file and cannot be modified.",
182        alias
183    )
184}
185
186pub fn operation_failed(e: &impl std::fmt::Display) -> String {
187    format!("Failed: {}", e)
188}
189
190// ── Snippet CLI ─────────────────────────────────────────────────
191
192pub const NO_SNIPPETS: &str = "No snippets configured. Use 'purple snippet add' to create one.";
193
194pub use super::snippet_added;
195pub use super::snippet_removed;
196pub use super::snippet_updated;
197
198pub fn snippet_not_found(name: &str) -> String {
199    format!("No snippet '{}' found.", name)
200}
201
202pub fn no_hosts_with_tag(tag: &str) -> String {
203    format!("No hosts found with tag '{}'.", tag)
204}
205
206pub const SPECIFY_TARGET: &str = "Specify a host alias, --tag or --all.";
207
208// ── Run/exec output ─────────────────────────────────────────────
209
210pub fn beaming_up(alias: &str) -> String {
211    format!("Beaming you up to {}...\n", alias)
212}
213
214pub fn running_snippet_on(name: &str, alias: &str) -> String {
215    format!("Running '{}' on {}...\n", name, alias)
216}
217
218pub fn host_separator(alias: &str) -> String {
219    format!("── {} ──", alias)
220}
221
222pub fn exited_with_code(code: i32) -> String {
223    format!("Exited with code {}.", code)
224}
225
226pub const DONE: &str = "Done.";
227
228pub fn done_multi(name: &str, count: usize) -> String {
229    format!("Done. Ran '{}' on {} hosts.", name, count)
230}
231
232pub const PRESS_ENTER: &str = "Press Enter to continue...";
233
234pub fn host_failed(alias: &str, e: &impl std::fmt::Display) -> String {
235    format!("[{}] Failed: {}", alias, e)
236}
237
238pub fn skipping_host(alias: &str, e: &impl std::fmt::Display) -> String {
239    format!("Skipping {}: {}", alias, e)
240}
241
242// ── Password CLI ────────────────────────────────────────────────
243
244pub fn password_removed(alias: &str) -> String {
245    format!("Password removed for {}.", alias)
246}
247
248// ── Log CLI ─────────────────────────────────────────────────────
249
250pub fn log_deleted(path: &impl std::fmt::Display) -> String {
251    format!("Log file deleted: {}", path)
252}
253
254pub fn no_log_file(path: &impl std::fmt::Display) -> String {
255    format!("No log file found at {}", path)
256}
257
258// ── Theme CLI ───────────────────────────────────────────────────
259
260pub const BUILTIN_THEMES: &str = "Built-in themes:";
261pub const CUSTOM_THEMES: &str = "\nCustom themes:";
262
263pub fn theme_set(name: &str) -> String {
264    format!("Theme set to: {}", name)
265}
266
267// ── Sync output ─────────────────────────────────────────────────
268
269pub fn syncing(name: &str, summary: &str) -> String {
270    format!("\x1b[2K\rSyncing {}... {}", name, summary)
271}
272
273/// One-shot "Syncing X... " prefix without the cursor-clear/CR escapes.
274/// Used before progress callbacks start emitting overwrite-style updates,
275/// so the user sees something happening even if the provider is slow to
276/// produce the first progress event.
277pub fn syncing_start(name: &str) -> String {
278    format!("Syncing {}... ", name)
279}
280
281/// Rendered before the dot-progress (`\u{2713}` or error) on each
282/// per-host vault sign in the CLI bulk path. The trailing space is
283/// intentional — the success/fail glyph follows on the same line.
284pub fn vault_signing_host(alias: &str) -> String {
285    format!("Signing {}... ", alias)
286}
287
288/// Stderr line emitted by `purple vault sign --all` when a host block
289/// disappeared between the moment we enumerated it and the moment we
290/// tried to write its CertificateFile (rename, delete, race with another
291/// process). The cert is on disk; only the wiring is missing.
292pub fn vault_sign_host_block_gone(alias: &str) -> String {
293    format!(
294        "  warning: {} no longer in ssh config; CertificateFile not written (cert saved on disk)",
295        alias
296    )
297}
298
299/// Single-word "failed." status the CLI sync output appends when a
300/// provider fetch hit a hard error. Mirrors the trailing-status pattern
301/// used by `syncing_start` so the line reads `Syncing X... failed.`.
302pub const SYNC_FAILED: &str = "failed.";
303
304pub fn servers_found_with_failures(count: usize, failures: usize, total: usize) -> String {
305    format!(
306        "{} servers found ({} of {} failed to fetch).",
307        count, failures, total
308    )
309}
310
311pub fn servers_found(count: usize) -> String {
312    format!("{} servers found.", count)
313}
314
315pub fn sync_result(prefix: &str, added: usize, updated: usize, unchanged: usize) -> String {
316    format!(
317        "{}Added {}, updated {}, unchanged {}.",
318        prefix, added, updated, unchanged
319    )
320}
321
322pub fn sync_removed(count: usize) -> String {
323    format!("  Removed {}.", count)
324}
325
326pub fn sync_stale(count: usize) -> String {
327    format!("  Marked {} stale.", count)
328}
329
330pub fn sync_skip_remove(display_name: &str) -> String {
331    format!(
332        "! {}: skipping --remove due to partial failures.",
333        display_name
334    )
335}
336
337pub fn sync_error(display_name: &str, e: &impl std::fmt::Display) -> String {
338    format!("! {}: {}", display_name, e)
339}
340
341pub const SYNC_SKIP_WRITE: &str =
342    "! Skipping config write due to sync failures. Fix the errors and re-run.";
343
344// ── Provider validation (CLI) ───────────────────────────────────
345
346pub const PROXMOX_URL_REQUIRED: &str =
347    "Proxmox requires --url (e.g. --url https://pve.example.com:8006).";
348pub const NETBOX_URL_REQUIRED: &str =
349    "NetBox requires --url (e.g. --url https://netbox.example.com).";
350pub const AWS_REGIONS_REQUIRED: &str =
351    "AWS requires --regions (e.g. --regions us-east-1,eu-west-1).";
352pub const AZURE_REGIONS_REQUIRED: &str =
353    "Azure requires --regions with one or more subscription IDs.";
354pub const GCP_PROJECT_REQUIRED: &str = "GCP requires --project (e.g. --project my-gcp-project-id).";
355pub use super::ALIAS_PREFIX_INVALID;
356
357/// An unknown `--ssm` value. Refused rather than read as off, because a typo
358/// would otherwise leave every host on its IP address without saying so.
359pub fn ssm_mode_invalid(value: &str) -> String {
360    format!(
361        "Unknown --ssm value '{}'. Use off, auto or always.",
362        value.trim()
363    )
364}
365
366/// One line after a bare config is updated, naming the route that keeps a
367/// second account alongside it instead of replacing this one.
368///
369/// Not `--label`: a labeled add is refused while a bare config exists, because
370/// the two spellings cannot be mixed. Labeling the existing config renames it
371/// and rewrites its host markers, which the TUI asks about and a one-shot CLI
372/// add cannot.
373pub fn provider_replaced_hint(provider: &str) -> String {
374    format!(
375        "Updated the existing [{}] config in place. To keep two side by side, add the second one from the TUI with 'a' in the provider list, which names this config first.",
376        provider
377    )
378}
379
380pub const WARN_URL_NOT_USED: &str =
381    "Warning: --url is only used by self-hosted providers (Proxmox, NetBox). Ignoring.";
382pub const WARN_PROFILE_NOT_USED: &str =
383    "Warning: --profile is only used by the AWS provider. Ignoring.";
384pub const WARN_SSM_NOT_USED: &str = "Warning: --ssm is only used by the AWS provider. Ignoring.";
385pub const WARN_PROJECT_NOT_USED: &str =
386    "Warning: --project is only used by the GCP provider. Ignoring.";
387pub const WARN_COMPARTMENT_NOT_USED: &str =
388    "Warning: --compartment is only used by the Oracle provider. Ignoring.";
389pub const WARN_NO_VERIFY_TLS_NOT_USED: &str =
390    "Warning: --no-verify-tls is only used by self-hosted providers (Proxmox, NetBox). Ignoring.";
391pub const WARN_VERIFY_TLS_NOT_USED: &str =
392    "Warning: --verify-tls is only used by self-hosted providers (Proxmox, NetBox). Ignoring.";
393pub const WARN_FILTER_NOT_USED: &str =
394    "Warning: --filter is only used by the NetBox provider. Ignoring.";
395pub const WARN_REGIONS_NOT_USED: &str = "Warning: --regions is only used by the AWS, Scaleway, GCP, Azure and Oracle providers. \
396     Ignoring.";
397
398/// A token was asked for but resolved to nothing. Valid for providers that
399/// read credentials elsewhere, so the save goes through. Saying so out loud
400/// separates a deliberate clear from a script that piped a blank secret.
401pub fn warn_token_resolved_empty(display_name: &str) -> String {
402    format!(
403        "Warning: empty token. {} will look for credentials elsewhere when it syncs.",
404        display_name
405    )
406}
407
408/// Same case, except a stored token gets overwritten by the empty one. The
409/// loss is the part worth naming: nothing else reports it.
410pub fn warn_token_replaced_with_empty(display_name: &str) -> String {
411    format!(
412        "Warning: empty token replaces the one stored for {}. It will look for credentials elsewhere when it syncs.",
413        display_name
414    )
415}
416
417/// Per-host status prefixes for `purple sync` output. Indented two
418/// spaces so the result line aligns under the `Syncing X...` header.
419/// `--dry-run` mode prepends "Would have:" so the user knows nothing
420/// was written.
421pub const SYNC_RESULT_PREFIX_LIVE: &str = "  ";
422pub const SYNC_RESULT_PREFIX_DRY_RUN: &str = "  Would have: ";
423
424/// Stderr line printed by `purple provider add` when the user-supplied
425/// URL does not start with `https://`. CLI-flavoured: tells the user to
426/// use the `--no-verify-tls` flag, distinct from the TUI variant which
427/// references a Verify TLS toggle.
428pub const PROVIDER_URL_REQUIRES_HTTPS: &str =
429    "URL must start with https://. For self-signed certificates use --no-verify-tls.";
430
431/// Reuse the TUI form's `Token can't be empty...` lines verbatim — the
432/// remediation steps (paste a JSON file path, paste an OCI config path,
433/// grab a token from the dashboard) are identical between CLI and TUI.
434pub use super::PROVIDER_TOKEN_REQUIRED_GCP;
435pub use super::PROVIDER_TOKEN_REQUIRED_ORACLE;
436pub use super::azure_subscription_id_invalid;
437pub use super::provider_token_required;
438
439/// Stderr line printed when `purple provider add scaleway` is missing
440/// `--regions`. Mirrors the Azure/GCP/Oracle pattern of including the
441/// concrete flag form in the message.
442pub const SCALEWAY_REGIONS_REQUIRED: &str = "Scaleway requires --regions with one or more zones \
443     (e.g. --regions fr-par-1,nl-ams-1).";
444
445pub const ORACLE_COMPARTMENT_REQUIRED: &str =
446    "Oracle requires --compartment (e.g. --compartment ocid1.compartment.oc1..aaa...).";
447
448// ── Vault CLI ───────────────────────────────────────────────────
449
450pub fn vault_no_role(alias: &str) -> String {
451    format!(
452        "No Vault SSH role configured for '{}'. Set it in the host form \
453         (Vault SSH Role field) or in the provider config (vault_role).",
454        alias
455    )
456}
457
458pub fn vault_cert_signed(path: &impl std::fmt::Display) -> String {
459    format!("Certificate signed: {}", path)
460}
461
462pub fn vault_sign_failed(e: &impl std::fmt::Display) -> String {
463    format!("failed: {}", e)
464}
465
466pub fn vault_config_update_warning(e: &impl std::fmt::Display) -> String {
467    format!("Warning: Failed to update SSH config: {}", e)
468}
469
470// ── List hosts ──────────────────────────────────────────────────
471
472pub const NO_HOSTS: &str = "No hosts configured. Run 'purple' to add some!";
473
474// ── Token ───────────────────────────────────────────────────────
475
476pub const NO_TOKEN: &str =
477    "No token provided. Use --token, --token-stdin, or set PURPLE_TOKEN env var.";
478
479// ── What's new (CLI) ────────────────────────────────────────────
480
481pub mod whats_new {
482    pub const HEADER: &str = "purple release notes";
483}