返回 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 credentialMode := fs.String("credential-mode", "remote", "where model-call credentials live: remote (on the host) | local-proxy (desktop holds the key; calls tunnel back)")
119 passphraseEnv := fs.String("passphrase-env", "", "env var name holding the key passphrase")
120 passwordEnv := fs.String("password-env", "", "env var name holding the login password")
121 if code, ok := parseCommandFlags(fs, args[2:]); !ok {
122 return code
123 }
124 user, host, port, err := remote.ParseTarget(target)
125 if err != nil {
126 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
127 return 2
128 }
129 entry := config.RemoteHostEntry{
130 Name: name,
131 Host: host,
132 Port: port,
133 User: user,
134 IdentityFile: *identity,
135 ProxyJump: *jump,
136 Workspace: *workspace,
137 ServeInstall: *serveInstall,
138 CredentialMode: *credentialMode,
139 UseSSHConfig: *useSSHConfig,
140 PassphraseEnv: *passphraseEnv,
141 PasswordEnv: *passwordEnv,
142 }
143 if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
144 var removalCandidates []string
145 if existing, ok := c.RemoteHost(entry.Name); ok {
146 for _, key := range []string{existing.PasswordEnv, existing.PassphraseEnv} {
147 if config.IsGeneratedRemoteCredential(entry.Name, key) {
148 removalCandidates = append(removalCandidates, key)
149 }
150 }
151 }
152 if err := c.UpsertRemoteHost(entry); err != nil {
153 return nil, err
154 }
155 return config.UnusedGeneratedRemoteCredentialChanges(c, removalCandidates), nil
156 }); err != nil {
157 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
158 return 1
159 }
160 fmt.Printf("added remote host %q (%s)\n", name, target)
161 return 0
162 }
163
164 func remoteListCLI() int {
165 cfg, err := config.Load()
166 if err != nil {
167 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
168 return 1
169 }
170 if len(cfg.Remote.Hosts) == 0 {
171 fmt.Println(i18n.M.RemoteNoHostsHint)
172 return 0
173 }
174 w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
175 fmt.Fprintln(w, "NAME\tTARGET\tWORKSPACE\tFORWARDS\tSSH-CONFIG")
176 for _, h := range cfg.Remote.Hosts {
177 target := h.Host
178 if h.User != "" {
179 target = h.User + "@" + target
180 }
181 if h.Port != 0 && h.Port != 22 {
182 target = fmt.Sprintf("%s:%d", target, h.Port)
183 }
184 ws := h.Workspace
185 if ws == "" {
186 ws = "-"
187 }
188 fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%v\n", h.Name, target, ws, len(h.Forwards), h.UseSSHConfig)
189 }
190 _ = w.Flush()
191 return 0
192 }
193
194 func remoteRemoveCLI(args []string) int {
195 if len(args) != 1 {
196 fmt.Fprintln(os.Stderr, "usage: reasonix remote remove <name>")
197 return 2
198 }
199 name := args[0]
200 removed := false
201 if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
202 var removalCandidates []string
203 if existing, ok := c.RemoteHost(name); ok {
204 for _, key := range []string{existing.PasswordEnv, existing.PassphraseEnv} {
205 if config.IsGeneratedRemoteCredential(name, key) {
206 removalCandidates = append(removalCandidates, key)
207 }
208 }
209 }
210 removed = c.RemoveRemoteHost(name)
211 return config.UnusedGeneratedRemoteCredentialChanges(c, removalCandidates), nil
212 }); err != nil {
213 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
214 return 1
215 }
216 if !removed {
217 fmt.Fprintf(os.Stderr, "no remote host named %q\n", name)
218 return 1
219 }
220 fmt.Printf("removed remote host %q\n", name)
221 return 0
222 }
223
224 func remoteImportCLI(args []string) int {
225 fs := newFlagSet("remote import")
226 all := fs.Bool("all", false, "import every concrete ~/.ssh/config alias")
227 if code, ok := parseCommandFlags(fs, args); !ok {
228 return code
229 }
230 src, err := remote.LoadUserSSHConfig()
231 if err != nil {
232 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
233 return 1
234 }
235 candidates := src.Aliases()
236 if len(candidates) == 0 {
237 fmt.Println("no importable aliases found in ~/.ssh/config")
238 return 0
239 }
240 wanted := map[string]bool{}
241 for _, a := range fs.Args() {
242 wanted[a] = true
243 }
244 imported := 0
245 err = config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
246 for _, cand := range candidates {
247 if !*all && len(wanted) > 0 && !wanted[cand.Alias] {
248 continue
249 }
250 if !*all && len(wanted) == 0 {
251 continue // neither --all nor explicit aliases: nothing to do
252 }
253 entry := config.RemoteHostEntry{
254 Name: cand.Alias,
255 Host: cand.Alias,
256 UseSSHConfig: true,
257 }
258 if existing, ok := c.RemoteHost(entry.Name); ok {
259 entry.PassphraseEnv = existing.PassphraseEnv
260 entry.PasswordEnv = existing.PasswordEnv
261 entry.Workspace = existing.Workspace
262 entry.ServeInstall = existing.ServeInstall
263 entry.Forwards = append([]config.RemoteForwardEntry(nil), existing.Forwards...)
264 }
265 if err := c.UpsertRemoteHost(entry); err != nil {
266 return nil, err
267 }
268 imported++
269 }
270 return nil, nil
271 })
272 if err != nil {
273 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
274 return 1
275 }
276 if imported == 0 {
277 fmt.Println("nothing imported; pass alias names or --all")
278 remotePrintImportCandidates(candidates)
279 return 0
280 }
281 fmt.Printf("imported %d host(s) from ~/.ssh/config\n", imported)
282 return 0
283 }
284
285 func remotePrintImportCandidates(cands []remote.ImportedHost) {
286 fmt.Println("available aliases:")
287 for _, c := range cands {
288 fmt.Printf(" %s\n", c.Alias)
289 }
290 }
291
292 func remoteTestCLI(args []string) int {
293 if len(args) != 1 {
294 fmt.Fprintln(os.Stderr, "usage: reasonix remote test <name|user@host>")
295 return 2
296 }
297 client, cleanup, err := buildRemoteClient(args[0])
298 if err != nil {
299 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
300 return 1
301 }
302 defer cleanup()
303 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
304 defer cancel()
305 if err := client.Start(ctx); err != nil {
306 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
307 return 1
308 }
309 defer client.Close()
310 res, err := client.Exec(ctx, "uname -sm && whoami")
311 if err != nil {
312 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
313 return 1
314 }
315 fmt.Printf("connection OK\n%s", res.Stdout)
316 return 0
317 }
318
319 func remoteUsage() {
320 fmt.Println(`Manage remote SSH hosts and their persistent serve (user-global config).
321
322 Usage:
323 reasonix remote add <name> [user@]host[:port] [--identity F] [--jump SPEC]
324 [--workspace PATH] [--use-ssh-config] [--serve-install auto|npm|upload|never]
325 [--passphrase-env NAME] [--password-env NAME]
326 reasonix remote list
327 reasonix remote remove <name>
328 reasonix remote import [alias...|--all] # from ~/.ssh/config
329 reasonix remote test <name|user@host> # dial + auth + host-key check
330 reasonix remote connect <name> [--workspace PATH] [--local-port N] [--no-serve] [--open] [--forward-only]
331 reasonix remote open <name> # connect --open
332 reasonix remote status [<name>]
333 reasonix remote forward add <host> (-L|-R) <spec> | forward rm <host> <name> | forward ls <host>
334 reasonix remote serve start|stop|status|logs <name> [--workspace PATH] [-n N]
335 reasonix remote fs ls <name>:<path> | fs get <name>:<remote> [local] | fs put <local> <name>:<remote>`)
336 }
337
337 lines GO