diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d30fe3b9..c486cd1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - breaking(domain) - service-version oriented `domain` commands have been moved under the `service domain` command. Versionless `domain-v1` commands have been moved to the `domain` command ([#1615](https://github.com/fastly/cli/pull/1615)) ### Enhancements: +- feat(setup): Add interactive setup wizard for CLI configuration with API token and SSO authentication options - feat(rust): Allow testing with prerelease Rust versions ([#1604](https://github.com/fastly/cli/pull/1604)) - feat(compute/hashfiles): remove hashsum subcommand ([#1608](https://github.com/fastly/cli/pull/1608)) - feat(ngwaf/rules): add support for CRUD operations for NGWAF rules ([#1605](https://github.com/fastly/cli/pull/1605)) diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index dc9d0c16a..d23690e2b 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -60,6 +60,7 @@ complete -F _fastly_bash_autocomplete fastly Args: "--completion-bash", WantOutput: `help sso +setup auth-token compute config diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 2476ff000..1d82038d1 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -115,6 +115,7 @@ import ( servicevclsnippet "github.com/fastly/cli/pkg/commands/service/vcl/snippet" serviceversion "github.com/fastly/cli/pkg/commands/service/version" "github.com/fastly/cli/pkg/commands/serviceauth" + "github.com/fastly/cli/pkg/commands/setup" "github.com/fastly/cli/pkg/commands/shellcomplete" "github.com/fastly/cli/pkg/commands/sso" "github.com/fastly/cli/pkg/commands/stats" @@ -149,6 +150,7 @@ func Define( // nolint:revive // function-length // placement of the `sso` subcommand not look too odd we place it at the // beginning of the list of commands. ssoCmdRoot := sso.NewRootCommand(app, data) + setupCmdRoot := setup.NewRootCommand(app, data, ssoCmdRoot) authtokenCmdRoot := authtoken.NewRootCommand(app, data) authtokenCreate := authtoken.NewCreateCommand(authtokenCmdRoot.CmdClause, data) @@ -1329,6 +1331,7 @@ func Define( // nolint:revive // function-length serviceVersionStage, serviceVersionUnstage, serviceVersionUpdate, + setupCmdRoot, ssoCmdRoot, statsCmdRoot, statsHistorical, diff --git a/pkg/commands/profile/create.go b/pkg/commands/profile/create.go index 137877f36..4d6ef7c4c 100644 --- a/pkg/commands/profile/create.go +++ b/pkg/commands/profile/create.go @@ -2,12 +2,8 @@ package profile import ( "context" - "errors" "fmt" "io" - "io/fs" - "os" - "path/filepath" "strings" "github.com/fastly/go-fastly/v12/fastly" @@ -15,7 +11,6 @@ import ( "github.com/fastly/cli/pkg/api" "github.com/fastly/cli/pkg/argparser" "github.com/fastly/cli/pkg/commands/sso" - "github.com/fastly/cli/pkg/config" fsterr "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/profile" @@ -141,7 +136,7 @@ func (c *CreateCommand) staticTokenFlow(makeDefault bool, in io.Reader, out io.W func promptForToken(in io.Reader, out io.Writer, errLog fsterr.LogInterface) (string, error) { text.Output(out, "An API token is used to authenticate requests to the Fastly API. To create a token, visit https://manage.fastly.com/account/personal/tokens\n\n") - token, err := text.InputSecure(out, text.Prompt("Fastly API token: "), in, validateTokenNotEmpty) + token, err := text.InputSecure(out, text.Prompt("Fastly API token: "), in, profile.ValidateTokenNotEmpty) if err != nil { errLog.Add(err) return "", err @@ -150,17 +145,6 @@ func promptForToken(in io.Reader, out io.Writer, errLog fsterr.LogInterface) (st return token, nil } -func validateTokenNotEmpty(s string) error { - if s == "" { - return ErrEmptyToken - } - return nil -} - -// ErrEmptyToken is returned when a user tries to supply an empty string as a -// token in the terminal prompt. -var ErrEmptyToken = errors.New("token cannot be empty") - // validateToken ensures the token can be used to acquire user data. func (c *CreateCommand) validateToken(token, endpoint string, spinner text.Spinner) (string, error) { var ( @@ -217,53 +201,22 @@ func (c *CreateCommand) validateToken(token, endpoint string, spinner text.Spinn func (c *CreateCommand) updateInMemCfg(email, token, endpoint string, makeDefault bool, spinner text.Spinner) error { return spinner.Process("Persisting configuration", func(_ *text.SpinnerWrapper) error { c.Globals.Config.Fastly.APIEndpoint = endpoint - - if c.Globals.Config.Profiles == nil { - c.Globals.Config.Profiles = make(config.Profiles) - } - c.Globals.Config.Profiles[c.profile] = &config.Profile{ - Default: makeDefault, - Email: email, - Token: token, - } - - // If the user wants the newly created profile to be their new default, then - // we'll call SetDefault for its side effect of resetting all other profiles - // to have their Default field set to false. - if makeDefault { - if p, ok := profile.SetDefault(c.profile, c.Globals.Config.Profiles); ok { - c.Globals.Config.Profiles = p - } - } + c.Globals.Config.Profiles = profile.Create( + c.profile, + c.Globals.Config.Profiles, + email, + token, + makeDefault, + ) return nil }) } func (c *CreateCommand) persistCfg() error { - // TODO: The following directory checks should be encapsulated by the - // File.Write() method as this chunk of code is duplicated in various places. - // Consider consolidating with pkg/filesystem/directory.go - // This function is itself duplicated in pkg/commands/profile/update.go - dir := filepath.Dir(c.Globals.ConfigPath) - fi, err := os.Stat(dir) - switch { - case err == nil && !fi.IsDir(): - return fmt.Errorf("config file path %s isn't a directory", dir) - case err != nil && errors.Is(err, fs.ErrNotExist): - if err := os.MkdirAll(dir, config.DirectoryPermissions); err != nil { - c.Globals.ErrLog.AddWithContext(err, map[string]any{ - "Directory": dir, - "Permissions": config.DirectoryPermissions, - }) - return fmt.Errorf("error creating config file directory: %w", err) - } - } - if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { c.Globals.ErrLog.Add(err) return fmt.Errorf("error saving config file: %w", err) } - return nil } diff --git a/pkg/commands/setup/root.go b/pkg/commands/setup/root.go new file mode 100644 index 000000000..6db37b595 --- /dev/null +++ b/pkg/commands/setup/root.go @@ -0,0 +1,316 @@ +package setup + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/fastly/cli/pkg/api/undocumented" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/sso" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/useragent" +) + +// RootCommand is the setup command. +type RootCommand struct { + argparser.Base + ssoCmd *sso.RootCommand + + profileName string + setDefault argparser.OptionalBool +} + +// NewRootCommand creates a new setup command. +func NewRootCommand(parent argparser.Registerer, g *global.Data, ssoCmd *sso.RootCommand) *RootCommand { + var c RootCommand + c.Globals = g + c.ssoCmd = ssoCmd + c.CmdClause = parent.Command("setup", "Interactive setup wizard for configuring the Fastly CLI") + c.CmdClause.Flag("name", "Profile name to create").Default(profile.DefaultName).StringVar(&c.profileName) + c.CmdClause.Flag("set-default", "Set as default profile").Action(c.setDefault.Set).BoolVar(&c.setDefault.Value) + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + token := c.Globals.Flags.Token + nonInteractive := c.Globals.Flags.NonInteractive + autoYes := c.Globals.Flags.AutoYes + + if nonInteractive && token == "" { + return fsterr.RemediationError{ + Inner: errors.New("--token is required when using --non-interactive"), + Remediation: "Provide an API token: fastly setup --non-interactive --token $FASTLY_API_TOKEN", + } + } + + if profile.Exist(c.profileName, c.Globals.Config.Profiles) { + if nonInteractive { + return fsterr.RemediationError{ + Inner: fmt.Errorf("profile '%s' already exists", c.profileName), + Remediation: "Use 'fastly profile update' to modify, or specify a different --name.", + } + } + + if autoYes { + return fsterr.RemediationError{ + Inner: fmt.Errorf("profile '%s' already exists", c.profileName), + Remediation: "Specify a different profile name: fastly setup -y --name ", + } + } + + text.Warning(out, "A profile named '%s' already exists.", c.profileName) + cont, err := text.AskYesNo(out, "Would you like to create a profile with a different name? [y/N] ", in) + if err != nil { + return err + } + if !cont { + text.Info(out, "Setup cancelled.") + return nil + } + } + + if nonInteractive { + return c.runNonInteractive(out, token, nonInteractive) + } + return c.runInteractive(in, out, autoYes) +} + +// verifyResponse models the /verify endpoint response. +type verifyResponse struct { + Customer struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"customer"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + } `json:"user"` + Token struct { + ID string `json:"id"` + Name string `json:"name"` + Scope string `json:"scope"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + } `json:"token"` +} + +func (c *RootCommand) validateToken(token string) (*verifyResponse, error) { + endpoint, _ := c.Globals.APIEndpoint() + data, err := undocumented.Call(undocumented.CallOptions{ + APIEndpoint: endpoint, + HTTPClient: c.Globals.HTTPClient, + HTTPHeaders: []undocumented.HTTPHeader{ + {Key: "Accept", Value: "application/json"}, + {Key: "User-Agent", Value: useragent.Name}, + }, + Method: http.MethodGet, + Path: "/verify", + Token: token, + }) + if err != nil { + return nil, fmt.Errorf("error validating token: %w", err) + } + + var resp verifyResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("error decoding API response: %w", err) + } + return &resp, nil +} + +func (c *RootCommand) runNonInteractive(out io.Writer, token string, _ bool) error { + resp, err := c.validateToken(token) + if err != nil { + return err + } + + makeDefault := c.setDefault.Value + if !c.setDefault.WasSet { + _, defaultProfile := profile.Default(c.Globals.Config.Profiles) + makeDefault = (defaultProfile == nil) + } + + if err := c.createProfile(c.profileName, token, resp, makeDefault); err != nil { + return err + } + + if makeDefault { + text.Success(out, "Profile '%s' created and set as default.", c.profileName) + } else { + text.Success(out, "Profile '%s' created.", c.profileName) + } + return nil +} + +func (c *RootCommand) runInteractive(in io.Reader, out io.Writer, autoYes bool) error { + text.Output(out, "Welcome to Fastly CLI Setup!") + text.Break(out) + text.Output(out, "This wizard will help you configure authentication and create a profile.") + text.Break(out) + + useSSO := false + if !autoYes { + text.Output(out, "How would you like to authenticate?") + text.Break(out) + text.Output(out, " 1. API Token (recommended for automation)") + text.Output(out, " 2. SSO/Browser Login (recommended for interactive use)") + text.Break(out) + choice, err := text.Input(out, "Choice [1]: ", in) + if err != nil { + return err + } + useSSO = (choice == "2") + } + + profileName := c.profileName + needsNewName := profile.Exist(c.profileName, c.Globals.Config.Profiles) + + if !autoYes { + prompt := fmt.Sprintf("Profile name [%s]: ", c.profileName) + if needsNewName { + prompt = "Enter a new profile name: " + } + + for { + input, err := text.Input(out, prompt, in) + if err != nil { + return err + } + input = strings.TrimSpace(input) + + if input == "" && !needsNewName { + break + } + + if input == "" { + text.Warning(out, "Profile name cannot be empty.") + continue + } + + if profile.Exist(input, c.Globals.Config.Profiles) { + text.Warning(out, "Profile '%s' already exists. Please choose a different name.", input) + needsNewName = true + prompt = "Enter a new profile name: " + continue + } + + profileName = input + break + } + } else if needsNewName { + return fmt.Errorf("profile '%s' already exists", c.profileName) + } + + _, defaultProfile := profile.Default(c.Globals.Config.Profiles) + hasExistingDefault := (defaultProfile != nil) + + makeDefault := true + if c.setDefault.WasSet { + makeDefault = c.setDefault.Value + } else if hasExistingDefault { + if autoYes { + makeDefault = false + } else { + var err error + makeDefault, err = text.AskYesNo(out, "Set this profile as your default? [y/N] ", in) + if err != nil { + return err + } + } + } + + if useSSO { + return c.runSSOFlow(in, out, profileName, makeDefault) + } + return c.runAPITokenFlow(in, out, profileName, makeDefault) +} + +func (c *RootCommand) runSSOFlow(in io.Reader, out io.Writer, profileName string, makeDefault bool) error { + c.ssoCmd.InvokedFromProfileCreate = true + c.ssoCmd.ProfileCreateName = profileName + c.ssoCmd.ProfileDefault = makeDefault + + if err := c.ssoCmd.Exec(in, out); err != nil { + return fmt.Errorf("failed to authenticate: %w", err) + } + + c.displaySummary(out, profileName, makeDefault) + return nil +} + +func (c *RootCommand) runAPITokenFlow(in io.Reader, out io.Writer, profileName string, makeDefault bool) error { + text.Break(out) + text.Output(out, "You can create an API token at: https://manage.fastly.com/account/personal/tokens") + text.Break(out) + token, err := text.InputSecure(out, "Fastly API token: ", in, profile.ValidateTokenNotEmpty) + if err != nil { + return err + } + if token == "" { + return fsterr.RemediationError{ + Inner: errors.New("API token cannot be empty"), + Remediation: "Enter a valid API token, or create one at https://manage.fastly.com/account/personal/tokens", + } + } + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + var resp *verifyResponse + err = spinner.Process("Validating token", func(_ *text.SpinnerWrapper) error { + resp, err = c.validateToken(token) + return err + }) + if err != nil { + return err + } + + text.Break(out) + text.Success(out, "Token validated successfully!") + fmt.Fprintf(out, "\n Email: %s\n", resp.User.Login) + fmt.Fprintf(out, " Customer: %s (%s)\n", resp.Customer.Name, resp.Customer.ID) + + if err := c.createProfile(profileName, token, resp, makeDefault); err != nil { + return err + } + + c.displaySummary(out, profileName, makeDefault) + return nil +} + +func (c *RootCommand) createProfile(name, token string, resp *verifyResponse, makeDefault bool) error { + c.Globals.Config.Profiles = profile.Create( + name, + c.Globals.Config.Profiles, + resp.User.Login, + token, + makeDefault, + ) + return c.Globals.Config.Write(c.Globals.ConfigPath) +} + +func (c *RootCommand) displaySummary(out io.Writer, profileName string, isDefault bool) { + text.Break(out) + if isDefault { + text.Success(out, "Setup complete! Profile '%s' created and set as default.", profileName) + } else { + text.Success(out, "Setup complete! Profile '%s' created.", profileName) + } + text.Description(out, "Your configuration has been saved to", c.Globals.ConfigPath) + text.Break(out) + text.Output(out, "Next steps:") + text.Output(out, " - Run 'fastly whoami' to verify your identity") + text.Output(out, " - Run 'fastly service list' to see your services") + text.Output(out, " - Run 'fastly compute init' to start a new Compute project") +} diff --git a/pkg/commands/setup/root_test.go b/pkg/commands/setup/root_test.go new file mode 100644 index 000000000..f685a958d --- /dev/null +++ b/pkg/commands/setup/root_test.go @@ -0,0 +1,88 @@ +package setup_test + +import ( + "path/filepath" + "testing" + + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/testutil" +) + +func TestSetupNonInteractive(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "non-interactive requires token", + Args: "--non-interactive", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantError: "--token is required when using --non-interactive", + }, + { + Name: "non-interactive profile already exists", + Args: "--non-interactive --token test_token_123 --name user", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "user": &config.Profile{ + Default: true, + Email: "existing@example.com", + Token: "existing_token", + }, + }, + }, + WantError: "profile 'user' already exists", + }, + { + Name: "auto-yes profile already exists", + Args: "--auto-yes --name user", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "user": &config.Profile{ + Default: true, + Email: "existing@example.com", + Token: "existing_token", + }, + }, + }, + WantError: "profile 'user' already exists", + }, + } + + testutil.RunCLIScenarios(t, []string{"setup"}, scenarios) +} diff --git a/pkg/commands/setup/testdata/config.toml b/pkg/commands/setup/testdata/config.toml new file mode 100644 index 000000000..b907b1b93 --- /dev/null +++ b/pkg/commands/setup/testdata/config.toml @@ -0,0 +1,4 @@ +config_version = 2 + +[fastly] + api_endpoint = "https://api.fastly.com" diff --git a/pkg/config/config.go b/pkg/config/config.go index df86ab467..f8251649d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -421,6 +421,10 @@ func (f *File) UseStatic(path string) error { // Write encodes in-memory data to disk. func (f *File) Write(path string) error { + if err := ensureConfigDirExists(path); err != nil { + return err + } + // gosec flagged this: // G304 (CWE-22): Potential file inclusion via variable // diff --git a/pkg/profile/profile.go b/pkg/profile/profile.go index 69343f2df..96447d6ec 100644 --- a/pkg/profile/profile.go +++ b/pkg/profile/profile.go @@ -1,9 +1,23 @@ package profile import ( + "errors" + "github.com/fastly/cli/pkg/config" ) +// ErrEmptyToken is returned when a user tries to supply an empty string as a +// token in the terminal prompt. +var ErrEmptyToken = errors.New("token cannot be empty") + +// ValidateTokenNotEmpty validates that a token string is not empty. +func ValidateTokenNotEmpty(s string) error { + if s == "" { + return ErrEmptyToken + } + return nil +} + // DefaultName is the default profile name. const DefaultName = "user" @@ -94,6 +108,25 @@ func Delete(name string, p config.Profiles) bool { return ok } +// Create adds a new profile to the configuration. +// Returns the updated profiles. +func Create(name string, p config.Profiles, email, token string, makeDefault bool) config.Profiles { + if p == nil { + p = make(config.Profiles) + } + + p[name] = &config.Profile{ + Default: makeDefault, + Email: email, + Token: token, + } + + if makeDefault { + p, _ = SetDefault(name, p) + } + return p +} + // EditOption lets callers of Edit specify profile fields to update. type EditOption func(*config.Profile)