Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ the session without any of this. To supply a code non-interactively, see
| Command | Description |
|---|---|
| `dwshell login [--user U] [--no-trusted]` | Authenticate and persist the session (and, by default, a trusted device). |
| `dwshell logout` | Deregister the trusted device and forget local credentials. |
| `dwshell logout [--all]` | Deregister the trusted device and forget local credentials. |
| `dwshell account list\|default\|rm` | Manage accounts, once you log in with more than one. |
| `dwshell list [--json]` | List machines with OS, online state, and owned/shared. |
| `dwshell <agent>` | Open an interactive shell. |
| `dwshell <agent> -c "cmd"` | Run a command non-interactively; exit code is propagated. |
Expand Down Expand Up @@ -247,6 +248,38 @@ the download page is where the licence is accepted. The agent's unattended mode
it — an install attempted that way answers *"Silent installation forbidden.
Please contact the support."*

### More than one account

Log in twice with different emails and dwshell keeps both. Until you do, nothing
changes: with a single account there is no default to think about and no flag to
pass, and an existing configuration is carried over the first time dwshell saves
anything.

```sh
$ dwshell login --user [email protected] # the email is the account's identity
$ dwshell account list
[email protected] (default)
[email protected]

$ dwshell list # the default account
$ dwshell list --account [email protected] # the other one
$ [email protected] dwshell list # for a whole session
```

The first account you log in with becomes the default; change it with
`dwshell account default <email>`. Logging in again with an email already
registered just refreshes that account.

`dwshell logout` forgets the selected account, `--all` forgets every one, and
`dwshell account rm <email>` forgets a named one — each deregistering that
account's trusted device, and each asking for confirmation unless you pass
`--yes`. Remove the default and, if a single account is left, it takes over; if
several remain, dwshell asks you to pick one rather than choosing for you.

Flags follow their subcommand — `dwshell list --account …`, not
`dwshell --account … list` — except in the shell shortcut, where
`dwshell --account … myserver` works too.

#### Agent name vs subcommand

`dwshell <agent>` is a convenience shortcut: the first argument is treated as an
Expand Down
172 changes: 172 additions & 0 deletions cmd/dwshell/account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/porech/dwshell/internal/client"
"github.com/porech/dwshell/internal/config"
"github.com/porech/dwshell/internal/term"
)

// cmdAccount dispatches `dwshell account <verb>`, which exists only for people
// who log in with more than one account. With a single account there is nothing
// here worth running.
func cmdAccount(ctx context.Context, args []string) int {
if len(args) == 0 {
return fail("usage: dwshell account <list|default|rm>")
}
switch args[0] {
case "list":
return cmdAccountList(args[1:])
case "default":
return cmdAccountDefault(args[1:])
case "rm":
return cmdAccountRemove(ctx, args[1:])
default:
return fail("unknown account subcommand %q", args[0])
}
}

// accountJSON is the --json shape of one registered account.
type accountJSON struct {
User string `json:"user"`
Default bool `json:"default"`
}

func cmdAccountList(args []string) int {
fs := newFlags("account list")
var configPath string
asJSON := false
fs.StringVar(&configPath, "config", "", "config file path")
fs.BoolVar(&asJSON, "json", false, "machine-readable output")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, err := config.Load(configPath)
if err != nil {
return fail("%v", err)
}
if len(cfg.Accounts) == 0 {
return fail("%v", config.ErrNoAccounts)
}
if asJSON {
out := make([]accountJSON, 0, len(cfg.Accounts))
for _, a := range cfg.Accounts {
out = append(out, accountJSON{User: a.User, Default: a.User == cfg.Default})
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(out)
return 0
}
for _, a := range cfg.Accounts {
name := a.User
if name == "" {
name = "(unnamed)"
}
if a.User == cfg.Default {
fmt.Printf("%s (default)\n", name)
continue
}
fmt.Println(name)
}
return 0
}

func cmdAccountDefault(args []string) int {
fs := newFlags("account default")
var configPath string
fs.StringVar(&configPath, "config", "", "config file path")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 1 {
return fail("usage: dwshell account default <email>")
}
cfg, err := config.Load(configPath)
if err != nil {
return fail("%v", err)
}
if err := cfg.SetDefault(fs.Arg(0)); err != nil {
return fail("%v", err)
}
if err := cfg.Save(); err != nil {
return fail("%v", err)
}
fmt.Fprintf(os.Stderr, "default account is now %s\n", fs.Arg(0))
return 0
}

func cmdAccountRemove(ctx context.Context, args []string) int {
fs := newFlags("account rm")
var configPath string
assumeYes := false
fs.StringVar(&configPath, "config", "", "config file path")
fs.BoolVar(&assumeYes, "yes", false, "do not ask for confirmation")
email, flagArgs := extractPositional(args)
if err := fs.Parse(flagArgs); err != nil {
return 2
}
if email == "" {
return fail("usage: dwshell account rm <email> [--yes]")
}
if err := confirm(fmt.Sprintf("remove account %q and deregister its trusted device?", email),
assumeYes, term.IsTTY()); err != nil {
return fail("%v", err)
}
// Selecting the account being removed is what lets the client deregister its
// trusted device server-side before forgetting it locally.
c, err := client.New(configPath, email)
if err != nil {
return fail("%v", err)
}
if err := c.Logout(ctx); err != nil {
return fail("%v", err)
}
fmt.Fprintf(os.Stderr, "account %s removed\n", email)
return 0
}

// extractPositional pulls the first non-flag token out of a subcommand's args,
// returning it with the flags that surrounded it. Go's flag package stops at
// the first positional, so `account rm a@b --yes` would otherwise drop --yes;
// this mirrors what extractAgent does for the shell shortcut.
func extractPositional(args []string) (pos string, flagArgs []string) {
i := 0
for i < len(args) {
a := args[i]
if len(a) > 0 && a[0] == '-' {
flagArgs = append(flagArgs, a)
if !containsEq(a) && valueFlags[trimDashes(a)] && i+1 < len(args) {
i++
flagArgs = append(flagArgs, args[i])
}
i++
continue
}
return a, append(flagArgs, args[i+1:]...)
}
return "", flagArgs
}

// confirm gates a change that cannot be undone. With a terminal it asks; with
// none — a script, a CI job — it refuses unless the caller passed --yes, so
// automation cannot drop an account by accident.
func confirm(prompt string, assumeYes, interactive bool) error {
if assumeYes {
return nil
}
if !interactive {
return fmt.Errorf("%s refusing without a terminal; pass --yes to proceed", prompt)
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
_, _ = fmt.Fscanln(os.Stdin, &answer)
if answer != "y" && answer != "Y" {
return fmt.Errorf("cancelled")
}
return nil
}
68 changes: 15 additions & 53 deletions cmd/dwshell/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,32 +62,6 @@ func printAgentJSON(a agentJSON) {
_ = enc.Encode(a)
}

// agentValueFlags are the `agent` subcommand flags that consume a following
// value token, so a positional can be pulled out from among them.
var agentValueFlags = map[string]bool{"config": true, "description": true, "group": true}

// extractPositional pulls the first non-flag token out of a subcommand's args,
// returning it with the flags that surrounded it. Go's flag package stops at
// the first positional, so `agent create NAME --json` would otherwise silently
// drop --json; this mirrors what extractAgent does for the shell shortcut.
func extractPositional(args []string) (pos string, flagArgs []string) {
i := 0
for i < len(args) {
a := args[i]
if len(a) > 0 && a[0] == '-' {
flagArgs = append(flagArgs, a)
if !containsEq(a) && agentValueFlags[trimDashes(a)] && i+1 < len(args) {
i++
flagArgs = append(flagArgs, args[i])
}
i++
continue
}
return a, append(flagArgs, args[i+1:]...)
}
return "", flagArgs
}

// cmdAgentManage dispatches the `dwshell agent <verb>` family. It is separate
// from cmdAgent, which is the shell shortcut for `dwshell <agent>`.
func cmdAgentManage(ctx context.Context, args []string) int {
Expand All @@ -112,9 +86,11 @@ func cmdAgentManage(ctx context.Context, args []string) int {

func cmdAgentCreate(ctx context.Context, args []string) int {
fs := newFlags("agent create")
var account string
var configPath, description, group string
asJSON := false
fs.StringVar(&configPath, "config", "", "config file path")
fs.StringVar(&account, "account", "", "account to use when several are logged in")
fs.StringVar(&description, "description", "", "free-text description")
fs.StringVar(&group, "group", "", "existing group to place the agent in")
fs.BoolVar(&asJSON, "json", false, "machine-readable output")
Expand All @@ -126,7 +102,7 @@ func cmdAgentCreate(ctx context.Context, args []string) int {
return fail("usage: dwshell agent create <name> [--group G] [--description D] [--json]")
}

c, err := client.New(configPath)
c, err := client.New(configPath, account)
if err != nil {
return fail("%v", err)
}
Expand Down Expand Up @@ -163,25 +139,6 @@ func cmdAgentCreate(ctx context.Context, args []string) int {
return 0
}

// confirm gates a change that cannot be undone. With a terminal it asks; with
// none — a script, a CI job — it refuses unless the caller passed --yes, so
// automation cannot delete a machine or invalidate a code by accident.
func confirm(prompt string, assumeYes, interactive bool) error {
if assumeYes {
return nil
}
if !interactive {
return fmt.Errorf("%s refusing without a terminal; pass --yes to proceed", prompt)
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
_, _ = fmt.Fscanln(os.Stdin, &answer)
if answer != "y" && answer != "Y" {
return fmt.Errorf("cancelled")
}
return nil
}

// resolveOwnAgent finds an agent by name or id and refuses anything these
// commands cannot act on: a share is someone else's agent.
func resolveOwnAgent(machines []remote.Machine, query string) (*remote.Machine, error) {
Expand Down Expand Up @@ -209,8 +166,8 @@ func agentCodeFor(m *remote.Machine) error {

// agentSession resolves an owned agent and hands back a session for acting on
// the account, which every subcommand below needs.
func agentSession(ctx context.Context, configPath, query string) (*remote.Machine, *client.Client, *session.Session, int) {
c, err := client.New(configPath)
func agentSession(ctx context.Context, configPath, account, query string) (*remote.Machine, *client.Client, *session.Session, int) {
c, err := client.New(configPath, account)
if err != nil {
return nil, nil, nil, fail("%v", err)
}
Expand All @@ -231,9 +188,11 @@ func agentSession(ctx context.Context, configPath, query string) (*remote.Machin

func cmdAgentCode(ctx context.Context, args []string) int {
fs := newFlags("agent code")
var account string
var configPath string
asJSON := false
fs.StringVar(&configPath, "config", "", "config file path")
fs.StringVar(&account, "account", "", "account to use when several are logged in")
fs.BoolVar(&asJSON, "json", false, "machine-readable output")
name, flagArgs := extractPositional(args)
if err := fs.Parse(flagArgs); err != nil {
Expand All @@ -242,7 +201,7 @@ func cmdAgentCode(ctx context.Context, args []string) int {
if name == "" {
return fail("usage: dwshell agent code <agent> [--json]")
}
m, _, _, code := agentSession(ctx, configPath, name)
m, _, _, code := agentSession(ctx, configPath, account, name)
if m == nil {
return code
}
Expand All @@ -264,9 +223,10 @@ func cmdAgentCode(ctx context.Context, args []string) int {
// and what they warn about: both are irreversible and both confirm first.
func cmdAgentLifecycle(ctx context.Context, args []string, verb string) int {
fs := newFlags("agent " + verb)
var configPath string
var configPath, account string
assumeYes := false
fs.StringVar(&configPath, "config", "", "config file path")
fs.StringVar(&account, "account", "", "account to use when several are logged in")
fs.BoolVar(&assumeYes, "yes", false, "do not ask for confirmation")
name, flagArgs := extractPositional(args)
if err := fs.Parse(flagArgs); err != nil {
Expand All @@ -275,7 +235,7 @@ func cmdAgentLifecycle(ctx context.Context, args []string, verb string) int {
if name == "" {
return fail("usage: dwshell agent %s <agent> [--yes]", verb)
}
m, _, sess, code := agentSession(ctx, configPath, name)
m, _, sess, code := agentSession(ctx, configPath, account, name)
if m == nil {
return code
}
Expand All @@ -300,7 +260,7 @@ func cmdAgentLifecycle(ctx context.Context, args []string, verb string) int {
}
// The command acknowledges without echoing the record, so the new code is
// read back from the listing.
c, err := client.New(configPath)
c, err := client.New(configPath, account)
if err != nil {
return fail("%v", err)
}
Expand All @@ -318,9 +278,11 @@ func cmdAgentLifecycle(ctx context.Context, args []string, verb string) int {

func cmdAgentGroup(ctx context.Context, args []string) int {
fs := newFlags("agent group")
var account string
var configPath string
none := false
fs.StringVar(&configPath, "config", "", "config file path")
fs.StringVar(&account, "account", "", "account to use when several are logged in")
fs.BoolVar(&none, "none", false, "remove the agent from its group")
name, flagArgs := extractPositional(args)
if err := fs.Parse(flagArgs); err != nil {
Expand All @@ -337,7 +299,7 @@ func cmdAgentGroup(ctx context.Context, args []string) int {
return fail("usage: dwshell agent group <agent> <group> (or --none)")
}

m, _, sess, code := agentSession(ctx, configPath, name)
m, _, sess, code := agentSession(ctx, configPath, account, name)
if m == nil {
return code
}
Expand Down
Loading