gssh-agent 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,7 @@ gssh 是一个供 Agent 使用的 SSH Session 管理工具。通过 Go 语言实
10
10
  - 断线自动重连
11
11
  - 支持密码认证和 SSH 密钥认证
12
12
  - 被连接机器零配置(普通 SSH 即可)
13
+ - SFTP 文件传输支持(上传/下载)
13
14
 
14
15
  ## 安装
15
16
 
@@ -124,6 +125,25 @@ gssh exec -s <session_id> "pwd"
124
125
  gssh forward -l 8080 -r 80
125
126
  ```
126
127
 
128
+ ### 文件传输(SFTP/SCP)
129
+
130
+ ```bash
131
+ # 上传文件(本地 -> 远程)
132
+ gssh scp -put /path/to/local/file.txt /path/to/remote/file.txt
133
+
134
+ # 下载文件(远程 -> 本地)
135
+ gssh scp -get /path/to/remote/file.txt /path/to/local/file.txt
136
+
137
+ # 列出远程目录
138
+ gssh sftp -c ls -p /path/to/remote/dir
139
+
140
+ # 创建远程目录
141
+ gssh sftp -c mkdir -p /path/to/remote/newdir
142
+
143
+ # 删除远程文件
144
+ gssh sftp -c rm -p /path/to/remote/file.txt
145
+ ```
146
+
127
147
  ### Session 管理
128
148
 
129
149
  ```bash
@@ -154,6 +174,10 @@ gssh reconnect -s <session_id>
154
174
  | `-l local` | 本地端口 |
155
175
  | `-r remote` | 远程端口 |
156
176
  | `-R` | 远程端口转发 |
177
+ | `-put` | 上传模式(本地 -> 远程) |
178
+ | `-get` | 下载模式(远程 -> 本地) |
179
+ | `-c command` | SFTP 命令(ls/mkdir/rm) |
180
+ | `-p path` | SFTP 路径 |
157
181
 
158
182
  ## 开发
159
183
 
package/bin/gssh CHANGED
Binary file
package/bin/gssh-daemon CHANGED
Binary file
package/cmd/gssh/main.go CHANGED
@@ -7,9 +7,13 @@ import (
7
7
  "fmt"
8
8
  "net"
9
9
  "os"
10
+ "os/signal"
10
11
  "strings"
12
+ "syscall"
11
13
 
12
14
  "gssh/internal/protocol"
15
+
16
+ "golang.org/x/term"
13
17
  )
14
18
 
15
19
  const (
@@ -50,6 +54,10 @@ func main() {
50
54
  err = handleForwards()
51
55
  case "forward-close":
52
56
  err = handleForwardClose()
57
+ case "scp":
58
+ err = handleSCP()
59
+ case "sftp":
60
+ err = handleSFTP()
53
61
  case "help", "-h", "--help":
54
62
  printUsage()
55
63
  default:
@@ -64,6 +72,50 @@ func main() {
64
72
  }
65
73
  }
66
74
 
75
+ // readPassword reads password from terminal without echo
76
+ func readPassword() (string, error) {
77
+ // Check if stdin is a terminal
78
+ if !term.IsTerminal(int(os.Stdin.Fd())) {
79
+ // Try to read from stdin
80
+ reader := bufio.NewReader(os.Stdin)
81
+ fmt.Print("Password: ")
82
+ password, err := reader.ReadString('\n')
83
+ if err != nil {
84
+ return "", err
85
+ }
86
+ return strings.TrimSuffix(password, "\n"), nil
87
+ }
88
+
89
+ fmt.Print("Password: ")
90
+ bytePassword, err := term.ReadPassword(int(os.Stdin.Fd()))
91
+ fmt.Println()
92
+ if err != nil {
93
+ return "", err
94
+ }
95
+ return string(bytePassword), nil
96
+ }
97
+
98
+ // readPassphrase reads passphrase from terminal without echo
99
+ func readPassphrase() (string, error) {
100
+ if !term.IsTerminal(int(os.Stdin.Fd())) {
101
+ reader := bufio.NewReader(os.Stdin)
102
+ fmt.Print("Key passphrase: ")
103
+ passphrase, err := reader.ReadString('\n')
104
+ if err != nil {
105
+ return "", err
106
+ }
107
+ return strings.TrimSuffix(passphrase, "\n"), nil
108
+ }
109
+
110
+ fmt.Print("Key passphrase: ")
111
+ bytePassphrase, err := term.ReadPassword(int(os.Stdin.Fd()))
112
+ fmt.Println()
113
+ if err != nil {
114
+ return "", err
115
+ }
116
+ return string(bytePassphrase), nil
117
+ }
118
+
67
119
  func sendRequest(method string, params interface{}) ([]byte, error) {
68
120
  conn, err := net.Dial("unix", *socketPath)
69
121
  if err != nil {
@@ -129,6 +181,8 @@ func handleConnect() error {
129
181
  port := flag.Int("p", 22, "Port")
130
182
  password := flag.String("P", "", "Password")
131
183
  keyPath := flag.String("i", "", "SSH key path")
184
+ askPassword := flag.Bool("ask-pass", false, "Ask for password interactively")
185
+ askPassphrase := flag.Bool("ask-passphrase", false, "Ask for key passphrase interactively")
132
186
 
133
187
  flag.CommandLine.Parse(flag.Args()[1:])
134
188
 
@@ -136,6 +190,28 @@ func handleConnect() error {
136
190
  return fmt.Errorf("user and host are required")
137
191
  }
138
192
 
193
+ // If no password provided via flag and -ask-pass is set, read interactively
194
+ if *password == "" && *askPassword {
195
+ p, err := readPassword()
196
+ if err != nil {
197
+ return fmt.Errorf("failed to read password: %w", err)
198
+ }
199
+ *password = p
200
+ }
201
+
202
+ // If key path provided and -ask-passphrase is set, read interactively
203
+ if *keyPath != "" && *askPassphrase {
204
+ passphrase, err := readPassphrase()
205
+ if err != nil {
206
+ return fmt.Errorf("failed to read passphrase: %w", err)
207
+ }
208
+ // Note: Passphrase support would require modifying the SSH client
209
+ // For now, we'll just warn that it's not supported
210
+ if passphrase != "" {
211
+ fmt.Println("Note: Passphrase for keys is not yet supported, ignoring")
212
+ }
213
+ }
214
+
139
215
  params := protocol.ConnectParams{
140
216
  User: *user,
141
217
  Host: *host,
@@ -202,6 +278,9 @@ func handleExec() error {
202
278
  args := flag.Args()[1:]
203
279
 
204
280
  sessionID := flag.String("s", "", "Session ID")
281
+ sudoPassword := flag.String("S", "", "sudo password (will prompt if empty and -s flag used)")
282
+ askSudoPassword := flag.Bool("ask-sudo-pass", false, "Interactively ask for sudo password")
283
+
205
284
  flag.CommandLine.Parse(args)
206
285
 
207
286
  if flag.NArg() < 1 {
@@ -209,6 +288,21 @@ func handleExec() error {
209
288
  }
210
289
  command := strings.Join(flag.Args(), " ")
211
290
 
291
+ // If sudo password needed but not provided, prompt for it
292
+ if *askSudoPassword && *sudoPassword == "" {
293
+ p, err := readPassword()
294
+ if err != nil {
295
+ return fmt.Errorf("failed to read sudo password: %w", err)
296
+ }
297
+ *sudoPassword = p
298
+ }
299
+
300
+ // If command contains sudo and password provided, wrap the command
301
+ if *sudoPassword != "" && strings.Contains(command, "sudo") {
302
+ // Use printf to pipe password to sudo -S
303
+ command = fmt.Sprintf("printf '%%s\\n' '%s' | %s -S", *sudoPassword, command)
304
+ }
305
+
212
306
  params := protocol.ExecParams{
213
307
  SessionID: *sessionID,
214
308
  Command: command,
@@ -364,31 +458,160 @@ func handleForwardClose() error {
364
458
  return nil
365
459
  }
366
460
 
461
+ func handleSCP() error {
462
+ sessionID := flag.String("s", "", "Session ID")
463
+ isUpload := flag.Bool("put", false, "Upload mode (local->remote)")
464
+ isDownload := flag.Bool("get", false, "Download mode (remote->local)")
465
+
466
+ flag.CommandLine.Parse(flag.Args()[1:])
467
+
468
+ if flag.NArg() < 2 {
469
+ return fmt.Errorf("source and destination paths required")
470
+ }
471
+
472
+ source := flag.Arg(0)
473
+ dest := flag.Arg(1)
474
+
475
+ if !*isUpload && !*isDownload {
476
+ return fmt.Errorf("must specify -put or -get")
477
+ }
478
+
479
+ params := protocol.SCPParams{
480
+ SessionID: *sessionID,
481
+ Source: source,
482
+ Dest: dest,
483
+ IsUpload: *isUpload,
484
+ }
485
+
486
+ result, err := sendRequest("scp", params)
487
+ if err != nil {
488
+ return err
489
+ }
490
+
491
+ var scpResult protocol.SCPResult
492
+ if err := json.Unmarshal(result, &scpResult); err != nil {
493
+ return fmt.Errorf("failed to parse result: %w", err)
494
+ }
495
+
496
+ if scpResult.Success {
497
+ fmt.Printf("Success: %s\n", scpResult.Message)
498
+ } else {
499
+ return fmt.Errorf("failed: %s", scpResult.Message)
500
+ }
501
+
502
+ return nil
503
+ }
504
+
505
+ func handleSFTP() error {
506
+ sessionID := flag.String("s", "", "Session ID")
507
+ command := flag.String("c", "", "SFTP command (ls, mkdir, rm)")
508
+ path := flag.String("p", ".", "Path")
509
+
510
+ flag.CommandLine.Parse(flag.Args()[1:])
511
+
512
+ if *command == "" {
513
+ return fmt.Errorf("SFTP command required (-c ls|mkdir|rm)")
514
+ }
515
+
516
+ switch *command {
517
+ case "ls":
518
+ params := protocol.SFTPParams{
519
+ SessionID: *sessionID,
520
+ Command: "ls",
521
+ Path: *path,
522
+ }
523
+ result, err := sendRequest("sftp_list", params)
524
+ if err != nil {
525
+ return err
526
+ }
527
+
528
+ var files []string
529
+ if err := json.Unmarshal(result, &files); err != nil {
530
+ return fmt.Errorf("failed to parse result: %w", err)
531
+ }
532
+
533
+ for _, f := range files {
534
+ fmt.Println(f)
535
+ }
536
+
537
+ case "mkdir":
538
+ params := protocol.SFTPParams{
539
+ SessionID: *sessionID,
540
+ Command: "mkdir",
541
+ Path: *path,
542
+ }
543
+ _, err := sendRequest("sftp_mkdir", params)
544
+ if err != nil {
545
+ return err
546
+ }
547
+ fmt.Printf("Directory created: %s\n", *path)
548
+
549
+ case "rm":
550
+ params := protocol.SFTPParams{
551
+ SessionID: *sessionID,
552
+ Command: "rm",
553
+ Path: *path,
554
+ }
555
+ _, err := sendRequest("sftp_remove", params)
556
+ if err != nil {
557
+ return err
558
+ }
559
+ fmt.Printf("File removed: %s\n", *path)
560
+
561
+ default:
562
+ return fmt.Errorf("unknown SFTP command: %s", *command)
563
+ }
564
+
565
+ return nil
566
+ }
567
+
367
568
  func printUsage() {
368
569
  fmt.Println(`gssh - SSH Session Manager for Agents (Stateless)
369
570
 
370
571
  Usage:
371
- gssh connect -u user -h host [-p port] [-i key_path] [-P password]
572
+ gssh connect -u user -h host [-p port] [-i key_path] [-P password] [--ask-pass]
372
573
  gssh disconnect [-s session_id]
373
574
  gssh reconnect [-s session_id]
374
- gssh exec [-s session_id] "command"
575
+ gssh exec [-s session_id] [-S password | --ask-sudo-pass] "sudo command"
375
576
  gssh list
376
577
  gssh use <session_id>
377
578
  gssh forward [-s session_id] -l local_port -r remote_port
378
579
  gssh forwards
379
580
  gssh forward-close <forward_id>
581
+ gssh scp [-s session_id] [-put|-get] <source> <dest>
582
+ gssh sftp [-s session_id] -c <command> -p <path>
380
583
 
381
584
  Note: For sudo commands, use key-based authentication or configure passwordless sudo.
382
585
 
383
586
  Options:
384
- -socket path Unix socket path (default: /tmp/gssh.sock)
385
- -s session_id Session ID
386
- -u user Username
387
- -h host Host
388
- -p port Port (default: 22)
389
- -P password Password
390
- -i key_path SSH key path
391
- -l local Local port
392
- -r remote Remote port
393
- -R Remote port forward`)
587
+ -socket path Unix socket path (default: /tmp/gssh.sock)
588
+ -s session_id Session ID
589
+ -u user Username
590
+ -h host Host
591
+ -p port Port (default: 22)
592
+ -P password Password (or use --ask-pass for interactive input)
593
+ -i key_path SSH key path
594
+ -S password sudo password (for executing sudo commands)
595
+ --ask-sudo-pass Interactively ask for sudo password
596
+ -l local Local port
597
+ -r remote Remote port
598
+ -R Remote port forward
599
+ -put Upload mode (local -> remote)
600
+ -get Download mode (remote -> local)
601
+ -c command SFTP command (ls, mkdir, rm)
602
+ -p path Path for SFTP command
603
+ --ask-pass Interactively ask for SSH password (secure)
604
+ --ask-passphrase Interactively ask for key passphrase (secure)`)
605
+ }
606
+
607
+ // Restore terminal on exit
608
+ func init() {
609
+ // Setup cleanup to restore terminal state on unexpected exit
610
+ c := make(chan os.Signal, 1)
611
+ signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
612
+ go func() {
613
+ <-c
614
+ term.Restore(int(os.Stdin.Fd()), &term.State{})
615
+ os.Exit(1)
616
+ }()
394
617
  }
package/go.mod CHANGED
@@ -4,7 +4,12 @@ go 1.21
4
4
 
5
5
  require (
6
6
  github.com/google/uuid v1.5.0
7
+ github.com/pkg/sftp v1.13.6
7
8
  golang.org/x/crypto v0.18.0
9
+ golang.org/x/term v0.16.0
8
10
  )
9
11
 
10
- require golang.org/x/sys v0.16.0 // indirect
12
+ require (
13
+ github.com/kr/fs v0.1.0 // indirect
14
+ golang.org/x/sys v0.16.0 // indirect
15
+ )
package/go.sum CHANGED
@@ -1,8 +1,54 @@
1
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1
4
  github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
2
5
  github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
6
+ github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
7
+ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
8
+ github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
9
+ github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
10
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
11
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
12
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
13
+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
14
+ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
15
+ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
16
+ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
17
+ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
18
+ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
19
+ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
20
+ golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
3
21
  golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
4
22
  golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
23
+ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
24
+ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
25
+ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
26
+ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
27
+ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
28
+ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
29
+ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
30
+ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
31
+ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
32
+ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
33
+ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
34
+ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
35
+ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5
36
  golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
6
37
  golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
38
+ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
39
+ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
40
+ golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
7
41
  golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
8
42
  golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
43
+ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
44
+ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
45
+ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
46
+ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
47
+ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
48
+ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
49
+ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
50
+ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
51
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
52
+ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
53
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
54
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -2,11 +2,14 @@ package client
2
2
 
3
3
  import (
4
4
  "fmt"
5
+ "io"
5
6
  "net"
6
7
  "os"
7
8
  "os/user"
8
9
  "path/filepath"
10
+ "time"
9
11
 
12
+ "github.com/pkg/sftp"
10
13
  "golang.org/x/crypto/ssh"
11
14
  )
12
15
 
@@ -130,6 +133,231 @@ func (c *SSHClient) LocalForward(localPort, remotePort int) (net.Listener, error
130
133
  return c.Client.Listen("tcp", fmt.Sprintf("localhost:%d", localPort))
131
134
  }
132
135
 
136
+ // SFTPClient wraps SFTP client for file transfers
137
+ type SFTPClient struct {
138
+ Client *sftp.Client
139
+ }
140
+
141
+ // NewSFTPClient creates a new SFTP client from SSH client
142
+ func (c *SSHClient) NewSFTPClient() (*SFTPClient, error) {
143
+ sftpClient, err := sftp.NewClient(c.Client)
144
+ if err != nil {
145
+ return nil, fmt.Errorf("failed to create SFTP client: %w", err)
146
+ }
147
+ return &SFTPClient{Client: sftpClient}, nil
148
+ }
149
+
150
+ // Upload uploads a local file to remote
151
+ func (s *SFTPClient) Upload(localPath, remotePath string) (int64, error) {
152
+ localFile, err := os.Open(localPath)
153
+ if err != nil {
154
+ return 0, fmt.Errorf("failed to open local file: %w", err)
155
+ }
156
+ defer localFile.Close()
157
+
158
+ localInfo, err := localFile.Stat()
159
+ if err != nil {
160
+ return 0, fmt.Errorf("failed to stat local file: %w", err)
161
+ }
162
+
163
+ remoteFile, err := s.Client.Create(remotePath)
164
+ if err != nil {
165
+ return 0, fmt.Errorf("failed to create remote file: %w", err)
166
+ }
167
+ defer remoteFile.Close()
168
+
169
+ written, err := io.Copy(remoteFile, localFile)
170
+ if err != nil {
171
+ return 0, fmt.Errorf("failed to upload file: %w", err)
172
+ }
173
+
174
+ // Set file permissions
175
+ s.Client.Chmod(remotePath, localInfo.Mode())
176
+
177
+ return written, nil
178
+ }
179
+
180
+ // Download downloads a remote file to local
181
+ func (s *SFTPClient) Download(remotePath, localPath string) (int64, error) {
182
+ remoteFile, err := s.Client.Open(remotePath)
183
+ if err != nil {
184
+ return 0, fmt.Errorf("failed to open remote file: %w", err)
185
+ }
186
+ defer remoteFile.Close()
187
+
188
+ localFile, err := os.Create(localPath)
189
+ if err != nil {
190
+ return 0, fmt.Errorf("failed to create local file: %w", err)
191
+ }
192
+ defer localFile.Close()
193
+
194
+ written, err := io.Copy(localFile, remoteFile)
195
+ if err != nil {
196
+ return 0, fmt.Errorf("failed to download file: %w", err)
197
+ }
198
+
199
+ // Get remote file info to set local permissions
200
+ remoteInfo, err := s.Client.Stat(remotePath)
201
+ if err == nil {
202
+ os.Chmod(localPath, remoteInfo.Mode())
203
+ }
204
+
205
+ return written, nil
206
+ }
207
+
208
+ // List lists files in a remote directory
209
+ func (s *SFTPClient) List(path string) ([]string, error) {
210
+ entries, err := s.Client.ReadDir(path)
211
+ if err != nil {
212
+ return nil, fmt.Errorf("failed to read directory: %w", err)
213
+ }
214
+
215
+ names := make([]string, 0, len(entries))
216
+ for _, entry := range entries {
217
+ names = append(names, entry.Name())
218
+ }
219
+ return names, nil
220
+ }
221
+
222
+ // Mkdir creates a remote directory
223
+ func (s *SFTPClient) Mkdir(path string) error {
224
+ return s.Client.Mkdir(path)
225
+ }
226
+
227
+ // Remove removes a remote file
228
+ func (s *SFTPClient) Remove(path string) error {
229
+ return s.Client.Remove(path)
230
+ }
231
+
232
+ // Stat gets remote file info
233
+ func (s *SFTPClient) Stat(path string) (os.FileInfo, error) {
234
+ return s.Client.Stat(path)
235
+ }
236
+
237
+ // Close closes the SFTP client
238
+ func (s *SFTPClient) Close() error {
239
+ return s.Client.Close()
240
+ }
241
+
242
+ // TransferResult represents the result of a file transfer
243
+ type TransferResult struct {
244
+ Bytes int64
245
+ Duration time.Duration
246
+ }
247
+
248
+ // TransferWithProgress uploads or downloads a file with progress callback
249
+ func (s *SFTPClient) TransferWithProgress(isUpload bool, src, dst string, progress func(int64)) (*TransferResult, error) {
250
+ start := time.Now()
251
+
252
+ if isUpload {
253
+ result, err := s.uploadWithProgress(src, dst, progress)
254
+ if err != nil {
255
+ return nil, err
256
+ }
257
+ return &TransferResult{
258
+ Bytes: result,
259
+ Duration: time.Since(start),
260
+ }, nil
261
+ } else {
262
+ result, err := s.downloadWithProgress(src, dst, progress)
263
+ if err != nil {
264
+ return nil, err
265
+ }
266
+ return &TransferResult{
267
+ Bytes: result,
268
+ Duration: time.Since(start),
269
+ }, nil
270
+ }
271
+ }
272
+
273
+ func (s *SFTPClient) uploadWithProgress(localPath, remotePath string, progress func(int64)) (int64, error) {
274
+ localFile, err := os.Open(localPath)
275
+ if err != nil {
276
+ return 0, fmt.Errorf("failed to open local file: %w", err)
277
+ }
278
+ defer localFile.Close()
279
+
280
+ localInfo, err := localFile.Stat()
281
+ if err != nil {
282
+ return 0, fmt.Errorf("failed to stat local file: %w", err)
283
+ }
284
+
285
+ remoteFile, err := s.Client.Create(remotePath)
286
+ if err != nil {
287
+ return 0, fmt.Errorf("failed to create remote file: %w", err)
288
+ }
289
+ defer remoteFile.Close()
290
+
291
+ // Create a writer with progress tracking
292
+ writer := &progressWriter{
293
+ w: remoteFile,
294
+ progress: progress,
295
+ total: localInfo.Size(),
296
+ }
297
+
298
+ written, err := io.Copy(writer, localFile)
299
+ if err != nil {
300
+ return 0, fmt.Errorf("failed to upload file: %w", err)
301
+ }
302
+
303
+ // Set file permissions
304
+ s.Client.Chmod(remotePath, localInfo.Mode())
305
+
306
+ return written, nil
307
+ }
308
+
309
+ func (s *SFTPClient) downloadWithProgress(remotePath, localPath string, progress func(int64)) (int64, error) {
310
+ remoteFile, err := s.Client.Open(remotePath)
311
+ if err != nil {
312
+ return 0, fmt.Errorf("failed to open remote file: %w", err)
313
+ }
314
+ defer remoteFile.Close()
315
+
316
+ remoteInfo, err := s.Client.Stat(remotePath)
317
+ if err != nil {
318
+ return 0, fmt.Errorf("failed to stat remote file: %w", err)
319
+ }
320
+
321
+ localFile, err := os.Create(localPath)
322
+ if err != nil {
323
+ return 0, fmt.Errorf("failed to create local file: %w", err)
324
+ }
325
+ defer localFile.Close()
326
+
327
+ // Create a writer with progress tracking
328
+ writer := &progressWriter{
329
+ w: localFile,
330
+ progress: progress,
331
+ total: remoteInfo.Size(),
332
+ }
333
+
334
+ written, err := io.Copy(writer, remoteFile)
335
+ if err != nil {
336
+ return 0, fmt.Errorf("failed to download file: %w", err)
337
+ }
338
+
339
+ // Set local file permissions
340
+ os.Chmod(localPath, remoteInfo.Mode())
341
+
342
+ return written, nil
343
+ }
344
+
345
+ type progressWriter struct {
346
+ w io.Writer
347
+ progress func(int64)
348
+ total int64
349
+ written int64
350
+ }
351
+
352
+ func (pw *progressWriter) Write(p []byte) (n int, err error) {
353
+ n, err = pw.w.Write(p)
354
+ pw.written += int64(n)
355
+ if pw.progress != nil {
356
+ pw.progress(pw.written)
357
+ }
358
+ return
359
+ }
360
+
133
361
  // expandPath expands ~ to home directory
134
362
  func expandPath(path string) string {
135
363
  if len(path) > 0 && path[0] == '~' {
@@ -88,3 +88,26 @@ type ForwardCloseParams struct {
88
88
  type UseParams struct {
89
89
  SessionID string `json:"session_id"`
90
90
  }
91
+
92
+ // SCPParams represents SCP transfer parameters
93
+ type SCPParams struct {
94
+ SessionID string `json:"session_id,omitempty"`
95
+ Source string `json:"source"`
96
+ Dest string `json:"dest"`
97
+ IsUpload bool `json:"is_upload"` // true = upload (local->remote), false = download (remote->local)
98
+ }
99
+
100
+ // SCPResult represents SCP transfer result
101
+ type SCPResult struct {
102
+ Success bool `json:"success"`
103
+ Message string `json:"message"`
104
+ Bytes int64 `json:"bytes"`
105
+ Duration int64 `json:"duration_ms"`
106
+ }
107
+
108
+ // SFTPParams represents SFTP command parameters
109
+ type SFTPParams struct {
110
+ SessionID string `json:"session_id,omitempty"`
111
+ Command string `json:"command"` // "ls", "cd", "pwd", "mkdir", "rm", "rmdir"
112
+ Path string `json:"path"`
113
+ }
@@ -314,6 +314,164 @@ func (m *Manager) CloseForward(forwardID string) error {
314
314
  return nil
315
315
  }
316
316
 
317
+ // SCP uploads or downloads files via SFTP
318
+ func (m *Manager) SCP(sessionID, source, dest string, isUpload bool) (*protocol.SCPResult, error) {
319
+ m.mu.RLock()
320
+ var ms *ManagedSession
321
+ if sessionID != "" {
322
+ ms = m.sessions[sessionID]
323
+ } else if m.defaultID != "" {
324
+ ms = m.sessions[m.defaultID]
325
+ }
326
+ m.mu.RUnlock()
327
+
328
+ if ms == nil {
329
+ return nil, fmt.Errorf("session not found")
330
+ }
331
+
332
+ ms.mu.RLock()
333
+ sshClient := ms.SSHClient
334
+ ms.mu.RUnlock()
335
+
336
+ if sshClient == nil {
337
+ return nil, fmt.Errorf("session not connected")
338
+ }
339
+
340
+ // Create SFTP client
341
+ sftpClient, err := sshClient.NewSFTPClient()
342
+ if err != nil {
343
+ return nil, fmt.Errorf("failed to create SFTP client: %w", err)
344
+ }
345
+ defer sftpClient.Close()
346
+
347
+ start := time.Now()
348
+
349
+ var bytes int64
350
+ var transferErr error
351
+
352
+ if isUpload {
353
+ // Upload: source is local, dest is remote
354
+ bytes, transferErr = sftpClient.Upload(source, dest)
355
+ } else {
356
+ // Download: source is remote, dest is local
357
+ bytes, transferErr = sftpClient.Download(source, dest)
358
+ }
359
+
360
+ duration := time.Since(start).Milliseconds()
361
+
362
+ if transferErr != nil {
363
+ return &protocol.SCPResult{
364
+ Success: false,
365
+ Message: transferErr.Error(),
366
+ Bytes: bytes,
367
+ Duration: duration,
368
+ }, nil
369
+ }
370
+
371
+ return &protocol.SCPResult{
372
+ Success: true,
373
+ Message: fmt.Sprintf("Transferred %d bytes in %dms", bytes, duration),
374
+ Bytes: bytes,
375
+ Duration: duration,
376
+ }, nil
377
+ }
378
+
379
+ // SFTPList lists files in a remote directory
380
+ func (m *Manager) SFTPList(sessionID, path string) ([]string, error) {
381
+ m.mu.RLock()
382
+ var ms *ManagedSession
383
+ if sessionID != "" {
384
+ ms = m.sessions[sessionID]
385
+ } else if m.defaultID != "" {
386
+ ms = m.sessions[m.defaultID]
387
+ }
388
+ m.mu.RUnlock()
389
+
390
+ if ms == nil {
391
+ return nil, fmt.Errorf("session not found")
392
+ }
393
+
394
+ ms.mu.RLock()
395
+ sshClient := ms.SSHClient
396
+ ms.mu.RUnlock()
397
+
398
+ if sshClient == nil {
399
+ return nil, fmt.Errorf("session not connected")
400
+ }
401
+
402
+ sftpClient, err := sshClient.NewSFTPClient()
403
+ if err != nil {
404
+ return nil, fmt.Errorf("failed to create SFTP client: %w", err)
405
+ }
406
+ defer sftpClient.Close()
407
+
408
+ return sftpClient.List(path)
409
+ }
410
+
411
+ // SFTPMkdir creates a remote directory
412
+ func (m *Manager) SFTPMkdir(sessionID, path string) error {
413
+ m.mu.RLock()
414
+ var ms *ManagedSession
415
+ if sessionID != "" {
416
+ ms = m.sessions[sessionID]
417
+ } else if m.defaultID != "" {
418
+ ms = m.sessions[m.defaultID]
419
+ }
420
+ m.mu.RUnlock()
421
+
422
+ if ms == nil {
423
+ return fmt.Errorf("session not found")
424
+ }
425
+
426
+ ms.mu.RLock()
427
+ sshClient := ms.SSHClient
428
+ ms.mu.RUnlock()
429
+
430
+ if sshClient == nil {
431
+ return fmt.Errorf("session not connected")
432
+ }
433
+
434
+ sftpClient, err := sshClient.NewSFTPClient()
435
+ if err != nil {
436
+ return fmt.Errorf("failed to create SFTP client: %w", err)
437
+ }
438
+ defer sftpClient.Close()
439
+
440
+ return sftpClient.Mkdir(path)
441
+ }
442
+
443
+ // SFTPRemove removes a remote file
444
+ func (m *Manager) SFTPRemove(sessionID, path string) error {
445
+ m.mu.RLock()
446
+ var ms *ManagedSession
447
+ if sessionID != "" {
448
+ ms = m.sessions[sessionID]
449
+ } else if m.defaultID != "" {
450
+ ms = m.sessions[m.defaultID]
451
+ }
452
+ m.mu.RUnlock()
453
+
454
+ if ms == nil {
455
+ return fmt.Errorf("session not found")
456
+ }
457
+
458
+ ms.mu.RLock()
459
+ sshClient := ms.SSHClient
460
+ ms.mu.RUnlock()
461
+
462
+ if sshClient == nil {
463
+ return fmt.Errorf("session not connected")
464
+ }
465
+
466
+ sftpClient, err := sshClient.NewSFTPClient()
467
+ if err != nil {
468
+ return fmt.Errorf("failed to create SFTP client: %w", err)
469
+ }
470
+ defer sftpClient.Close()
471
+
472
+ return sftpClient.Remove(path)
473
+ }
474
+
317
475
  // monitorReconnect monitors connection and auto-reconnects
318
476
  func (m *Manager) monitorReconnect(ms *ManagedSession) {
319
477
  ticker := time.NewTicker(5 * time.Second)
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "gssh-agent",
3
- "version": "1.0.0",
4
- "description": "SSH Session Manager for Agents",
3
+ "version": "1.0.1",
4
+ "description": "SSH Session Manager for Agents - Stateless SSH client with SFTP support",
5
5
  "bin": {
6
- "gssh": "bin/gssh",
7
- "gssh-daemon": "bin/gssh-daemon"
6
+ "gssh": "./bin/gssh",
7
+ "gssh-daemon": "./bin/gssh-daemon"
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",
@@ -12,14 +12,29 @@
12
12
  },
13
13
  "keywords": [
14
14
  "ssh",
15
+ "sftp",
16
+ "scp",
15
17
  "agent",
16
18
  "session-manager",
17
- "port-forwarding"
19
+ "port-forwarding",
20
+ "remote-execution"
18
21
  ],
19
22
  "author": "",
20
23
  "license": "MIT",
21
24
  "bugs": {
22
25
  "url": "https://github.com/forechoandlook/gssh/issues"
23
26
  },
24
- "homepage": "https://github.com/forechoandlook/gssh#readme"
27
+ "homepage": "https://github.com/forechoandlook/gssh#readme",
28
+ "preferGlobal": true,
29
+ "os": [
30
+ "darwin",
31
+ "linux"
32
+ ],
33
+ "cpu": [
34
+ "x64",
35
+ "arm64"
36
+ ],
37
+ "engines": {
38
+ "node": ">=14.0.0"
39
+ }
25
40
  }
@@ -89,6 +89,34 @@ func (h *Handler) Handle(data []byte) ([]byte, error) {
89
89
  }
90
90
  err = h.manager.CloseForward(params.ForwardID)
91
91
 
92
+ case "scp":
93
+ var params protocol.SCPParams
94
+ if err := json.Unmarshal(req.Params, &params); err != nil {
95
+ return h.errorResponse(req.ID, -32602, "Invalid params")
96
+ }
97
+ result, err = h.manager.SCP(params.SessionID, params.Source, params.Dest, params.IsUpload)
98
+
99
+ case "sftp_list":
100
+ var params protocol.SFTPParams
101
+ if err := json.Unmarshal(req.Params, &params); err != nil {
102
+ return h.errorResponse(req.ID, -32602, "Invalid params")
103
+ }
104
+ result, err = h.manager.SFTPList(params.SessionID, params.Path)
105
+
106
+ case "sftp_mkdir":
107
+ var params protocol.SFTPParams
108
+ if err := json.Unmarshal(req.Params, &params); err != nil {
109
+ return h.errorResponse(req.ID, -32602, "Invalid params")
110
+ }
111
+ err = h.manager.SFTPMkdir(params.SessionID, params.Path)
112
+
113
+ case "sftp_remove":
114
+ var params protocol.SFTPParams
115
+ if err := json.Unmarshal(req.Params, &params); err != nil {
116
+ return h.errorResponse(req.ID, -32602, "Invalid params")
117
+ }
118
+ err = h.manager.SFTPRemove(params.SessionID, params.Path)
119
+
92
120
  default:
93
121
  return h.errorResponse(req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method))
94
122
  }
package/skill.md CHANGED
@@ -1,96 +1,60 @@
1
- # gssh 使用指南
1
+ ---
2
+ name: use-gssh
3
+ description: "Manage SSH sessions with gssh CLI for agents"
4
+ allowed-tools: Read, Write, Glob, Grep, Bash
5
+ triggers:
6
+ - gssh
7
+ - ssh session
8
+ - ssh 连接
9
+ - ssh 管理
10
+ ---
2
11
 
3
12
  ## 快速开始
4
13
 
5
- ### 1. 安装
6
-
7
- ```bash
8
- git clone <repository-url>
9
- cd gssh
10
- go build -o bin/daemon cmd/daemon/main.go
11
- go build -o bin/gssh cmd/gssh/main.go
12
- ./homebrew/install.sh
13
- ```
14
-
15
- ### 2. 基本使用
16
-
17
14
  ```bash
18
- # 连接 SSH
19
- gssh connect -u admin1 -h 192.168.1.100 -P 1234
15
+ # 连接 SSH(交互式输入密码)
16
+ gssh connect -u admin -h 192.168.1.100 --ask-pass
20
17
 
21
18
  # 执行命令
22
19
  gssh exec "ls -la"
23
20
 
24
- # 列出 session
25
- gssh list
21
+ # 断开连接
22
+ gssh disconnect
26
23
  ```
27
24
 
28
- ## 核心概念
29
-
30
- - **daemon**: 后台服务,管理 SSH 连接
31
- - **session**: 一个 SSH 连接会话
32
- - **默认 session**: 不指定 session ID 时使用的会话
33
-
34
- ## 命令列表
25
+ ## 核心命令
35
26
 
36
27
  | 命令 | 说明 |
37
28
  |------|------|
38
- | `connect` | 建立新 SSH 连接 |
29
+ | `connect` | 建立 SSH 连接 |
39
30
  | `exec` | 执行命令 |
40
- | `list` | 列出所有 session |
41
- | `use` | 切换默认 session |
42
- | `disconnect` | 断开 session |
43
- | `reconnect` | 重连 session |
31
+ | `scp` | 文件传输(上/下载) |
32
+ | `sftp` | SFTP 操作 |
44
33
  | `forward` | 端口转发 |
45
- | `forwards` | 列出转发 |
46
- | `forward-close` | 关闭转发 |
34
+ | `list` | 列出 session |
47
35
 
48
- ## 典型工作流
36
+ ## 认证
49
37
 
50
38
  ```bash
51
- # 1. 连接远程主机
52
- gssh connect -u admin -h 192.168.1.100 -P password
53
-
54
- # 2. 执行命令(使用默认 session)
55
- gssh exec "pwd"
56
- gssh exec "uname -a"
39
+ # 交互式密码(推荐)
40
+ gssh connect -u user -h host --ask-pass
57
41
 
58
- # 3. 断开连接
59
- gssh disconnect
60
-
61
- # 4. 重连
62
- gssh reconnect -s <session-id>
42
+ # SSH 密钥
43
+ gssh connect -u user -h host -i ~/.ssh/id_rsa
63
44
  ```
64
45
 
65
- ## SSH 密钥认证
46
+ ## sudo 命令
66
47
 
67
48
  ```bash
68
- # 使用密钥连接
69
- gssh connect -u admin -h 192.168.1.100 -i ~/.ssh/id_rsa
49
+ gssh exec --ask-sudo-pass "sudo apt update"
70
50
  ```
71
51
 
72
- ## 端口转发
52
+ ## 文件传输
73
53
 
74
54
  ```bash
75
- # 本地转发:localhost:8080 -> remote:80
76
- gssh forward -l 8080 -r 80
77
- ```
55
+ # 上传
56
+ gssh scp -put local.txt /remote/path/
78
57
 
79
- ## 服务管理
80
-
81
- ```bash
82
- # 启动
83
- brew services start gssh
84
-
85
- # 停止
86
- brew services stop gssh
87
-
88
- # 状态
89
- brew services list
58
+ # 下载
59
+ gssh scp -get /remote/file.txt ./
90
60
  ```
91
-
92
- ## 注意事项
93
-
94
- - Agent 使用场景:无状态,每次请求独立完成
95
- - sudo 命令:需要配置 passwordless sudo 或使用密钥认证
96
- - Session 状态由 daemon 维护,CLI 只负责发送请求
package/bin/daemon DELETED
Binary file
Binary file
@@ -1,22 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
- <plist version="1.0">
4
- <dict>
5
- <key>Label</key>
6
- <string>com.gssh.daemon</string>
7
- <key>ProgramArguments</key>
8
- <array>
9
- <string>/usr/local/bin/gssh-daemon</string>
10
- <string>-socket</string>
11
- <string>/tmp/gssh.sock</string>
12
- </array>
13
- <key>RunAtLoad</key>
14
- <true/>
15
- <key>KeepAlive</key>
16
- <true/>
17
- <key>StandardOutPath</key>
18
- <string>/var/log/gssh.log</string>
19
- <key>StandardErrorPath</key>
20
- <string>/var/log/gssh.error.log</string>
21
- </dict>
22
- </plist>
@@ -1,43 +0,0 @@
1
- #!/bin/bash
2
- # gssh service installation script
3
-
4
- set -e
5
-
6
- # Detect architecture
7
- ARCH=$(uname -m)
8
- if [ "$ARCH" = "arm64" ]; then
9
- PREFIX="/opt/homebrew"
10
- else
11
- PREFIX="/usr/local"
12
- fi
13
-
14
- DAEMON_BIN="$PREFIX/bin/gssh-daemon"
15
- CLI_BIN="$PREFIX/bin/gssh"
16
-
17
- echo "Installing gssh to $PREFIX/bin/..."
18
-
19
- # Copy binaries
20
- cp bin/daemon "$DAEMON_BIN"
21
- cp bin/gssh "$CLI_BIN"
22
-
23
- chmod +x "$DAEMON_BIN" "$CLI_BIN"
24
-
25
- # Install launchd plist
26
- PLIST_DIR="$HOME/Library/LaunchAgents"
27
- mkdir -p "$PLIST_DIR"
28
- cp homebrew/gssh.plist "$PLIST_DIR/"
29
-
30
- echo "Loading gssh service..."
31
- launchctl load "$PLIST_DIR/gssh.plist"
32
-
33
- echo "gssh installed successfully!"
34
- echo ""
35
- echo "Usage:"
36
- echo " Start: launchctl start com.gssh.daemon"
37
- echo " Stop: launchctl stop com.gssh.daemon"
38
- echo " Status: launchctl list | grep gssh"
39
- echo ""
40
- echo "Or use Homebrew services:"
41
- echo " brew services start gssh"
42
- echo " brew services stop gssh"
43
- echo " brew services list"