Skip to content
Draft
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
26 changes: 23 additions & 3 deletions flyctl/flyctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
fly "github.com/superfly/fly-go"
"github.com/superfly/flyctl/helpers"
"github.com/superfly/flyctl/internal/instrument"
"github.com/superfly/flyctl/internal/profile"
"github.com/superfly/flyctl/terminal"
"gopkg.in/yaml.v3"
)
Expand All @@ -18,9 +19,7 @@ var configDir string

// InitConfig - Initialises config file for Viper
func InitConfig() {
var dir string

dir, err := helpers.GetConfigDirectory()
dir, err := configDirectory()
if err != nil {
fmt.Println("Error accessing home directory", err)

Expand All @@ -39,6 +38,27 @@ func InitConfig() {
initViper()
}

// configDirectory resolves the active profile's config directory.
//
// This runs while the root command is being built, before cobra has parsed
// anything, so the profile flag is scraped straight out of os.Args. A profile
// that fails to resolve is not reported here: the same resolution runs again
// as a command preparer, which raises a far better error. Falling back to the
// plain config directory keeps this from being the thing that reports it.
func configDirectory() (string, error) {
opts := profile.ResolveOptions{Flag: profile.FlagFromArgs(os.Args[1:])}

if wd, err := os.Getwd(); err == nil {
opts.WorkingDir = wd
}

if res, err := profile.Resolve(opts); err == nil {
return res.Dir, nil
}

return helpers.GetConfigDirectory()
}

// ConfigDir - Returns Directory holding the Config file
func ConfigDir() string {
return configDir
Expand Down
40 changes: 35 additions & 5 deletions internal/cmdutil/preparers/preparers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,22 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"

"github.com/spf13/pflag"
"github.com/superfly/client-signals/go"
fly "github.com/superfly/fly-go"
"github.com/superfly/fly-go/flaps"
"github.com/superfly/flyctl/helpers"
"github.com/superfly/flyctl/internal/command_context"
"github.com/superfly/flyctl/internal/config"
"github.com/superfly/flyctl/internal/flag/flagctx"
"github.com/superfly/flyctl/internal/flapsutil"
"github.com/superfly/flyctl/internal/flyutil"
"github.com/superfly/flyctl/internal/instrument"
"github.com/superfly/flyctl/internal/logger"
"github.com/superfly/flyctl/internal/profile"
"github.com/superfly/flyctl/internal/state"
"github.com/superfly/flyctl/internal/uiex"
mpgv1 "github.com/superfly/flyctl/internal/uiex/mpg/v1"
Expand Down Expand Up @@ -118,15 +120,43 @@ func InitClient(ctx context.Context) (context.Context, error) {
}

func DetermineConfigDir(ctx context.Context) (context.Context, error) {
dir, err := helpers.GetConfigDirectory()
var opts profile.ResolveOptions

// Flags are not in context on every path into this preparer (shell
// completion sets up its own, thinner context), so read them defensively.
if fs := flagctx.FromContextOrNil(ctx); fs != nil {
if v, err := fs.GetString(profile.FlagName); err == nil {
opts.Flag = v
}
}

// Resolved independently of state.WorkingDirectory, which the completion
// path never sets.
if wd, err := os.Getwd(); err == nil {
opts.WorkingDir = wd
}

res, err := profile.Resolve(opts)
if err != nil {
return ctx, err
// Most commands must stop here: reaching a different Fly.io account
// than the one asked for is worse than not running at all. The profile
// management commands are the exception, since they are how the user
// repairs a dangling profile reference in the first place.
if !command_context.HasAnnotation(ctx, profile.TolerateUnresolvedAnnotation) {
return ctx, err
}

if res, err = profile.Fallback(err); err != nil {
return ctx, err
}
}

logger.FromContext(ctx).
Debugf("determined config directory: %q", dir)
Debugf("determined config directory: %q (profile %q via %s)", res.Dir, res.Name, res.Source)

ctx = profile.NewContext(ctx, res)

return state.WithConfigDirectory(ctx, dir), nil
return state.WithConfigDirectory(ctx, res.Dir), nil
}

// ApplyAliases consolidates flags with aliases into a single source-of-truth flag.
Expand Down
138 changes: 138 additions & 0 deletions internal/command/profile/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package profile

import (
"context"
"fmt"
"time"

"github.com/spf13/cobra"
fly "github.com/superfly/fly-go"

"github.com/superfly/flyctl/internal/command"
"github.com/superfly/flyctl/internal/command/auth/webauth"
"github.com/superfly/flyctl/internal/flag"
"github.com/superfly/flyctl/internal/flyutil"
profilelib "github.com/superfly/flyctl/internal/profile"
"github.com/superfly/flyctl/internal/state"
"github.com/superfly/flyctl/iostreams"
)

func newAdd() *cobra.Command {
const (
short = "Add a profile and log a Fly.io account into it"
long = `Creates a new, empty config directory for the named profile and
logs an account into it, leaving every other profile untouched.

By default this opens a browser to log in. Pass --token to store an existing
API token instead, which is what CI and scripted setups want.

Adding a profile does not switch to it. Pass --use to do both at once.
`
)

cmd := command.New("add <name>", short, long, runAdd)

cmd.Aliases = []string{"create", "new"}
cmd.Args = cobra.ExactArgs(1)

flag.Add(cmd,
flag.String{
Name: "token",
Description: "Store this API token instead of opening a browser to log in",
},
flag.Bool{
Name: "use",
Description: "Make the new profile active once it is created",
},
)

return cmd
}

func runAdd(ctx context.Context) error {
io := iostreams.FromContext(ctx)
cs := io.ColorScheme()

name := flag.FirstArg(ctx)
if err := profilelib.ValidateName(name); err != nil {
return err
}

dir, err := profilelib.Create(name)
if err != nil {
return err
}

// Anything that fails from here leaves a half-built profile behind, which
// would then resolve to an account-less config directory. Clean it up so
// the failure is total rather than partial.
committed := false
defer func() {
if !committed {
_ = profilelib.Remove(name)
}
}()

// Point the login flow at the new profile's directory rather than the one
// this command resolved to.
loginCtx := state.WithConfigDirectory(ctx, dir)

token := flag.GetString(ctx, "token")
if token == "" {
if token, err = webauth.RunWebLogin(loginCtx, false); err != nil {
return err
}
}

if err := webauth.SaveToken(loginCtx, token); err != nil {
return err
}

// SaveToken already greeted the user by email, but it does not hand the
// address back, so ask once more to cache it for `fly profile list`.
email, err := currentUserEmail(ctx, token)
if err != nil {
return err
}

now := time.Now()
if err := profilelib.WriteMetadata(name, profilelib.Metadata{
Email: email,
CreatedAt: now,
VerifiedAt: now,
}); err != nil {
return err
}

committed = true

fmt.Fprintf(io.Out, "\ncreated profile %s for %s\n", cs.Bold(name), cs.Bold(email))

if flag.GetBool(ctx, "use") {
if err := profilelib.SetActive(name); err != nil {
return err
}

fmt.Fprintf(io.Out, "switched to profile %s\n", cs.Bold(name))

return nil
}

fmt.Fprintf(io.Out, "\nUse it with any of:\n")
fmt.Fprintf(io.Out, " fly profile use %s # switch globally\n", name)
fmt.Fprintf(io.Out, " fly profile link %s # pin this directory tree to it\n", name)
fmt.Fprintf(io.Out, " fly --profile %s <cmd> # just this command\n", name)

return nil
}

func currentUserEmail(ctx context.Context, token string) (string, error) {
user, err := flyutil.NewClientFromOptions(ctx, fly.ClientOptions{
AccessToken: token,
}).GetCurrentUser(ctx)
if err != nil {
return "", fmt.Errorf("failed retrieving current user: %w", err)
}

return user.Email, nil
}
71 changes: 71 additions & 0 deletions internal/command/profile/link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package profile

import (
"context"
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/superfly/flyctl/internal/command"
"github.com/superfly/flyctl/internal/flag"
profilelib "github.com/superfly/flyctl/internal/profile"
"github.com/superfly/flyctl/iostreams"
)

func newLink() *cobra.Command {
const (
short = "Pin the current directory tree to a profile"
long = `Writes a .fly-profile file in the current directory naming the
profile to use for it and everything beneath it.

This is what makes multi-account work painless: once a project is linked, any
flyctl command run inside it reaches the right account with no switching and
no flags, no matter which profile is active globally.

Commit the file to source control to share the binding with the rest of the
team, or add it to .gitignore to keep it personal.
`
)

cmd := command.New("link <name>", short, long, runLink)

cmd.Aliases = []string{"bind"}
cmd.Args = cobra.ExactArgs(1)

return cmd
}

func runLink(ctx context.Context) error {
io := iostreams.FromContext(ctx)
cs := io.ColorScheme()

name := flag.FirstArg(ctx)

if err := profilelib.ValidateName(name); err != nil {
return err
}

exists, err := profilelib.Exists(name)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("profile %q does not exist; create it with `fly profile add %s`", name, name)
}

wd, err := os.Getwd()
if err != nil {
return err
}

path, err := profilelib.WriteProjectFile(wd, name)
if err != nil {
return err
}

fmt.Fprintf(io.Out, "wrote %s\n", path)
fmt.Fprintf(io.Out, "flyctl commands under %s will now use profile %s\n", wd, cs.Bold(name))

return nil
}
Loading