Skip to content

Commit ab5549d

Browse files
chuccpclaude
andcommitted
refactor: extract utilities, fix SCP port parsing, add terminal resize
- Extract shared helpers (parseUserHostPath, path helpers, fatalError, etc.) into util.go to eliminate duplication across args.go, config.go, sftp.go, ssh.go - Fix SCP -P port value being incorrectly treated as local file path - Add mergeConfig to centralize config merging logic in SCP/rsync modes - Add dynamic terminal resizing with platform-specific signal handling - Add cleanRemotePath for Git Bash path conversion detection - Fix -u flag default: use empty string with applyUserDefault instead of "root" Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 7a20e72 commit ab5549d

10 files changed

Lines changed: 398 additions & 187 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ go.work.sum
3434
/.idea
3535
*.config
3636
*.log
37+
/test_dir
38+
/dltest
39+
*.txt

args.go

Lines changed: 13 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -22,46 +22,9 @@ type ParsedCommand struct {
2222
SCPArgs []string // scp arguments
2323
}
2424

25-
// parseUserHostPath parses user@host:path format, supporting IPv6
26-
// returns user, host, path
27-
func parseUserHostPath(arg string) (user, host, remotePath string) {
28-
atIdx := strings.Index(arg, "@")
29-
if atIdx <= 0 {
30-
return "", "", ""
31-
}
32-
user = arg[:atIdx]
33-
remainder := arg[atIdx+1:]
34-
35-
// check if IPv6 address (starts with [)
36-
if strings.HasPrefix(remainder, "[") {
37-
// IPv6 format: [::1]:path or [2001:db8::1]:path
38-
closeBracket := strings.Index(remainder, "]")
39-
if closeBracket > 0 {
40-
host = remainder[:closeBracket+1] // including square brackets
41-
// check if there is a path after ]:
42-
if closeBracket+1 < len(remainder) && remainder[closeBracket+1] == ':' {
43-
remotePath = remainder[closeBracket+2:]
44-
}
45-
}
46-
} else {
47-
// IPv4 or hostname: host:path
48-
colonIdx := strings.Index(remainder, ":")
49-
if colonIdx > 0 {
50-
host = remainder[:colonIdx]
51-
remotePath = remainder[colonIdx+1:]
52-
} else {
53-
host = remainder
54-
}
55-
}
56-
return user, host, remotePath
57-
}
58-
5925
// parseSSHArgs parses ssh-style arguments (user@host or -p port user@host)
6026
func parseSSHArgs(args []string) (*Config, string) {
61-
config := &Config{
62-
User: "root",
63-
Port: "22",
64-
}
27+
config := newDefaultConfig()
6528
var command string
6629

6730
i := 0
@@ -99,7 +62,7 @@ func parseSSHArgs(args []string) (*Config, string) {
9962
}
10063
// remaining args as command
10164
if config.Host != "" {
102-
command = strings.Join(args[i:], " ")
65+
command = joinArgs(args[i:])
10366
break
10467
}
10568
i++
@@ -110,10 +73,7 @@ func parseSSHArgs(args []string) (*Config, string) {
11073

11174
// parseSCPArgs parses scp command arguments
11275
func parseSCPArgs(args []string) (*Config, []string) {
113-
config := &Config{
114-
User: "root",
115-
Port: "22",
116-
}
76+
config := newDefaultConfig()
11777
var scpArgs []string
11878

11979
i := 0
@@ -126,7 +86,6 @@ func parseSCPArgs(args []string) (*Config, []string) {
12686
if arg == "-P" && i+1 < len(args) {
12787
// scp uses uppercase -P for port
12888
config.Port = args[i+1]
129-
scpArgs = append(scpArgs, "-P", args[i+1])
13089
i += 2
13190
continue
13291
}
@@ -140,12 +99,7 @@ func parseSCPArgs(args []string) (*Config, []string) {
14099
continue
141100
}
142101
if strings.Contains(arg, "@") && strings.Contains(arg, ":") {
143-
// user@host:path format (supports IPv6)
144-
user, host, _ := parseUserHostPath(arg)
145-
if user != "" && host != "" {
146-
config.User = user
147-
config.Host = host
148-
}
102+
config.setUserHostFromArg(arg)
149103
}
150104
scpArgs = append(scpArgs, arg)
151105
i++
@@ -156,10 +110,7 @@ func parseSCPArgs(args []string) (*Config, []string) {
156110

157111
// parseRsyncArgs parses rsync command arguments
158112
func parseRsyncArgs(args []string) (*Config, []string) {
159-
config := &Config{
160-
User: "root",
161-
Port: "22",
162-
}
113+
config := newDefaultConfig()
163114
var rsyncArgs []string
164115

165116
i := 0
@@ -180,18 +131,16 @@ func parseRsyncArgs(args []string) (*Config, []string) {
180131
continue
181132
}
182133
if strings.HasPrefix(arg, "-p") && len(arg) > 2 {
183-
// -p22 format port
184-
config.Port = arg[2:]
185-
i++
186-
continue
134+
// -p22 format port (only match if followed by digits)
135+
portPart := arg[2:]
136+
if isAllDigits(portPart) {
137+
config.Port = portPart
138+
i++
139+
continue
140+
}
187141
}
188142
if strings.Contains(arg, "@") && strings.Contains(arg, ":") {
189-
// user@host:path format (supports IPv6)
190-
user, host, _ := parseUserHostPath(arg)
191-
if user != "" && host != "" {
192-
config.User = user
193-
config.Host = host
194-
}
143+
config.setUserHostFromArg(arg)
195144
}
196145
rsyncArgs = append(rsyncArgs, arg)
197146
i++

config.go

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,81 @@ type Config struct {
1717
StrictHostKey bool // whether to verify host key
1818
}
1919

20+
// newDefaultConfig creates a Config with default values
21+
func newDefaultConfig() *Config {
22+
return &Config{
23+
User: "root",
24+
Port: "22",
25+
}
26+
}
27+
28+
// applyUserDefault sets the user to "root" if empty
29+
func applyUserDefault(cfg *Config) {
30+
if cfg.User == "" {
31+
cfg.User = "root"
32+
}
33+
}
34+
35+
// setUserHostFromArg parses user@host:path format and sets config fields
36+
func (c *Config) setUserHostFromArg(arg string) {
37+
user, host, _ := parseUserHostPath(arg)
38+
if user != "" && host != "" {
39+
c.User = user
40+
c.Host = host
41+
}
42+
}
43+
44+
// validate checks that the config has required fields
45+
func (c *Config) validate() error {
46+
if c.Host == "" {
47+
return fmt.Errorf("host address not specified")
48+
}
49+
if c.Password == "" && c.KeyPath == "" {
50+
return fmt.Errorf("no authentication method provided (password or key required)")
51+
}
52+
return nil
53+
}
54+
55+
// mergeConfig merges non-empty fields from src into dst,
56+
// then applies command-line overrides and user default
57+
func mergeConfig(dst, src *Config, pass, keyPath, host, user, port string) {
58+
// inherit from source (config file)
59+
if src != nil {
60+
if src.Password != "" {
61+
dst.Password = src.Password
62+
}
63+
if src.KeyPath != "" {
64+
dst.KeyPath = src.KeyPath
65+
}
66+
if src.User != "" {
67+
dst.User = src.User
68+
}
69+
if src.Host != "" {
70+
dst.Host = src.Host
71+
}
72+
if src.Port != "" {
73+
dst.Port = src.Port
74+
}
75+
}
76+
// command-line overrides
77+
if pass != "" {
78+
dst.Password = pass
79+
}
80+
if keyPath != "" {
81+
dst.KeyPath = keyPath
82+
}
83+
if host != "" {
84+
dst.Host = host
85+
}
86+
if user != "" {
87+
dst.User = user
88+
}
89+
if port != "" && port != "22" {
90+
dst.Port = port
91+
}
92+
applyUserDefault(dst)
93+
}
94+
2095
// parseConfigFile parses a config file (format: key: value)
2196
func parseConfigFile(filename string) (*Config, error) {
2297
file, err := os.Open(filename)
@@ -25,10 +100,7 @@ func parseConfigFile(filename string) (*Config, error) {
25100
}
26101
defer file.Close()
27102

28-
config := &Config{
29-
Port: "22",
30-
User: "root",
31-
}
103+
config := newDefaultConfig()
32104

33105
scanner := bufio.NewScanner(file)
34106
for scanner.Scan() {
@@ -80,4 +152,4 @@ func readPasswordFile(filename string) (string, error) {
80152
// getEnvPassword gets password from environment variable
81153
func getEnvPassword() string {
82154
return os.Getenv("SSHPASS")
83-
}
155+
}

0 commit comments

Comments
 (0)