diff --git a/cmd/nssh/main.go b/cmd/nssh/main.go index f7eb923..d205ac0 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -748,6 +748,8 @@ func newHostCmd() *cobra.Command { cmd.AddCommand(host.NewEditCmd()) cmd.AddCommand(host.NewRemoveCmd()) cmd.AddCommand(host.NewSortCmd()) + cmd.AddCommand(host.NewPruneCmd()) + cmd.AddCommand(host.NewRenameCmd()) ui.ApplyStyledHelpRecursive(cmd) return cmd diff --git a/docs/examples/help/host.txt b/docs/examples/help/host.txt index d05c7dc..c69d412 100644 --- a/docs/examples/help/host.txt +++ b/docs/examples/help/host.txt @@ -7,6 +7,8 @@ $ nssh host --help │ edit Edit host settings │ │ get Show host details │ │ list List all hosts │ +│ prune Remove stale and duplicate hosts │ +│ rename Rename hosts with CNAME changes │ │ rm Remove hosts from config │ │ sort Sort hosts in config │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/docs/examples/help/host/prune.txt b/docs/examples/help/host/prune.txt new file mode 100644 index 0000000..fa6f92b --- /dev/null +++ b/docs/examples/help/host/prune.txt @@ -0,0 +1,10 @@ +$ nssh host prune --help +╭─ Usage ──────────────────────────────────────────────────────────────────────╮ +│ nssh host prune [flags] Remove stale and duplicate hosts │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Global Options ─────────────────────────────────────────────────────────────╮ +│ -e, --explain Print command explanation │ +│ -h, --help Print command help │ +│ -v, --verbose Print debug messages │ +│ -V, --version Print command version │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/docs/examples/help/host/rename.txt b/docs/examples/help/host/rename.txt new file mode 100644 index 0000000..7c95528 --- /dev/null +++ b/docs/examples/help/host/rename.txt @@ -0,0 +1,10 @@ +$ nssh host rename --help +╭─ Usage ──────────────────────────────────────────────────────────────────────╮ +│ nssh host rename [flags] Rename hosts with CNAME changes │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Global Options ─────────────────────────────────────────────────────────────╮ +│ -e, --explain Print command explanation │ +│ -h, --help Print command help │ +│ -v, --verbose Print debug messages │ +│ -V, --version Print command version │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/internal/cli/host/dns.go b/internal/cli/host/dns.go new file mode 100644 index 0000000..b19f7d9 --- /dev/null +++ b/internal/cli/host/dns.go @@ -0,0 +1,221 @@ +package host + +import ( + "context" + "errors" + "net" + "strings" + "sync" + "time" + + "github.com/ntwrknrd/nssh/internal/ssh/sshconfig" +) + +// dnsResult holds the DNS resolution result for a single host. +type dnsResult struct { + host *sshconfig.HostEntry + target string // what we resolved (HostName or Host) + status string // "ok", "nxdomain", "cname", "timeout", "error", "skip" + detail string // CNAME target, error message, or skip reason +} + +// resolveAllHosts resolves DNS for all hosts concurrently. +// Returns results in the same order as input. +func resolveAllHosts(hosts []*sshconfig.HostEntry) []dnsResult { + results := make([]dnsResult, len(hosts)) + + var wg sync.WaitGroup + sem := make(chan struct{}, 20) // 20-worker pool + + for i, h := range hosts { + results[i].host = h + + // Determine what to resolve + target := h.Host + if h.HostName != "" && h.HostName != h.Host { + target = h.HostName + } + results[i].target = target + + // Skip wildcards + if strings.ContainsAny(target, "*?") { + results[i].status = "skip" + results[i].detail = "wildcard pattern" + continue + } + + // Skip IP addresses + if net.ParseIP(target) != nil { + results[i].status = "skip" + results[i].detail = "IP address" + continue + } + + wg.Add(1) + sem <- struct{}{} + go func(idx int, t string) { + defer wg.Done() + defer func() { <-sem }() + results[idx].status, results[idx].detail = resolveOne(t) + }(i, target) + } + + wg.Wait() + return results +} + +// resolveOne performs DNS lookup for a single target with retry on timeout. +// After retries exhaust, persistent timeouts are promoted to "timeout" status +// so they can be surfaced as prune candidates separately from real errors. +func resolveOne(target string) (string, string) { + const maxAttempts = 3 + + for attempt := range maxAttempts { + status, detail := resolveOnce(target) + if status != "error" { + return status, detail + } + // Only retry on timeout errors + if !strings.Contains(detail, "i/o timeout") { + return status, detail + } + if attempt == maxAttempts-1 { + return "timeout", "" + } + time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond) + } + return "timeout", "" // should not happen +} + +func resolveOnce(target string) (string, string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := net.DefaultResolver.LookupHost(ctx, target) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return "nxdomain", "" + } + return "error", err.Error() + } + + // Check for CNAME + cname, err := net.LookupCNAME(target) + if err == nil { + cname = strings.TrimSuffix(cname, ".") + if !strings.EqualFold(cname, target) { + return "cname", cname + } + } + + return "ok", "" +} + +// dnsSummary holds counts from DNS resolution. +type dnsSummary struct { + ok int + nxdomain int + cname int + timeout int + skip int + errCount int +} + +// deduplicateHosts removes duplicate host entries (same Host name). +// Keeps the first occurrence of each. +func deduplicateHosts(hosts []*sshconfig.HostEntry) []*sshconfig.HostEntry { + seen := make(map[string]bool) + result := make([]*sshconfig.HostEntry, 0, len(hosts)) + for _, h := range hosts { + key := strings.ToLower(h.Host) + if seen[key] { + continue + } + seen[key] = true + result = append(result, h) + } + return result +} + +// findDuplicatesInFile returns host entries that share a Host name +// within the same source file. For each set of N duplicates, returns +// the last N-1 entries (keeping the first as the canonical one). +func findDuplicatesInFile(hosts []*sshconfig.HostEntry) []*sshconfig.HostEntry { + // key: lowercase(host) + "\x00" + sourceFile + type entry struct { + count int + hosts []*sshconfig.HostEntry + } + seen := make(map[string]*entry) + + for _, h := range hosts { + key := strings.ToLower(h.Host) + "\x00" + h.SourceFile + e, ok := seen[key] + if !ok { + e = &entry{} + seen[key] = e + } + e.count++ + e.hosts = append(e.hosts, h) + } + + var dupes []*sshconfig.HostEntry + for _, e := range seen { + if e.count > 1 { + // Skip the first (canonical), return the rest as duplicates + dupes = append(dupes, e.hosts[1:]...) + } + } + return dupes +} + +// removeHostByLines removes exactly one host entry from the list by matching +// its raw Lines slice. This allows removing a specific duplicate without +// removing all entries that share the same Host name. +func removeHostByLines(hosts []*sshconfig.HostEntry, lines []string) []*sshconfig.HostEntry { + removed := false + result := make([]*sshconfig.HostEntry, 0, len(hosts)) + for _, h := range hosts { + if !removed && linesEqual(h.Lines, lines) { + removed = true + continue + } + result = append(result, h) + } + return result +} + +func linesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// summarizeDNS counts results by status. +func summarizeDNS(results []dnsResult) dnsSummary { + var s dnsSummary + for _, r := range results { + switch r.status { + case "ok": + s.ok++ + case "nxdomain": + s.nxdomain++ + case "cname": + s.cname++ + case "timeout": + s.timeout++ + case "skip": + s.skip++ + case "error": + s.errCount++ + } + } + return s +} diff --git a/internal/cli/host/prune.go b/internal/cli/host/prune.go new file mode 100644 index 0000000..523087c --- /dev/null +++ b/internal/cli/host/prune.go @@ -0,0 +1,219 @@ +package host + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/ntwrknrd/nssh/internal/exit" + "github.com/ntwrknrd/nssh/internal/ssh/sshconfig" + "github.com/ntwrknrd/nssh/internal/ui" + "github.com/spf13/cobra" +) + +// NewPruneCmd creates the host prune command. +func NewPruneCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "prune", + Short: "Remove stale and duplicate hosts", + Long: `Detect hosts whose DNS names no longer resolve (NXDOMAIN) or that +appear as duplicates within the same config file, and remove them. + +Runs DNS lookups against all configured hosts, then presents an +interactive selection of stale and duplicate entries to remove.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPrune() + }, + } + + return cmd +} + +// pruneCandidate is a host flagged for potential removal. +type pruneCandidate struct { + host *sshconfig.HostEntry + reason string // "NXDOMAIN" or "duplicate" +} + +func runPrune() error { + parser := getParser() + + ui.CommandStart("PRUNE HOSTS") + + // Build context mapping upfront (may trigger vault unlock prompt) + fileToContext := buildFileToContext() + + hosts, err := parser.GetAllHosts() + if err != nil { + ui.Error("Failed to get hosts: %s", err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + if len(hosts) == 0 { + ui.Warning("No hosts configured") + ui.CommandEnd(ui.StatusWarning) + return nil + } + + // Find duplicates within the same file before deduplicating for DNS + dupes := findDuplicatesInFile(hosts) + unique := deduplicateHosts(hosts) + + fmt.Printf(" Checking %d hosts...\n", len(unique)) + fmt.Println() + + results := resolveAllHosts(unique) + summary := summarizeDNS(results) + + // Show summary counts (include duplicate count) + printPruneSummary(summary, len(dupes)) + + // Show error details if any + printDNSErrors(results) + + // Build candidate list: NXDOMAIN + timeout + duplicates + var candidates []pruneCandidate + for _, r := range results { + switch r.status { + case "nxdomain": + candidates = append(candidates, pruneCandidate{host: r.host, reason: "NXDOMAIN"}) + case "timeout": + candidates = append(candidates, pruneCandidate{host: r.host, reason: "timeout"}) + } + } + for _, d := range dupes { + candidates = append(candidates, pruneCandidate{host: d, reason: "duplicate"}) + } + + if len(candidates) == 0 { + fmt.Println() + ui.Info("No stale or duplicate hosts detected") + ui.CommandEnd(ui.StatusNoop) + return nil + } + + // Display table + fmt.Println() + tbl := ui.NewTable("Host", "HostName", "Context", "Reason") + for _, c := range candidates { + ctx := contextForHost(c.host, fileToContext) + hostname := c.host.HostName + if hostname == "" || hostname == c.host.Host { + hostname = "-" + } + tbl.AddRow(c.host.Host, hostname, ctx, c.reason) + } + tbl.Render() + fmt.Println() + + // Multi-select + options := make([]ui.FuzzySelectOption, len(candidates)) + for i, c := range candidates { + options[i] = ui.FuzzySelectOption{ + Label: fmt.Sprintf("%s (%s)", c.host.Host, c.reason), + Value: c.host.Host, + } + } + + indices, err := ui.FuzzySelectMulti("Select hosts to remove", options) + if err != nil { + ui.Abort("Selection failed: %s", err) + ui.CommandEnd(ui.StatusAbort) + return nil + } + if len(indices) == 0 { + ui.Abort("No hosts selected") + ui.CommandEnd(ui.StatusAbort) + return nil + } + + // Group selected hosts by source file, tracking reason + type removal struct { + host *sshconfig.HostEntry + reason string + } + byFile := make(map[string][]removal) + for _, idx := range indices { + c := candidates[idx] + byFile[c.host.SourceFile] = append(byFile[c.host.SourceFile], removal{host: c.host, reason: c.reason}) + } + + // Process each file + ui.SubSection("Removing") + + backedUp := make(map[string]bool) + for path, removals := range byFile { + cfg, err := parser.ParseFile(path) + if err != nil { + for _, rm := range removals { + ui.Error("%s: parse failed: %s", rm.host.Host, err) + } + continue + } + + // Backup once per file + if !backedUp[path] { + if err := createBackup(path, getBackupDir(), getMaxBackups()); err != nil { + ui.Warning("Backup failed for %s: %v", abbreviateHome(path), err) + } + backedUp[path] = true + } + + // Remove hosts + for _, rm := range removals { + if rm.reason == "duplicate" { + // Remove only the duplicate entry, not all copies. + // Match by line number of the Host directive. + cfg.Hosts = removeHostByLines(cfg.Hosts, rm.host.Lines) + } else { + cfg.Hosts = sshconfig.RemoveHost(cfg.Hosts, rm.host.Host) + } + ui.Success("%s", rm.host.Host) + } + + // Write updated config + if err := parser.WriteFile(cfg); err != nil { + ui.Error("Failed to write %s: %s", abbreviateHome(path), err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + // Clean up empty context + if len(cfg.Hosts) == 0 { + cleanupEmptyContext(path, map[string]string{ + filepath.Base(path): fileToContext[filepath.Base(path)], + }) + } + } + + ui.CommandEnd(ui.StatusSuccess) + return nil +} + +// printPruneSummary prints the DNS summary plus duplicate count. +func printPruneSummary(s dnsSummary, dupeCount int) { + var parts []string + if s.ok > 0 { + parts = append(parts, fmt.Sprintf("[*] %d resolved", s.ok)) + } + if s.nxdomain > 0 { + parts = append(parts, fmt.Sprintf("[!] %d NXDOMAIN", s.nxdomain)) + } + if s.cname > 0 { + parts = append(parts, fmt.Sprintf("[*] %d renamed (CNAME)", s.cname)) + } + if s.timeout > 0 { + parts = append(parts, fmt.Sprintf("[!] %d timeout", s.timeout)) + } + if dupeCount > 0 { + parts = append(parts, fmt.Sprintf("[!] %d duplicates", dupeCount)) + } + if s.skip > 0 { + parts = append(parts, fmt.Sprintf("[*] %d skipped", s.skip)) + } + if s.errCount > 0 { + parts = append(parts, fmt.Sprintf("[!] %d errors", s.errCount)) + } + fmt.Printf(" %s\n", strings.Join(parts, " ")) +} diff --git a/internal/cli/host/rename.go b/internal/cli/host/rename.go new file mode 100644 index 0000000..d97c065 --- /dev/null +++ b/internal/cli/host/rename.go @@ -0,0 +1,378 @@ +package host + +import ( + "fmt" + "path/filepath" + "strings" + + clisession "github.com/ntwrknrd/nssh/internal/cli/session" + "github.com/ntwrknrd/nssh/internal/exit" + "github.com/ntwrknrd/nssh/internal/ssh/sshconfig" + "github.com/ntwrknrd/nssh/internal/ui" + "github.com/ntwrknrd/nssh/internal/vault" + "github.com/spf13/cobra" +) + +// NewRenameCmd creates the host rename command. +func NewRenameCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rename", + Short: "Rename hosts with CNAME changes", + Long: `Detect hosts whose DNS names have changed via CNAME records and +update the SSH config to match. + +For each renamed host, the old Host alias is kept so existing scripts +and muscle memory continue to work. When the target host already exists, +the old entry is merged as an alias on the existing entry.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runRename() + }, + } + + return cmd +} + +func runRename() error { + parser := getParser() + + ui.CommandStart("RENAME HOSTS") + + // Build context mapping upfront (may trigger vault unlock prompt) + _ = buildFileToContext() + + hosts, err := parser.GetAllHosts() + if err != nil { + ui.Error("Failed to get hosts: %s", err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + + if len(hosts) == 0 { + ui.Warning("No hosts configured") + ui.CommandEnd(ui.StatusWarning) + return nil + } + + hosts = deduplicateHosts(hosts) + + fmt.Printf(" Checking %d hosts...\n", len(hosts)) + fmt.Println() + + results := resolveAllHosts(hosts) + summary := summarizeDNS(results) + + // Show summary counts + printDNSSummary(summary) + + // Show error details if any + printDNSErrors(results) + + // Filter to CNAME results + var cnameResults []dnsResult + for _, r := range results { + if r.status == "cname" { + cnameResults = append(cnameResults, r) + } + } + + if len(cnameResults) == 0 { + fmt.Println() + ui.Info("No renamed hosts detected") + ui.CommandEnd(ui.StatusNoop) + return nil + } + + // Display table + fmt.Println() + tbl := ui.NewTable("Host", "Current HostName", "New HostName") + for _, r := range cnameResults { + tbl.AddRow(r.host.Host, r.target, r.detail) + } + tbl.Render() + fmt.Println() + + // Multi-select + options := make([]ui.FuzzySelectOption, len(cnameResults)) + for i, r := range cnameResults { + options[i] = ui.FuzzySelectOption{ + Label: fmt.Sprintf("%s -> %s", r.host.Host, sshconfig.DeriveHostID(r.detail)), + Value: r.host.Host, + } + } + + indices, err := ui.FuzzySelectMulti("Select hosts to rename", options) + if err != nil { + ui.Abort("Selection failed: %s", err) + ui.CommandEnd(ui.StatusAbort) + return nil + } + if len(indices) == 0 { + ui.Abort("No hosts selected") + ui.CommandEnd(ui.StatusAbort) + return nil + } + + // Build host index for collision detection and merge targets + allHosts, _ := parser.GetAllHosts() + hostByID := make(map[string]*sshconfig.HostEntry) + for _, h := range allHosts { + hostByID[strings.ToLower(h.Host)] = h + } + + // Classify operations: rename (no collision) or merge (target exists) + type renameOp struct { + host *sshconfig.HostEntry + newID string + cnameTarget string + } + type mergeOp struct { + old *sshconfig.HostEntry // entry to remove + target *sshconfig.HostEntry // existing entry to add alias to + alias string // old Host ID to add as alias + } + + byFile := make(map[string][]renameOp) + var merges []mergeOp + + for _, idx := range indices { + r := cnameResults[idx] + newID := sshconfig.DeriveHostID(r.detail) + + if strings.EqualFold(newID, r.host.Host) { + // Same ID -- just update HostName, no collision + byFile[r.host.SourceFile] = append(byFile[r.host.SourceFile], renameOp{ + host: r.host, + newID: newID, + cnameTarget: r.detail, + }) + continue + } + + existing, hasCollision := hostByID[strings.ToLower(newID)] + if !hasCollision { + byFile[r.host.SourceFile] = append(byFile[r.host.SourceFile], renameOp{ + host: r.host, + newID: newID, + cnameTarget: r.detail, + }) + } else { + merges = append(merges, mergeOp{ + old: r.host, + target: existing, + alias: r.host.Host, + }) + } + } + + if len(byFile) == 0 && len(merges) == 0 { + ui.Info("No changes to apply") + ui.CommandEnd(ui.StatusNoop) + return nil + } + + // Process renames + ui.SubSection("Updating") + + backedUp := make(map[string]bool) + backupFile := func(path string) { + if !backedUp[path] { + if err := createBackup(path, getBackupDir(), getMaxBackups()); err != nil { + ui.Warning("Backup failed for %s: %v", abbreviateHome(path), err) + } + backedUp[path] = true + } + } + + for path, ops := range byFile { + cfg, err := parser.ParseFile(path) + if err != nil { + for _, op := range ops { + ui.Error("%s: parse failed: %s", op.host.Host, err) + } + continue + } + + backupFile(path) + + for _, op := range ops { + host := sshconfig.FindHostByPattern(cfg.Hosts, op.host.Host) + if host == nil { + ui.Error("%s: not found in %s", op.host.Host, abbreviateHome(path)) + continue + } + + oldID := host.Host + + // Update Host line: "Host " + for i, line := range host.Lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), "host ") { + host.Lines[i] = fmt.Sprintf("Host %s %s\n", op.newID, oldID) + break + } + } + + // Update HostName + updateHostProperty(host, "hostname", op.cnameTarget) + + // Update in-memory fields + host.Host = op.newID + host.Patterns = []string{op.newID, oldID} + host.HostName = op.cnameTarget + + ui.Success("%s -> %s (alias: %s)", oldID, op.newID, oldID) + } + + if err := parser.WriteFile(cfg); err != nil { + ui.Error("Failed to write %s: %s", abbreviateHome(path), err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + } + + // Process merges: add alias to existing entry, remove old entry + if len(merges) > 0 { + // Collect all files we need to touch + // For each merge: target file (add alias) + old file (remove entry) + type fileEdit struct { + addAliases map[string]string // host pattern -> alias to add + removePatterns []string + } + edits := make(map[string]*fileEdit) + + getEdit := func(path string) *fileEdit { + if e, ok := edits[path]; ok { + return e + } + e := &fileEdit{addAliases: make(map[string]string)} + edits[path] = e + return e + } + + for _, m := range merges { + // Add alias to target's file + targetEdit := getEdit(m.target.SourceFile) + targetEdit.addAliases[strings.ToLower(m.target.Host)] = m.alias + + // Remove old entry from its file + oldEdit := getEdit(m.old.SourceFile) + oldEdit.removePatterns = append(oldEdit.removePatterns, m.old.Host) + } + + for path, edit := range edits { + cfg, err := parser.ParseFile(path) + if err != nil { + ui.Error("parse %s: %s", abbreviateHome(path), err) + continue + } + + backupFile(path) + + // Add aliases + for hostPattern, alias := range edit.addAliases { + host := sshconfig.FindHostByPattern(cfg.Hosts, hostPattern) + if host == nil { + continue + } + // Append alias to Host line + for i, line := range host.Lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), "host ") { + // Add alias at the end of the Host line + host.Lines[i] = strings.TrimRight(line, "\n") + " " + alias + "\n" + break + } + } + host.Patterns = append(host.Patterns, alias) + } + + // Remove old entries + for _, pattern := range edit.removePatterns { + cfg.Hosts = sshconfig.RemoveHost(cfg.Hosts, pattern) + } + + if err := parser.WriteFile(cfg); err != nil { + ui.Error("Failed to write %s: %s", abbreviateHome(path), err) + ui.CommandEnd(ui.StatusError) + return &exit.ExitError{Code: 1} + } + } + + for _, m := range merges { + ui.Success("%s merged into %s (alias: %s)", m.old.Host, m.target.Host, m.alias) + } + } + + ui.CommandEnd(ui.StatusSuccess) + return nil +} + +// printDNSSummary prints a one-line summary of DNS resolution results. +func printDNSSummary(s dnsSummary) { + var parts []string + if s.ok > 0 { + parts = append(parts, fmt.Sprintf("[*] %d resolved", s.ok)) + } + if s.nxdomain > 0 { + parts = append(parts, fmt.Sprintf("[!] %d NXDOMAIN", s.nxdomain)) + } + if s.cname > 0 { + parts = append(parts, fmt.Sprintf("[*] %d renamed (CNAME)", s.cname)) + } + if s.timeout > 0 { + parts = append(parts, fmt.Sprintf("[!] %d timeout", s.timeout)) + } + if s.skip > 0 { + parts = append(parts, fmt.Sprintf("[*] %d skipped", s.skip)) + } + if s.errCount > 0 { + parts = append(parts, fmt.Sprintf("[!] %d errors", s.errCount)) + } + fmt.Printf(" %s\n", strings.Join(parts, " ")) +} + +// printDNSErrors lists hosts that had DNS resolution errors. +func printDNSErrors(results []dnsResult) { + var errResults []dnsResult + for _, r := range results { + if r.status == "error" { + errResults = append(errResults, r) + } + } + if len(errResults) == 0 { + return + } + + fmt.Println() + ui.SubSection("DNS Errors", true) + for _, r := range errResults { + ui.Warning("%s: %s", r.host.Host, r.detail) + } +} + +// buildFileToContext creates a mapping from include filename to context name. +func buildFileToContext() map[string]string { + fileToContext := make(map[string]string) + mgr, err := clisession.NewManager(vault.Auto()) + if err != nil { + return fileToContext + } + _ = clisession.TryUnlockIfTTY(mgr) + contexts, err := mgr.ListContexts() + if err != nil { + return fileToContext + } + for _, ctx := range contexts { + fileToContext[ctx.GitIncludeFile] = ctx.Name + } + return fileToContext +} + +// contextForHost returns the context name for a host entry, or the filename. +func contextForHost(host *sshconfig.HostEntry, fileToContext map[string]string) string { + base := filepath.Base(host.SourceFile) + if name, ok := fileToContext[base]; ok { + return name + } + return base +}