From 7d9b794ceb98bd68e065bdbea84a68c89427c0dc Mon Sep 17 00:00:00 2001 From: David Ibia Date: Mon, 12 Jan 2026 23:53:35 +0100 Subject: [PATCH] feat: add info command for JSON host details Adds a jq-friendly `sshm info` subcommand with host completion and documentation, and makes home directory resolution testable for backup path tests. --- README.md | 24 +++ cmd/info.go | 199 ++++++++++++++++++++++ cmd/info_test.go | 321 ++++++++++++++++++++++++++++++++++++ cmd/root_test.go | 2 +- internal/config/ssh.go | 22 ++- internal/config/ssh_test.go | 14 +- 6 files changed, 572 insertions(+), 10 deletions(-) create mode 100644 cmd/info.go create mode 100644 cmd/info_test.go diff --git a/README.md b/README.md index 21c58a1..ac205eb 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,17 @@ sshm move my-server -c /path/to/custom/ssh_config # Search for hosts (interactive filter) sshm search +# Print machine-readable info (JSON) for scripting +sshm info prod-server +sshm info prod-server --pretty + +# With a custom SSH config file +sshm -c /path/to/custom/ssh_config info prod-server + +# Pipe to jq +sshm info prod-server | jq -r '.result.target.hostname' +sshm info prod-server | jq -r '.result.target.user' + # Show version information (includes update check) sshm --version @@ -275,6 +286,19 @@ sshm --version sshm --help ``` +### Host Info (JSON) + +`sshm info ` prints a single JSON object to stdout so you can script against it with `jq`. + +```bash +# Extract fields +sshm info prod-server | jq -r '.result.target.hostname' +sshm info prod-server | jq -r '.result.target.port' + +# Check not-found (exit code 2) +sshm info does-not-exist | jq -r '.error.code' +``` + ### Shell Completion SSHM supports shell completion for host names, making it easy to connect to hosts without typing full names: diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..4c9df50 --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "encoding/json" + "io" + "os" + "strconv" + "strings" + + "github.com/Gu1llaum-3/sshm/internal/config" + + "github.com/spf13/cobra" +) + +type infoResponse struct { + Schema string `json:"schema"` + OK bool `json:"ok"` + Hostname string `json:"hostname"` + Result *infoResult `json:"result"` + Error *infoError `json:"error"` +} + +type infoResult struct { + CanonicalName string `json:"canonical_name"` + Target infoTarget `json:"target"` + IdentityFile *string `json:"identity_file"` + ProxyJump *string `json:"proxy_jump"` + ProxyCommand *string `json:"proxy_command"` + Options *string `json:"options"` + Tags []string `json:"tags"` + RemoteCommand *string `json:"remote_command"` + RequestTTY *string `json:"request_tty"` + Source *infoSource `json:"source"` +} + +type infoTarget struct { + Host string `json:"host"` + Hostname *string `json:"hostname"` + User *string `json:"user"` + Port *int `json:"port"` +} + +type infoSource struct { + File string `json:"file"` + Line int `json:"line"` +} + +type infoError struct { + Code string `json:"code"` + Message string `json:"message"` + Details json.RawMessage `json:"details"` +} + +func maybeString(v string) *string { + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return nil + } + return &trimmed +} + +func maybePort(v string) (*int, error) { + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return nil, nil + } + port, err := strconv.Atoi(trimmed) + if err != nil { + return nil, err + } + return &port, nil +} + +func writeInfoJSON(out io.Writer, pretty bool, resp infoResponse) { + var b []byte + var err error + if pretty { + b, err = json.MarshalIndent(resp, "", " ") + } else { + b, err = json.Marshal(resp) + } + if err != nil { + _, _ = io.WriteString(out, `{"schema":"sshm.info.v1","ok":false,"hostname":"","result":null,"error":{"code":"INTERNAL","message":"failed to marshal JSON","details":null}}\n`) + return + } + _, _ = out.Write(append(b, '\n')) +} + +func runInfo(out io.Writer, hostnameArg string, cfgFile string, pretty bool) int { + resp := infoResponse{ + Schema: "sshm.info.v1", + OK: false, + Hostname: hostnameArg, + Result: nil, + Error: nil, + } + + var host *config.SSHHost + var err error + if cfgFile != "" { + host, err = config.GetSSHHostFromFile(hostnameArg, cfgFile) + } else { + host, err = config.GetSSHHost(hostnameArg) + } + if err != nil { + code := 1 + errCode := "CONFIG_ERROR" + msg := err.Error() + if strings.Contains(msg, "not found") { + code = 2 + errCode = "NOT_FOUND" + } + + resp.Error = &infoError{Code: errCode, Message: msg, Details: nil} + writeInfoJSON(out, pretty, resp) + return code + } + + port, portErr := maybePort(host.Port) + if portErr != nil { + resp.Error = &infoError{Code: "CONFIG_ERROR", Message: "invalid port in host configuration", Details: nil} + writeInfoJSON(out, pretty, resp) + return 1 + } + + res := infoResult{ + CanonicalName: host.Name, + Target: infoTarget{ + Host: hostnameArg, + Hostname: maybeString(host.Hostname), + User: maybeString(host.User), + Port: port, + }, + IdentityFile: maybeString(host.Identity), + ProxyJump: maybeString(host.ProxyJump), + ProxyCommand: maybeString(host.ProxyCommand), + Options: maybeString(host.Options), + Tags: host.Tags, + RemoteCommand: maybeString(host.RemoteCommand), + RequestTTY: maybeString(host.RequestTTY), + Source: &infoSource{ + File: host.SourceFile, + Line: host.LineNumber, + }, + } + + resp.OK = true + resp.Result = &res + writeInfoJSON(out, pretty, resp) + return 0 +} + +var infoPretty bool + +var infoCmd = &cobra.Command{ + Use: "info ", + Short: "Print machine-readable information about a host", + Long: "Print machine-readable information (JSON) about a configured SSH host.", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var hosts []config.SSHHost + var err error + if configFile != "" { + hosts, err = config.ParseSSHConfigFile(configFile) + } else { + hosts, err = config.ParseSSHConfig() + } + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + toCompleteLower := strings.ToLower(toComplete) + for _, host := range hosts { + if strings.HasPrefix(strings.ToLower(host.Name), toCompleteLower) { + completions = append(completions, host.Name) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + exitCode := runInfo(cmd.OutOrStdout(), args[0], configFile, infoPretty) + if exitCode != 0 { + os.Exit(exitCode) + } + return nil + }, +} + +func init() { + infoCmd.Flags().BoolVar(&infoPretty, "pretty", false, "Pretty-print JSON output") + RootCmd.AddCommand(infoCmd) +} diff --git a/cmd/info_test.go b/cmd/info_test.go new file mode 100644 index 0000000..28b594d --- /dev/null +++ b/cmd/info_test.go @@ -0,0 +1,321 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +type infoResponseForTest struct { + Schema string `json:"schema"` + OK bool `json:"ok"` + Hostname string `json:"hostname"` + Result *infoResultForTest `json:"result"` + Error *infoErrorForTest `json:"error"` +} + +type infoResultForTest struct { + CanonicalName string `json:"canonical_name"` + Target infoTargetForTest `json:"target"` + IdentityFile *string `json:"identity_file"` + ProxyJump *string `json:"proxy_jump"` + ProxyCommand *string `json:"proxy_command"` + Options *string `json:"options"` + Tags []string `json:"tags"` + RemoteCommand *string `json:"remote_command"` + RequestTTY *string `json:"request_tty"` + Source *infoSourceForTest `json:"source"` +} + +type infoTargetForTest struct { + Host string `json:"host"` + Hostname *string `json:"hostname"` + User *string `json:"user"` + Port *int `json:"port"` +} + +type infoSourceForTest struct { + File string `json:"file"` + Line int `json:"line"` +} + +type infoErrorForTest struct { + Code string `json:"code"` + Message string `json:"message"` + Details json.RawMessage `json:"details"` +} + +func TestInfoCommandConfig(t *testing.T) { + if infoCmd.Use != "info " { + t.Fatalf("infoCmd.Use=%q", infoCmd.Use) + } + + err := infoCmd.Args(infoCmd, []string{}) + if err == nil { + t.Fatalf("expected args error for no args") + } + + err = infoCmd.Args(infoCmd, []string{"one", "two"}) + if err == nil { + t.Fatalf("expected args error for too many args") + } + + err = infoCmd.Args(infoCmd, []string{"host"}) + if err != nil { + t.Fatalf("expected no args error, got %v", err) + } +} + +func TestInfoCommandRegistration(t *testing.T) { + found := false + for _, c := range RootCmd.Commands() { + if c.Name() == "info" { + found = true + break + } + } + if !found { + t.Fatalf("info command not registered") + } +} + +func TestRunInfoSuccessJSON(t *testing.T) { + tempDir := t.TempDir() + cfg := filepath.Join(tempDir, "config") + + cfgContent := `# Tags: prod, web +Host prod-web + HostName 10.0.0.10 + User deploy + Port 2222 + IdentityFile ~/.ssh/id_prod + ProxyJump bastion + ServerAliveInterval 60 +` + + if err := os.WriteFile(cfg, []byte(cfgContent), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + + buf := new(bytes.Buffer) + exitCode := runInfo(buf, "prod-web", cfg, false) + if exitCode != 0 { + t.Fatalf("exitCode=%d", exitCode) + } + + out := buf.String() + if strings.TrimSpace(out) == "" { + t.Fatalf("expected output") + } + + var resp infoResponseForTest + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output not JSON: %v\noutput=%q", err, out) + } + + if resp.Schema != "sshm.info.v1" { + t.Fatalf("schema=%q", resp.Schema) + } + if !resp.OK { + t.Fatalf("ok=false") + } + if resp.Result == nil { + t.Fatalf("result is nil") + } + if resp.Error != nil { + t.Fatalf("error is non-nil") + } + + if resp.Result.CanonicalName != "prod-web" { + t.Fatalf("canonical_name=%q", resp.Result.CanonicalName) + } + if resp.Result.Target.Host != "prod-web" { + t.Fatalf("target.host=%q", resp.Result.Target.Host) + } + if resp.Result.Target.Hostname == nil || *resp.Result.Target.Hostname != "10.0.0.10" { + t.Fatalf("target.hostname=%v", resp.Result.Target.Hostname) + } + if resp.Result.Target.User == nil || *resp.Result.Target.User != "deploy" { + t.Fatalf("target.user=%v", resp.Result.Target.User) + } + if resp.Result.Target.Port == nil || *resp.Result.Target.Port != 2222 { + t.Fatalf("target.port=%v", resp.Result.Target.Port) + } + if resp.Result.Source == nil || resp.Result.Source.File == "" || resp.Result.Source.Line == 0 { + t.Fatalf("source missing: %#v", resp.Result.Source) + } + if resp.Result.IdentityFile == nil || *resp.Result.IdentityFile != "~/.ssh/id_prod" { + t.Fatalf("identity_file=%v", resp.Result.IdentityFile) + } + if resp.Result.ProxyJump == nil || *resp.Result.ProxyJump != "bastion" { + t.Fatalf("proxy_jump=%v", resp.Result.ProxyJump) + } +} + +func TestRunInfoNotFoundJSON(t *testing.T) { + tempDir := t.TempDir() + cfg := filepath.Join(tempDir, "config") + cfgContent := `Host known + HostName example.com +` + if err := os.WriteFile(cfg, []byte(cfgContent), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + + buf := new(bytes.Buffer) + exitCode := runInfo(buf, "missing", cfg, false) + if exitCode != 2 { + t.Fatalf("exitCode=%d", exitCode) + } + + var resp infoResponseForTest + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("output not JSON: %v", err) + } + if resp.OK { + t.Fatalf("ok=true") + } + if resp.Error == nil { + t.Fatalf("error is nil") + } + if resp.Error.Code != "NOT_FOUND" { + t.Fatalf("error.code=%q", resp.Error.Code) + } +} + +func TestRunInfoPrettyJSON(t *testing.T) { + tempDir := t.TempDir() + cfg := filepath.Join(tempDir, "config") + cfgContent := `Host known + HostName 127.0.0.1 +` + if err := os.WriteFile(cfg, []byte(cfgContent), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + + buf := new(bytes.Buffer) + exitCode := runInfo(buf, "known", cfg, true) + if exitCode != 0 { + t.Fatalf("exitCode=%d", exitCode) + } + + out := buf.String() + if !strings.Contains(out, "\n") { + t.Fatalf("expected pretty output") + } + + var resp infoResponseForTest + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("output not JSON: %v", err) + } + if !resp.OK { + t.Fatalf("ok=false") + } +} + +func TestInfoValidArgsFunction(t *testing.T) { + if infoCmd.ValidArgsFunction == nil { + t.Fatalf("expected ValidArgsFunction to be set on infoCmd") + } +} + +func TestInfoValidArgsFunctionWithSSHConfig(t *testing.T) { + tmpDir := t.TempDir() + testConfigFile := filepath.Join(tmpDir, "config") + + sshConfig := `Host prod-server + HostName 192.168.1.1 + User admin + +Host dev-server + HostName 192.168.1.2 + User developer + +Host staging-db + HostName 192.168.1.3 + User dbadmin +` + if err := os.WriteFile(testConfigFile, []byte(sshConfig), 0600); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + originalConfigFile := configFile + defer func() { configFile = originalConfigFile }() + configFile = testConfigFile + + tests := []struct { + name string + toComplete string + args []string + wantCount int + wantHosts []string + }{ + { + name: "empty prefix returns all hosts", + toComplete: "", + args: []string{}, + wantCount: 3, + wantHosts: []string{"prod-server", "dev-server", "staging-db"}, + }, + { + name: "prefix filters hosts", + toComplete: "prod", + args: []string{}, + wantCount: 1, + wantHosts: []string{"prod-server"}, + }, + { + name: "prefix case insensitive", + toComplete: "DEV", + args: []string{}, + wantCount: 1, + wantHosts: []string{"dev-server"}, + }, + { + name: "no match returns empty", + toComplete: "nonexistent", + args: []string{}, + wantCount: 0, + wantHosts: []string{}, + }, + { + name: "already has host arg returns nothing", + toComplete: "", + args: []string{"existing-host"}, + wantCount: 0, + wantHosts: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + completions, directive := infoCmd.ValidArgsFunction(infoCmd, tt.args, tt.toComplete) + + if len(completions) != tt.wantCount { + t.Fatalf("Expected %d completions, got %d: %v", tt.wantCount, len(completions), completions) + } + + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("Expected ShellCompDirectiveNoFileComp, got %v", directive) + } + + for _, wantHost := range tt.wantHosts { + found := false + for _, comp := range completions { + if comp == wantHost { + found = true + break + } + } + if !found { + t.Fatalf("Expected completion %q not found in %v", wantHost, completions) + } + } + }) + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go index e56c39b..c90a0df 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -45,7 +45,7 @@ func TestRootCommandFlags(t *testing.T) { func TestRootCommandSubcommands(t *testing.T) { // Test that all expected subcommands are registered // Note: completion and help are automatically added by Cobra and may not always appear in Commands() - expectedCommands := []string{"add", "edit", "search"} + expectedCommands := []string{"add", "edit", "search", "info"} commands := RootCmd.Commands() commandNames := make(map[string]bool) diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 5d95d83..b7c9112 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -11,6 +11,18 @@ import ( "sync" ) +func getHomeDir() (string, error) { + home := os.Getenv("HOME") + if home != "" { + return home, nil + } + home = os.Getenv("USERPROFILE") + if home != "" { + return home, nil + } + return os.UserHomeDir() +} + // SSHHost represents an SSH host configuration type SSHHost struct { Name string @@ -33,7 +45,7 @@ type SSHHost struct { // GetDefaultSSHConfigPath returns the default SSH config path for the current platform func GetDefaultSSHConfigPath() (string, error) { - homeDir, err := os.UserHomeDir() + homeDir, err := getHomeDir() if err != nil { return "", err } @@ -49,7 +61,7 @@ func GetDefaultSSHConfigPath() (string, error) { // GetSSHMConfigDir returns the SSHM config directory func GetSSHMConfigDir() (string, error) { - homeDir, err := os.UserHomeDir() + homeDir, err := getHomeDir() if err != nil { return "", err } @@ -88,7 +100,7 @@ func GetSSHMBackupDir() (string, error) { // GetSSHDirectory returns the .ssh directory path func GetSSHDirectory() (string, error) { - homeDir, err := os.UserHomeDir() + homeDir, err := getHomeDir() if err != nil { return "", err } @@ -386,7 +398,7 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) { // Expand tilde to home directory if strings.HasPrefix(pattern, "~") { - homeDir, err := os.UserHomeDir() + homeDir, err := getHomeDir() if err != nil { return nil, fmt.Errorf("failed to get home directory: %w", err) } @@ -918,7 +930,7 @@ func quickHostSearchInFile(hostName string, configPath string, processedFiles ma func quickSearchInclude(hostName, pattern, baseConfigPath string, processedFiles map[string]bool) (bool, error) { // Expand tilde to home directory if strings.HasPrefix(pattern, "~") { - homeDir, err := os.UserHomeDir() + homeDir, err := getHomeDir() if err != nil { return false, fmt.Errorf("failed to get home directory: %w", err) } diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index d6c36ff..102e856 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -456,15 +456,21 @@ func TestBackupConfigToSSHMDirectory(t *testing.T) { // Create temporary directory for test files tempDir := t.TempDir() - // Override the home directory for this test originalHome := os.Getenv("HOME") if originalHome == "" { - originalHome = os.Getenv("USERPROFILE") // Windows + originalHome = os.Getenv("USERPROFILE") } + originalXDG := os.Getenv("XDG_CONFIG_HOME") + originalAppData := os.Getenv("APPDATA") - // Set test home directory os.Setenv("HOME", tempDir) - defer os.Setenv("HOME", originalHome) + os.Setenv("XDG_CONFIG_HOME", tempDir) + os.Setenv("APPDATA", tempDir) + defer func() { + os.Setenv("HOME", originalHome) + os.Setenv("XDG_CONFIG_HOME", originalXDG) + os.Setenv("APPDATA", originalAppData) + }() // Create a test SSH config file sshDir := filepath.Join(tempDir, ".ssh")