| 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 |