返回 DeepSeek-Reasonix
remote.go
根目录 / internal / cli / remote.go
1 package cli
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "strings"
8 "text/tabwriter"
9 "time"
10
11 "reasonix/internal/config"
12 "reasonix/internal/i18n"
13 "reasonix/internal/remote"
14 )
15
16 // remoteCommand dispatches `reasonix remote <sub>`, mirroring mcpCommand.
17 func remoteCommand(args []string, version string) int {
18 if len(args) == 0 {
19 remoteUsage()
20 return 2
21 }
22 switch args[0] {
23 case "add":
24 return remoteAddCLI(args[1:])
25 case "list", "ls":
26 return remoteListCLI()
27 case "remove", "rm":
28 return remoteRemoveCLI(args[1:])
29 case "import":
30 return remoteImportCLI(args[1:])
31 case "test":
32 return remoteTestCLI(args[1:])
33 case "connect", "open":
34 return remoteConnectCLI(args, version)
35 case "status":
36 return remoteStatusCLI(args[1:])
37 case "forward":
38 return remoteForwardCLI(args[1:])
39 case "serve":
40 return remoteServeCLI(args[1:], version)
41 case "fs":
42 return remoteFSCLI(args[1:])
43 case "attach-workspace":
44 return remoteAttachWorkspaceCLI(args[1:], version)
45 case "runtime-workbench":
46 return remoteRuntimeWorkbenchCLI(args[1:], version)
47 case "workbench-build-id":
48 return remoteWorkbenchBuildIDCLI(args[1:], version)
49 case "help", "-h", "--help":
50 remoteUsage()
51 return 0
52 default:
53 fmt.Fprintf(os.Stderr, "unknown remote subcommand %q\n\n", args[0])
54 remoteUsage()
55 return 2
56 }
57 }
58
59 // The Remote Workbench protocol and its hidden subcommands were removed. The
60 // command names stay routable for one release so old scripts and launchers fail
61 // with an actionable message instead of "unknown subcommand"; the following
62 // stable release deletes the stubs and the routes entirely.
63 func removedWorkbenchCommand(name string) int {
64 fmt.Fprintf(os.Stderr, "reasonix remote %s: Remote Workbench 已移除,请使用 `reasonix remote connect <host> --open`\n", name)
65 return 1
66 }
67
68 func remoteAttachWorkspaceCLI(args []string, version string) int {
69 return removedWorkbenchCommand("attach-workspace")
70 }
71 func remoteRuntimeWorkbenchCLI(args []string, version string) int {
72 return removedWorkbenchCommand("runtime-workbench")
73 }
74 func remoteWorkbenchBuildIDCLI(args []string, version string) int {
75 return removedWorkbenchCommand("workbench-build-id")
76 }
77
78 // editUserConfig runs mutate against the user-global config file under the edit
79 // lock and saves it there. Remote hosts are user-global (pinned in
80 // LoadForRoot), so they must never be written to a project reasonix.toml.
81 func editUserConfig(mutate func(*config.Config) error) error {
82 unlock := config.LockUserConfigEdits()
83 defer unlock()
84 path := config.UserConfigPath()
85 if strings.TrimSpace(path) == "" {
86 return fmt.Errorf("cannot resolve user config path")
87 }
88 cfg := config.LoadForEdit(path)
89 if cfg == nil {
90 cfg = config.Default()
91 }
92 if err := mutate(cfg); err != nil {
93 return err
94 }
95 return cfg.SaveTo(path)
96 }
97
98 const remoteAddUsage = "usage: reasonix remote add <name> [user@]host[:port] [flags]"
99
100 func remoteAddCLI(args []string) int {
101 // Positionals come first (name, target); Go's flag package stops at the
102 // first non-flag argument, so the flags are parsed from what follows.
103 if commandHelpRequested(args, 2) {
104 fmt.Fprintln(os.Stdout, remoteAddUsage)
105 return 0
106 }
107 if len(args) < 2 {
108 fmt.Fprintln(os.Stderr, remoteAddUsage)
109 return 2
110 }
111 name, target := args[0], args[1]
112 fs := newFlagSet("remote add")
113 identity := fs.String("identity", "", "path to a private key file")
114 jump := fs.String("jump", "", "ProxyJump chain (OpenSSH syntax)")
115 workspace := fs.String("workspace", "", "default remote workspace directory")
116 useSSHConfig := fs.Bool("use-ssh-config", false, "layer ~/.ssh/config values under unset fields")
117 serveInstall := fs.String("serve-install", "auto", "remote CLI install strategy: auto|npm|upload|never")
118 passphraseEnv := fs.String("passphrase-env", "", "env var name holding the key passphrase")
119 passwordEnv := fs.String("password-env", "", "env var name holding the login password")
120 if code, ok := parseCommandFlags(fs, args[2:]); !ok {
121 return code
122 }
123 user, host, port, err := remote.ParseTarget(target)
124 if err != nil {
125 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
126 return 2
127 }
128 entry := config.RemoteHostEntry{
129 Name: name,
130 Host: host,
131 Port: port,
132 User: user,
133 IdentityFile: *identity,
134 ProxyJump: *jump,
135 Workspace: *workspace,
136 ServeInstall: *serveInstall,
137 UseSSHConfig: *useSSHConfig,
138 PassphraseEnv: *passphraseEnv,
139 PasswordEnv: *passwordEnv,
140 }
141 if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
142 var removalCandidates []string
143 if existing, ok := c.RemoteHost(entry.Name); ok {
144 for _, key := range []string{existing.PasswordEnv, existing.PassphraseEnv} {
145 if config.IsGeneratedRemoteCredential(entry.Name, key) {
146 removalCandidates = append(removalCandidates, key)
147 }
148 }
149 }
150 if err := c.UpsertRemoteHost(entry); err != nil {
151 return nil, err
152 }
153 return config.UnusedGeneratedRemoteCredentialChanges(c, removalCandidates), nil
154 }); err != nil {
155 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
156 return 1
157 }
158 fmt.Printf("added remote host %q (%s)\n", name, target)
159 return 0
160 }
161
162 func remoteListCLI() int {
163 cfg, err := config.Load()
164 if err != nil {
165 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
166 return 1
167 }
168 if len(cfg.Remote.Hosts) == 0 {
169 fmt.Println(i18n.M.RemoteNoHostsHint)
170 return 0
171 }
172 w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
173 fmt.Fprintln(w, "NAME\tTARGET\tWORKSPACE\tFORWARDS\tSSH-CONFIG")
174 for _, h := range cfg.Remote.Hosts {
175 target := h.Host
176 if h.User != "" {
177 target = h.User + "@" + target
178 }
179 if h.Port != 0 && h.Port != 22 {
180 target = fmt.Sprintf("%s:%d", target, h.Port)
181 }
182 ws := h.Workspace
183 if ws == "" {
184 ws = "-"
185 }
186 fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%v\n", h.Name, target, ws, len(h.Forwards), h.UseSSHConfig)
187 }
188 _ = w.Flush()
189 return 0
190 }
191
192 func remoteRemoveCLI(args []string) int {
193 if len(args) != 1 {
194 fmt.Fprintln(os.Stderr, "usage: reasonix remote remove <name>")
195 return 2
196 }
197 name := args[0]
198 removed := false
199 if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
200 var removalCandidates []string
201 if existing, ok := c.RemoteHost(name); ok {
202 for _, key := range []string{existing.PasswordEnv, existing.PassphraseEnv} {
203 if config.IsGeneratedRemoteCredential(name, key) {
204 removalCandidates = append(removalCandidates, key)
205 }
206 }
207 }
208 removed = c.RemoveRemoteHost(name)
209 return config.UnusedGeneratedRemoteCredentialChanges(c, removalCandidates), nil
210 }); err != nil {
211 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
212 return 1
213 }
214 if !removed {
215 fmt.Fprintf(os.Stderr, "no remote host named %q\n", name)
216 return 1
217 }
218 fmt.Printf("removed remote host %q\n", name)
219 return 0
220 }
221
222 func remoteImportCLI(args []string) int {
223 fs := newFlagSet("remote import")
224 all := fs.Bool("all", false, "import every concrete ~/.ssh/config alias")
225 if code, ok := parseCommandFlags(fs, args); !ok {
226 return code
227 }
228 src, err := remote.LoadUserSSHConfig()
229 if err != nil {
230 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
231 return 1
232 }
233 candidates := src.Aliases()
234 if len(candidates) == 0 {
235 fmt.Println("no importable aliases found in ~/.ssh/config")
236 return 0
237 }
238 wanted := map[string]bool{}
239 for _, a := range fs.Args() {
240 wanted[a] = true
241 }
242 imported := 0
243 err = config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
244 for _, cand := range candidates {
245 if !*all && len(wanted) > 0 && !wanted[cand.Alias] {
246 continue
247 }
248 if !*all && len(wanted) == 0 {
249 continue // neither --all nor explicit aliases: nothing to do
250 }
251 entry := config.RemoteHostEntry{
252 Name: cand.Alias,
253 Host: cand.Alias,
254 UseSSHConfig: true,
255 }
256 if existing, ok := c.RemoteHost(entry.Name); ok {
257 entry.PassphraseEnv = existing.PassphraseEnv
258 entry.PasswordEnv = existing.PasswordEnv
259 entry.Workspace = existing.Workspace
260 entry.ServeInstall = existing.ServeInstall
261 entry.Forwards = append([]config.RemoteForwardEntry(nil), existing.Forwards...)
262 }
263 if err := c.UpsertRemoteHost(entry); err != nil {
264 return nil, err
265 }
266 imported++
267 }
268 return nil, nil
269 })
270 if err != nil {
271 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
272 return 1
273 }
274 if imported == 0 {
275 fmt.Println("nothing imported; pass alias names or --all")
276 remotePrintImportCandidates(candidates)
277 return 0
278 }
279 fmt.Printf("imported %d host(s) from ~/.ssh/config\n", imported)
280 return 0
281 }
282
283 func remotePrintImportCandidates(cands []remote.ImportedHost) {
284 fmt.Println("available aliases:")
285 for _, c := range cands {
286 fmt.Printf(" %s\n", c.Alias)
287 }
288 }
289
290 func remoteTestCLI(args []string) int {
291 if len(args) != 1 {
292 fmt.Fprintln(os.Stderr, "usage: reasonix remote test <name|user@host>")
293 return 2
294 }
295 client, cleanup, err := buildRemoteClient(args[0])
296 if err != nil {
297 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
298 return 1
299 }
300 defer cleanup()
301 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
302 defer cancel()
303 if err := client.Start(ctx); err != nil {
304 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
305 return 1
306 }
307 defer client.Close()
308 res, err := client.Exec(ctx, "uname -sm && whoami")
309 if err != nil {
310 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
311 return 1
312 }
313 fmt.Printf("connection OK\n%s", res.Stdout)
314 return 0
315 }
316
317 func remoteUsage() {
318 fmt.Println(`Manage remote SSH hosts and their persistent serve (user-global config).
319
320 Usage:
321 reasonix remote add <name> [user@]host[:port] [--identity F] [--jump SPEC]
322 [--workspace PATH] [--use-ssh-config] [--serve-install auto|npm|upload|never]
323 [--passphrase-env NAME] [--password-env NAME]
324 reasonix remote list
325 reasonix remote remove <name>
326 reasonix remote import [alias...|--all] # from ~/.ssh/config
327 reasonix remote test <name|user@host> # dial + auth + host-key check
328 reasonix remote connect <name> [--workspace PATH] [--local-port N] [--no-serve] [--open] [--forward-only]
329 reasonix remote open <name> # connect --open
330 reasonix remote status [<name>]
331 reasonix remote forward add <host> (-L|-R) <spec> | forward rm <host> <name> | forward ls <host>
332 reasonix remote serve start|stop|status|logs <name> [--workspace PATH] [-n N]
333 reasonix remote fs ls <name>:<path> | fs get <name>:<remote> [local] | fs put <local> <name>:<remote>`)
334 }
335
335 lines GO