github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/cmd/cli/command/commands.go (about)

     1  package command
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"math/rand"
     9  	"os"
    10  	"os/exec"
    11  	"regexp"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/AlecAivazis/survey/v2"
    16  	"github.com/aws/smithy-go"
    17  	"github.com/bufbuild/connect-go"
    18  	"github.com/defang-io/defang/src/pkg"
    19  	"github.com/defang-io/defang/src/pkg/cli"
    20  	cliClient "github.com/defang-io/defang/src/pkg/cli/client"
    21  	"github.com/defang-io/defang/src/pkg/scope"
    22  	"github.com/defang-io/defang/src/pkg/term"
    23  	"github.com/defang-io/defang/src/pkg/types"
    24  	defangv1 "github.com/defang-io/defang/src/protos/io/defang/v1"
    25  	"github.com/spf13/cobra"
    26  	"golang.org/x/mod/semver"
    27  )
    28  
    29  const DEFANG_PORTAL_HOST = "portal.defang.dev"
    30  const SERVICE_PORTAL_URL = "https://" + DEFANG_PORTAL_HOST + "/service"
    31  
    32  const authNeeded = "auth-needed" // annotation to indicate that a command needs authorization
    33  var authNeededAnnotation = map[string]string{authNeeded: ""}
    34  
    35  // GLOBALS
    36  var (
    37  	client         cliClient.Client
    38  	cluster        string
    39  	colorMode      = ColorAuto
    40  	gitHubClientId = pkg.Getenv("DEFANG_CLIENT_ID", "7b41848ca116eac4b125") // GitHub OAuth app
    41  	hasTty         = term.IsTerminal && !pkg.GetenvBool("CI")
    42  	nonInteractive = !hasTty
    43  	provider       = cliClient.Provider(pkg.Getenv("DEFANG_PROVIDER", "auto"))
    44  )
    45  
    46  func prettyError(err error) error {
    47  	// To avoid printing the internal gRPC error code
    48  	var cerr *connect.Error
    49  	if errors.As(err, &cerr) {
    50  		term.Debug(" - Server error:", err)
    51  		err = errors.Unwrap(err)
    52  	}
    53  	return err
    54  
    55  }
    56  
    57  func Execute(ctx context.Context) error {
    58  	if term.CanColor { // TODO: should use DoColor(…) instead
    59  		restore := term.EnableANSI()
    60  		defer restore()
    61  	}
    62  
    63  	if err := RootCmd.ExecuteContext(ctx); err != nil {
    64  		if !errors.Is(err, context.Canceled) {
    65  			term.Error("Error:", prettyError(err))
    66  		}
    67  
    68  		var derr *cli.ComposeError
    69  		if errors.As(err, &derr) {
    70  			compose := "compose"
    71  			fileFlag := composeCmd.Flag("file")
    72  			if fileFlag.Changed {
    73  				compose += " -f " + fileFlag.Value.String()
    74  			}
    75  			printDefangHint("Fix the error and try again. To validate the compose file, use:", compose+" config")
    76  		}
    77  
    78  		if strings.Contains(err.Error(), "secret") {
    79  			printDefangHint("To manage sensitive service config, use:", "config")
    80  		}
    81  
    82  		var cerr *cli.CancelError
    83  		if errors.As(err, &cerr) {
    84  			printDefangHint("Detached. The process will keep running.\nTo continue the logs from where you left off, do:", cerr.Error())
    85  		}
    86  
    87  		code := connect.CodeOf(err)
    88  		if code == connect.CodeUnauthenticated {
    89  			// All AWS errors are wrapped in OperationError
    90  			var oe *smithy.OperationError
    91  			if errors.As(err, &oe) {
    92  				fmt.Println("Could not authenticate to the AWS service. Please check your aws credentials and try again.")
    93  			} else {
    94  				printDefangHint("Please use the following command to log in:", "login")
    95  			}
    96  		}
    97  		if code == connect.CodeFailedPrecondition && (strings.Contains(err.Error(), "EULA") || strings.Contains(err.Error(), "terms")) {
    98  			printDefangHint("Please use the following command to see the Defang terms of service:", "terms")
    99  		}
   100  
   101  		return ExitCode(code)
   102  	}
   103  
   104  	if hasTty && term.HadWarnings {
   105  		fmt.Println("For help with warnings, check our FAQ at https://docs.defang.io/docs/faq")
   106  	}
   107  
   108  	if hasTty && !pkg.GetenvBool("DEFANG_HIDE_UPDATE") && rand.Intn(10) == 0 {
   109  		if latest, err := GetLatestVersion(ctx); err == nil && semver.Compare(GetCurrentVersion(), latest) < 0 {
   110  			term.Debug(" - Latest Version:", latest, "Current Version:", GetCurrentVersion())
   111  			fmt.Println("A newer version of the CLI is available at https://github.com/defang-io/defang/releases/latest")
   112  			if rand.Intn(10) == 0 && !pkg.GetenvBool("DEFANG_HIDE_HINTS") {
   113  				fmt.Println("To silence these notices, do: export DEFANG_HIDE_UPDATE=1")
   114  			}
   115  		}
   116  	}
   117  
   118  	return nil
   119  }
   120  
   121  func SetupCommands(version string) {
   122  	defangFabric := pkg.Getenv("DEFANG_FABRIC", cli.DefaultCluster)
   123  
   124  	RootCmd.Version = version
   125  	RootCmd.PersistentFlags().Var(&colorMode, "color", `colorize output; "auto", "always" or "never"`)
   126  	RootCmd.PersistentFlags().StringVarP(&cluster, "cluster", "s", defangFabric, "Defang cluster to connect to")
   127  	RootCmd.PersistentFlags().VarP(&provider, "provider", "P", `cloud provider to use; use "aws" for bring-your-own-cloud`)
   128  	RootCmd.PersistentFlags().BoolVarP(&cli.DoVerbose, "verbose", "v", false, "verbose logging") // backwards compat: only used by tail
   129  	RootCmd.PersistentFlags().BoolVar(&term.DoDebug, "debug", false, "debug logging for troubleshooting the CLI")
   130  	RootCmd.PersistentFlags().BoolVar(&cli.DoDryRun, "dry-run", false, "dry run (don't actually change anything)")
   131  	RootCmd.PersistentFlags().BoolVarP(&nonInteractive, "non-interactive", "T", !hasTty, "disable interactive prompts / no TTY")
   132  	RootCmd.PersistentFlags().StringP("cwd", "C", "", "change directory before running the command")
   133  	RootCmd.MarkPersistentFlagDirname("cwd")
   134  	RootCmd.PersistentFlags().StringP("file", "f", "", `compose file path`)
   135  	RootCmd.MarkPersistentFlagFilename("file", "yml", "yaml")
   136  
   137  	// Bootstrap command
   138  	RootCmd.AddCommand(bootstrapCmd)
   139  	bootstrapCmd.AddCommand(bootstrapDestroyCmd)
   140  	bootstrapCmd.AddCommand(bootstrapDownCmd)
   141  	bootstrapCmd.AddCommand(bootstrapRefreshCmd)
   142  	bootstrapCmd.AddCommand(bootstrapTearDownCmd)
   143  	bootstrapCmd.AddCommand(bootstrapListCmd)
   144  	bootstrapCmd.AddCommand(bootstrapCancelCmd)
   145  
   146  	// Eula command
   147  	tosCmd.Flags().Bool("agree-tos", false, "Agree to the Defang terms of service")
   148  	RootCmd.AddCommand(tosCmd)
   149  
   150  	// Token command
   151  	tokenCmd.Flags().Duration("expires", 24*time.Hour, "Validity duration of the token")
   152  	tokenCmd.Flags().String("scope", "", fmt.Sprintf("Scope of the token; one of %v (required)", scope.All()))
   153  	tokenCmd.MarkFlagRequired("scope")
   154  	RootCmd.AddCommand(tokenCmd)
   155  
   156  	// Login Command
   157  	// loginCmd.Flags().Bool("skip-prompt", false, "Skip the login prompt if already logged in"); TODO: Implement this
   158  	RootCmd.AddCommand(loginCmd)
   159  
   160  	// Whoami Command
   161  	RootCmd.AddCommand(whoamiCmd)
   162  
   163  	// Logout Command
   164  	RootCmd.AddCommand(logoutCmd)
   165  
   166  	// Generate Command
   167  	//generateCmd.Flags().StringP("name", "n", "service1", "Name of the service")
   168  	RootCmd.AddCommand(generateCmd)
   169  
   170  	// Get Services Command
   171  	getServicesCmd.Flags().BoolP("long", "l", false, "Show more details")
   172  	RootCmd.AddCommand(getServicesCmd)
   173  
   174  	// Get Status Command
   175  	RootCmd.AddCommand(getVersionCmd)
   176  
   177  	// Config Command (was: secrets)
   178  	configSetCmd.Flags().BoolP("name", "n", false, "Name of the config (backwards compat)")
   179  	configSetCmd.Flags().MarkHidden("name")
   180  	configCmd.AddCommand(configSetCmd)
   181  
   182  	configDeleteCmd.Flags().BoolP("name", "n", false, "Name of the config(s) (backwards compat)")
   183  	configDeleteCmd.Flags().MarkHidden("name")
   184  	configCmd.AddCommand(configDeleteCmd)
   185  
   186  	configCmd.AddCommand(configListCmd)
   187  
   188  	RootCmd.AddCommand(configCmd)
   189  	RootCmd.AddCommand(restartCmd)
   190  
   191  	// Compose Command
   192  	// composeCmd.Flags().Bool("compatibility", false, "Run compose in backward compatibility mode"); TODO: Implement compose option
   193  	// composeCmd.Flags().String("env-file", "", "Specify an alternate environment file."); TODO: Implement compose option
   194  	// composeCmd.Flags().Int("parallel", -1, "Control max parallelism, -1 for unlimited (default -1)"); TODO: Implement compose option
   195  	// composeCmd.Flags().String("profile", "", "Specify a profile to enable"); TODO: Implement compose option
   196  	// composeCmd.Flags().String("project-directory", "", "Specify an alternate working directory"); TODO: Implement compose option
   197  	// composeCmd.Flags().StringP("project", "p", "", "Compose project name"); TODO: Implement compose option
   198  	composeUpCmd.Flags().Bool("tail", false, "Tail the service logs after updating") // obsolete, but keep for backwards compatibility
   199  	composeUpCmd.Flags().MarkHidden("tail")
   200  	composeUpCmd.Flags().Bool("force", false, "Force a build of the image even if nothing has changed")
   201  	composeUpCmd.Flags().BoolP("detach", "d", false, "Run in detached mode")
   202  	composeCmd.AddCommand(composeUpCmd)
   203  	composeCmd.AddCommand(composeConfigCmd)
   204  	composeDownCmd.Flags().Bool("tail", false, "Tail the service logs after deleting") // obsolete, but keep for backwards compatibility
   205  	composeDownCmd.Flags().BoolP("detach", "d", false, "Run in detached mode")
   206  	composeDownCmd.Flags().MarkHidden("tail")
   207  	composeCmd.AddCommand(composeDownCmd)
   208  	composeStartCmd.Flags().Bool("force", false, "Force a build of the image even if nothing has changed")
   209  	composeCmd.AddCommand(composeStartCmd)
   210  	RootCmd.AddCommand(composeCmd)
   211  	composeCmd.AddCommand(composeRestartCmd)
   212  	composeCmd.AddCommand(composeStopCmd)
   213  
   214  	// Tail Command
   215  	tailCmd.Flags().StringP("name", "n", "", "Name of the service")
   216  	tailCmd.Flags().String("etag", "", "ETag or deployment ID of the service")
   217  	tailCmd.Flags().BoolP("raw", "r", false, "Show raw (unparsed) logs")
   218  	tailCmd.Flags().String("since", "5s", "Show logs since duration/time")
   219  	RootCmd.AddCommand(tailCmd)
   220  
   221  	// Delete Command
   222  	deleteCmd.Flags().BoolP("name", "n", false, "Name of the service(s) (backwards compat)")
   223  	deleteCmd.Flags().MarkHidden("name")
   224  	deleteCmd.Flags().Bool("tail", false, "Tail the service logs after deleting")
   225  	RootCmd.AddCommand(deleteCmd)
   226  
   227  	// Send Command
   228  	sendCmd.Flags().StringP("subject", "n", "", "Subject to send the message to (required)")
   229  	sendCmd.Flags().StringP("type", "t", "", "Type of message to send (required)")
   230  	sendCmd.Flags().String("id", "", "ID of the message")
   231  	sendCmd.Flags().StringP("data", "d", "", "String data to send")
   232  	sendCmd.Flags().StringP("content-type", "c", "", "Content-Type of the data")
   233  	sendCmd.MarkFlagRequired("subject")
   234  	sendCmd.MarkFlagRequired("type")
   235  	RootCmd.AddCommand(sendCmd)
   236  
   237  	// Cert management
   238  	// TODO: Add list, renew etc.
   239  	certCmd.AddCommand(certGenerateCmd)
   240  	RootCmd.AddCommand(certCmd)
   241  
   242  	if term.CanColor { // TODO: should use DoColor(…) instead
   243  		// Add some emphasis to the help command
   244  		re := regexp.MustCompile(`(?m)^[A-Za-z ]+?:`)
   245  		templ := re.ReplaceAllString(RootCmd.UsageTemplate(), "\033[1m$0\033[0m")
   246  		RootCmd.SetUsageTemplate(templ)
   247  	}
   248  
   249  	origHelpFunc := RootCmd.HelpFunc()
   250  	RootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
   251  		trackCmd(cmd, "Help", P{"args", args})
   252  		origHelpFunc(cmd, args)
   253  	})
   254  }
   255  
   256  var RootCmd = &cobra.Command{
   257  	SilenceUsage:  true,
   258  	SilenceErrors: true,
   259  	Use:           "defang",
   260  	Args:          cobra.NoArgs,
   261  	Short:         "Defang CLI manages services on the Defang cluster",
   262  	PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
   263  		// Use "defer" to track any errors that occur during the command
   264  		defer func() {
   265  			trackCmd(cmd, "Invoked", P{"args", args}, P{"err", err}, P{"non-interactive", nonInteractive}, P{"provider", provider})
   266  		}()
   267  
   268  		// Do this first, since any errors will be printed to the console
   269  		switch colorMode {
   270  		case ColorNever:
   271  			term.ForceColor(false)
   272  		case ColorAlways:
   273  			term.ForceColor(true)
   274  		}
   275  
   276  		switch provider {
   277  		case cliClient.ProviderAuto:
   278  			if awsInEnv() {
   279  				provider = cliClient.ProviderAWS
   280  			} else {
   281  				provider = cliClient.ProviderDefang
   282  			}
   283  		case cliClient.ProviderAWS:
   284  			if !awsInEnv() {
   285  				term.Warn(" ! AWS provider was selected, but AWS environment variables are not set")
   286  			}
   287  		case cliClient.ProviderDefang:
   288  			if awsInEnv() {
   289  				term.Warn(" ! Using Defang provider, but AWS environment variables were detected; use --provider")
   290  			}
   291  		}
   292  
   293  		cwd, _ := cmd.Flags().GetString("cwd")
   294  		if cwd != "" {
   295  			// Change directory before running the command
   296  			if err = os.Chdir(cwd); err != nil {
   297  				return err
   298  			}
   299  		}
   300  
   301  		composeFilePath, _ := cmd.Flags().GetString("file")
   302  		loader := cli.ComposeLoader{ComposeFilePath: composeFilePath}
   303  		client = cli.NewClient(cluster, provider, loader)
   304  
   305  		if v, err := client.GetVersions(cmd.Context()); err == nil {
   306  			version := "v" + cmd.Root().Version // HACK to avoid circular dependency with RootCmd
   307  			term.Debug(" - Fabric:", v.Fabric, "CLI:", version, "Min CLI:", v.CliMin)
   308  			if hasTty && semver.Compare(version, v.CliMin) < 0 {
   309  				term.Warn(" ! Your CLI version is outdated. Please update to the latest version.")
   310  				os.Setenv("DEFANG_HIDE_UPDATE", "1") // hide the update hint at the end
   311  			}
   312  		}
   313  
   314  		// Check if we are correctly logged in, but only if the command needs authorization
   315  		if _, ok := cmd.Annotations[authNeeded]; !ok {
   316  			return nil
   317  		}
   318  
   319  		if err = client.CheckLoginAndToS(cmd.Context()); err != nil {
   320  			if nonInteractive {
   321  				return err
   322  			}
   323  			// Login interactively now; only do this for authorization-related errors
   324  			if connect.CodeOf(err) == connect.CodeUnauthenticated {
   325  				term.Warn(" !", prettyError(err))
   326  
   327  				if err = cli.InteractiveLogin(cmd.Context(), client, gitHubClientId, cluster); err != nil {
   328  					return err
   329  				}
   330  
   331  				// FIXME: the new login might have changed the tenant, so we should reload the project
   332  				client = cli.NewClient(cluster, provider, loader)             // reconnect with the new token
   333  				if err = client.CheckLoginAndToS(cmd.Context()); err == nil { // recheck (new token = new user)
   334  					return nil // success
   335  				}
   336  			}
   337  
   338  			// Check if the user has agreed to the terms of service and show a prompt if needed
   339  			if connect.CodeOf(err) == connect.CodeFailedPrecondition {
   340  				term.Warn(" !", prettyError(err))
   341  				if err = cli.InteractiveAgreeToS(cmd.Context(), client); err != nil {
   342  					return err
   343  				}
   344  			}
   345  		}
   346  		return err
   347  	},
   348  }
   349  
   350  var loginCmd = &cobra.Command{
   351  	Use:   "login",
   352  	Args:  cobra.NoArgs,
   353  	Short: "Authenticate to the Defang cluster",
   354  	RunE: func(cmd *cobra.Command, args []string) error {
   355  		if nonInteractive {
   356  			if err := cli.NonInteractiveLogin(cmd.Context(), client, cluster); err != nil {
   357  				return err
   358  			}
   359  		} else {
   360  			err := cli.InteractiveLogin(cmd.Context(), client, gitHubClientId, cluster)
   361  			if err != nil {
   362  				return err
   363  			}
   364  
   365  			printDefangHint("To generate a sample service, do:", "generate")
   366  		}
   367  		return nil
   368  	},
   369  }
   370  
   371  var whoamiCmd = &cobra.Command{
   372  	Use:   "whoami",
   373  	Args:  cobra.NoArgs,
   374  	Short: "Show the current user",
   375  	RunE: func(cmd *cobra.Command, args []string) error {
   376  		err := cli.Whoami(cmd.Context(), client) // always prints
   377  		if err != nil {
   378  			return err
   379  		}
   380  		return nil
   381  	},
   382  }
   383  
   384  var certCmd = &cobra.Command{
   385  	Use:   "cert",
   386  	Args:  cobra.NoArgs,
   387  	Short: "Manage certificates",
   388  }
   389  
   390  var certGenerateCmd = &cobra.Command{
   391  	Use:     "generate",
   392  	Aliases: []string{"gen"},
   393  	Args:    cobra.NoArgs,
   394  	Short:   "Generate an letsencrypt certificate",
   395  	RunE: func(cmd *cobra.Command, args []string) error {
   396  		err := cli.GenerateLetsEncryptCert(cmd.Context(), client)
   397  		if err != nil {
   398  			return err
   399  		}
   400  		return nil
   401  	},
   402  }
   403  
   404  var generateCmd = &cobra.Command{
   405  	Use:     "generate",
   406  	Args:    cobra.NoArgs,
   407  	Aliases: []string{"gen", "new", "init"},
   408  	Short:   "Generate a sample Defang project in the current folder",
   409  	RunE: func(cmd *cobra.Command, args []string) error {
   410  		if nonInteractive {
   411  			return errors.New("cannot run in non-interactive mode")
   412  		}
   413  
   414  		var qs = []*survey.Question{
   415  			{
   416  				Name: "language",
   417  				Prompt: &survey.Select{
   418  					Message: "Choose the language you'd like to use:",
   419  					Options: []string{"Nodejs", "Golang", "Python"},
   420  					Default: "Nodejs",
   421  					Help:    "The generated code will be in the language you choose here.",
   422  				},
   423  			},
   424  			{
   425  				Name: "description",
   426  				Prompt: &survey.Input{
   427  					Message: "Please describe the service you'd like to build:",
   428  					Help: `Here are some example prompts you can use:
   429  	"A simple 'hello world' function"
   430  	"A service with 2 endpoints, one to upload and the other to download a file from AWS S3"
   431  	"A service with a default endpoint that returns an HTML page with a form asking for the user's name and then a POST endpoint to handle the form post when the user clicks the 'submit' button\"
   432  Generate will write files in the current folder. You can edit them and then deploy using 'defang compose up --tail' when ready.`,
   433  				},
   434  				Validate: survey.MinLength(5),
   435  			},
   436  			{
   437  				Name: "folder",
   438  				Prompt: &survey.Input{
   439  					Message: "What folder would you like to create the service in?",
   440  					Default: "service1",
   441  					Help:    "The generated code will be in the folder you choose here. If the folder does not exist, it will be created.",
   442  				},
   443  				Validate: survey.Required,
   444  			},
   445  		}
   446  
   447  		prompt := struct {
   448  			Language    string // or you can tag fields to match a specific name
   449  			Description string
   450  			Folder      string
   451  		}{}
   452  
   453  		// ask the questions
   454  		err := survey.Ask(qs, &prompt)
   455  		if err != nil {
   456  			return err
   457  		}
   458  
   459  		if client.CheckLoginAndToS(cmd.Context()) != nil {
   460  			// The user is either not logged in or has not agreed to the terms of service; ask for agreement to the terms now
   461  			if err := cli.InteractiveAgreeToS(cmd.Context(), client); err != nil {
   462  				// This might fail because the user did not log in. This is fine: we won't persist the terms agreement, but can proceed with the generation
   463  				if connect.CodeOf(err) != connect.CodeUnauthenticated {
   464  					return err
   465  				}
   466  			}
   467  		}
   468  
   469  		Track("Generate Started", P{"language", prompt.Language}, P{"description", prompt.Description}, P{"folder", prompt.Folder})
   470  
   471  		// create the folder if needed
   472  		cd := ""
   473  		if prompt.Folder != "." {
   474  			cd = "`cd " + prompt.Folder + "` and "
   475  			os.MkdirAll(prompt.Folder, 0755)
   476  			if err := os.Chdir(prompt.Folder); err != nil {
   477  				return err
   478  			}
   479  		}
   480  
   481  		// Check if the current folder is empty
   482  		if empty, err := pkg.IsDirEmpty("."); !empty || err != nil {
   483  			term.Warn(" ! The folder is not empty. Files may be overwritten. Press Ctrl+C to abort.")
   484  		}
   485  
   486  		term.Info(" * Working on it. This may take 1 or 2 minutes...")
   487  		_, err = cli.Generate(cmd.Context(), client, prompt.Language, prompt.Description)
   488  		if err != nil {
   489  			return err
   490  		}
   491  
   492  		term.Info(" * Code generated successfully in folder", prompt.Folder)
   493  
   494  		// TODO: should we use EDITOR env var instead?
   495  		cmdd := exec.Command("code", ".")
   496  		err = cmdd.Start()
   497  		if err != nil {
   498  			term.Debug(" - unable to launch VS Code:", err)
   499  		}
   500  
   501  		printDefangHint("Check the files in your favorite editor.\nTo deploy the service, "+cd+"do:", "compose up")
   502  		return nil
   503  	},
   504  }
   505  
   506  var getServicesCmd = &cobra.Command{
   507  	Use:         "services",
   508  	Annotations: authNeededAnnotation,
   509  	Args:        cobra.NoArgs,
   510  	Aliases:     []string{"getServices", "ls", "list"},
   511  	Short:       "Get list of services on the cluster",
   512  	RunE: func(cmd *cobra.Command, args []string) error {
   513  		long, _ := cmd.Flags().GetBool("long")
   514  
   515  		err := cli.GetServices(cmd.Context(), client, long)
   516  		if err != nil {
   517  			return err
   518  		}
   519  
   520  		if !long {
   521  			printDefangHint("To see more information about your services, do:", cmd.CalledAs()+" -l")
   522  		}
   523  
   524  		return nil
   525  	},
   526  }
   527  
   528  var getVersionCmd = &cobra.Command{
   529  	Use:     "version",
   530  	Args:    cobra.NoArgs,
   531  	Aliases: []string{"ver", "stat", "status"}, // for backwards compatibility
   532  	Short:   "Get version information for the CLI and Fabric service",
   533  	RunE: func(cmd *cobra.Command, args []string) error {
   534  		term.Print(term.BrightCyan, "Defang CLI:    ")
   535  		fmt.Println(GetCurrentVersion())
   536  
   537  		term.Print(term.BrightCyan, "Latest CLI:    ")
   538  		ver, err := GetLatestVersion(cmd.Context())
   539  		fmt.Println(ver)
   540  
   541  		term.Print(term.BrightCyan, "Defang Fabric: ")
   542  		ver, err2 := cli.GetVersion(cmd.Context(), client)
   543  		fmt.Println(ver)
   544  		return errors.Join(err, err2)
   545  	},
   546  }
   547  
   548  var tailCmd = &cobra.Command{
   549  	Use:         "tail",
   550  	Annotations: authNeededAnnotation,
   551  	Args:        cobra.NoArgs,
   552  	Short:       "Tail logs from one or more services",
   553  	RunE: func(cmd *cobra.Command, args []string) error {
   554  		var name, _ = cmd.Flags().GetString("name")
   555  		var etag, _ = cmd.Flags().GetString("etag")
   556  		var raw, _ = cmd.Flags().GetBool("raw")
   557  		var since, _ = cmd.Flags().GetString("since")
   558  
   559  		ts, err := cli.ParseTimeOrDuration(since)
   560  		if err != nil {
   561  			return fmt.Errorf("invalid duration or time: %w", err)
   562  		}
   563  
   564  		ts = ts.UTC()
   565  		term.Info(" * Showing logs since", ts.Format(time.RFC3339Nano), "; press Ctrl+C to stop:")
   566  		return cli.Tail(cmd.Context(), client, name, etag, ts, raw)
   567  	},
   568  }
   569  
   570  var configCmd = &cobra.Command{
   571  	Use:     "config", // like Docker
   572  	Args:    cobra.NoArgs,
   573  	Aliases: []string{"secrets", "secret"},
   574  	Short:   "Add, update, or delete service config",
   575  }
   576  
   577  var configSetCmd = &cobra.Command{
   578  	Use:         "create CONFIG", // like Docker
   579  	Annotations: authNeededAnnotation,
   580  	Args:        cobra.ExactArgs(1),
   581  	Aliases:     []string{"set", "add", "put"},
   582  	Short:       "Adds or updates a sensitive config value",
   583  	RunE: func(cmd *cobra.Command, args []string) error {
   584  		name := args[0]
   585  
   586  		var value string
   587  		if !nonInteractive {
   588  			// Prompt for sensitive value
   589  			var sensitivePrompt = &survey.Password{
   590  				Message: fmt.Sprintf("Enter value for %q:", name),
   591  				Help:    "The value will be stored securely and cannot be retrieved later.",
   592  			}
   593  
   594  			err := survey.AskOne(sensitivePrompt, &value)
   595  			if err != nil {
   596  				return err
   597  			}
   598  		} else {
   599  			bytes, err := io.ReadAll(os.Stdin)
   600  			if err != nil && err != io.EOF {
   601  				return fmt.Errorf("failed reading the value from non-terminal: %w", err)
   602  			}
   603  			value = strings.TrimSuffix(string(bytes), "\n")
   604  		}
   605  
   606  		if err := cli.ConfigSet(cmd.Context(), client, name, value); err != nil {
   607  			return err
   608  		}
   609  		term.Info(" * Updated value for", name)
   610  
   611  		printDefangHint("To update the deployed values, do:", "compose start")
   612  		return nil
   613  	},
   614  }
   615  
   616  var configDeleteCmd = &cobra.Command{
   617  	Use:         "rm CONFIG...", // like Docker
   618  	Annotations: authNeededAnnotation,
   619  	Args:        cobra.MinimumNArgs(1),
   620  	Aliases:     []string{"del", "delete", "remove"},
   621  	Short:       "Removes one or more config values",
   622  	RunE: func(cmd *cobra.Command, names []string) error {
   623  		if err := cli.ConfigDelete(cmd.Context(), client, names...); err != nil {
   624  			// Show a warning (not an error) if the config was not found
   625  			if connect.CodeOf(err) == connect.CodeNotFound {
   626  				term.Warn(" !", prettyError(err))
   627  				return nil
   628  			}
   629  			return err
   630  		}
   631  		term.Info(" * Deleted", names)
   632  
   633  		printDefangHint("To list the configs (but not their values), do:", "config ls")
   634  		return nil
   635  	},
   636  }
   637  
   638  var configListCmd = &cobra.Command{
   639  	Use:         "ls", // like Docker
   640  	Annotations: authNeededAnnotation,
   641  	Args:        cobra.NoArgs,
   642  	Aliases:     []string{"list"},
   643  	Short:       "List configs",
   644  	RunE: func(cmd *cobra.Command, args []string) error {
   645  		return cli.ConfigList(cmd.Context(), client)
   646  	},
   647  }
   648  
   649  var composeCmd = &cobra.Command{
   650  	Use:     "compose",
   651  	Aliases: []string{"stack"},
   652  	Args:    cobra.NoArgs,
   653  	Short:   "Work with local Compose files",
   654  }
   655  
   656  func printPlaygroundPortalServiceURLs(serviceInfos []*defangv1.ServiceInfo) {
   657  	// We can only show services deployed to the prod1 defang SaaS environment.
   658  	if provider == cliClient.ProviderDefang && cluster == cli.DefaultCluster {
   659  		term.Info(" * Monitor your services' status in the defang portal")
   660  		for _, serviceInfo := range serviceInfos {
   661  			fmt.Println("   -", SERVICE_PORTAL_URL+"/"+serviceInfo.Service.Name)
   662  		}
   663  	}
   664  }
   665  
   666  func printEndpoints(serviceInfos []*defangv1.ServiceInfo) {
   667  	for _, serviceInfo := range serviceInfos {
   668  		andEndpoints := ""
   669  		if len(serviceInfo.Endpoints) > 0 {
   670  			andEndpoints = "and will be available at:"
   671  		}
   672  		term.Info(" * Service", serviceInfo.Service.Name, "is in state", serviceInfo.Status, andEndpoints)
   673  		for i, endpoint := range serviceInfo.Endpoints {
   674  			if serviceInfo.Service.Ports[i].Mode == defangv1.Mode_INGRESS {
   675  				endpoint = "https://" + endpoint
   676  			}
   677  			fmt.Println("   -", endpoint)
   678  		}
   679  		if serviceInfo.Service.Domainname != "" {
   680  			if serviceInfo.ZoneId != "" {
   681  				fmt.Println("   -", "https://"+serviceInfo.Service.Domainname)
   682  			} else {
   683  				fmt.Println("   -", "https://"+serviceInfo.Service.Domainname+" (after ACME cert activation)")
   684  			}
   685  		}
   686  	}
   687  }
   688  
   689  var composeUpCmd = &cobra.Command{
   690  	Use:         "up",
   691  	Annotations: authNeededAnnotation,
   692  	Args:        cobra.NoArgs, // TODO: takes optional list of service names
   693  	Short:       "Like 'start' but immediately tracks the progress of the deployment",
   694  	RunE: func(cmd *cobra.Command, args []string) error {
   695  		var force, _ = cmd.Flags().GetBool("force")
   696  		var detach, _ = cmd.Flags().GetBool("detach")
   697  
   698  		since := time.Now()
   699  		deploy, err := cli.ComposeStart(cmd.Context(), client, force)
   700  		if err != nil {
   701  			return err
   702  		}
   703  
   704  		printPlaygroundPortalServiceURLs(deploy.Services)
   705  		printEndpoints(deploy.Services) // TODO: do this at the end
   706  
   707  		if detach {
   708  			term.Info(" * Done.")
   709  			return nil
   710  		}
   711  
   712  		etag := deploy.Etag
   713  		services := "all services"
   714  		if etag != "" {
   715  			services = "deployment ID " + etag
   716  		}
   717  
   718  		term.Info(" * Tailing logs for", services, "; press Ctrl+C to detach:")
   719  		err = cli.Tail(cmd.Context(), client, "", etag, since, false)
   720  		if err != nil {
   721  			return err
   722  		}
   723  		term.Info(" * Done.")
   724  		return nil
   725  	},
   726  }
   727  
   728  var composeStartCmd = &cobra.Command{
   729  	Use:         "start",
   730  	Aliases:     []string{"deploy"},
   731  	Annotations: authNeededAnnotation,
   732  	Args:        cobra.NoArgs, // TODO: takes optional list of service names
   733  	Short:       "Reads a Compose file and deploys services to the cluster",
   734  	RunE: func(cmd *cobra.Command, args []string) error {
   735  		var force, _ = cmd.Flags().GetBool("force")
   736  
   737  		deploy, err := cli.ComposeStart(cmd.Context(), client, force)
   738  		if err != nil {
   739  			return err
   740  		}
   741  
   742  		printPlaygroundPortalServiceURLs(deploy.Services)
   743  		printEndpoints(deploy.Services) // TODO: do this at the end
   744  
   745  		command := "tail"
   746  		if deploy.Etag != "" {
   747  			command += " --etag " + deploy.Etag
   748  		}
   749  		printDefangHint("To track the update, do:", command)
   750  		return nil
   751  	},
   752  }
   753  
   754  var composeRestartCmd = &cobra.Command{
   755  	Use:         "restart",
   756  	Annotations: authNeededAnnotation,
   757  	Args:        cobra.NoArgs, // TODO: takes optional list of service names
   758  	Short:       "Reads a Compose file and restarts its services",
   759  	RunE: func(cmd *cobra.Command, args []string) error {
   760  		etag, err := cli.ComposeRestart(cmd.Context(), client)
   761  		if err != nil {
   762  			return err
   763  		}
   764  		term.Info(" * Restarted services with deployment ID", etag)
   765  		return nil
   766  	},
   767  }
   768  
   769  var composeStopCmd = &cobra.Command{
   770  	Use:         "stop",
   771  	Annotations: authNeededAnnotation,
   772  	Args:        cobra.NoArgs, // TODO: takes optional list of service names
   773  	Short:       "Reads a Compose file and stops its services",
   774  	RunE: func(cmd *cobra.Command, args []string) error {
   775  		etag, err := cli.ComposeStop(cmd.Context(), client)
   776  		if err != nil {
   777  			return err
   778  		}
   779  		term.Info(" * Stopped services with deployment ID", etag)
   780  		return nil
   781  	},
   782  }
   783  
   784  var composeDownCmd = &cobra.Command{
   785  	Use:         "down",
   786  	Aliases:     []string{"rm"},
   787  	Annotations: authNeededAnnotation,
   788  	Args:        cobra.NoArgs, // TODO: takes optional list of service names
   789  	Short:       "Like 'stop' but also deprovisions the services from the cluster",
   790  	RunE: func(cmd *cobra.Command, args []string) error {
   791  		var detach, _ = cmd.Flags().GetBool("detach")
   792  
   793  		since := time.Now()
   794  		etag, err := cli.ComposeDown(cmd.Context(), client)
   795  		if err != nil {
   796  			if connect.CodeOf(err) == connect.CodeNotFound {
   797  				// Show a warning (not an error) if the service was not found
   798  				term.Warn(" !", prettyError(err))
   799  				return nil
   800  			}
   801  			return err
   802  		}
   803  
   804  		term.Info(" * Deleted services, deployment ID", etag)
   805  
   806  		if detach {
   807  			printDefangHint("To track the update, do:", "tail --etag "+etag)
   808  			return nil
   809  		}
   810  
   811  		err = cli.Tail(cmd.Context(), client, "", etag, since, false)
   812  		if err != nil {
   813  			return err
   814  		}
   815  		term.Info(" * Done.")
   816  		return nil
   817  
   818  	},
   819  }
   820  
   821  var composeConfigCmd = &cobra.Command{
   822  	Use:   "config",
   823  	Args:  cobra.NoArgs, // TODO: takes optional list of service names
   824  	Short: "Reads a Compose file and shows the generated config",
   825  	RunE: func(cmd *cobra.Command, args []string) error {
   826  		cli.DoDryRun = true // config is like start in a dry run
   827  		// force=false to calculate the digest
   828  		if _, err := cli.ComposeStart(cmd.Context(), client, false); !errors.Is(err, cli.ErrDryRun) {
   829  			return err
   830  		}
   831  		return nil
   832  	},
   833  }
   834  
   835  var deleteCmd = &cobra.Command{
   836  	Use:         "delete SERVICE...",
   837  	Annotations: authNeededAnnotation,
   838  	Args:        cobra.MinimumNArgs(1),
   839  	Aliases:     []string{"del", "rm", "remove"},
   840  	Short:       "Delete a service from the cluster",
   841  	RunE: func(cmd *cobra.Command, names []string) error {
   842  		var tail, _ = cmd.Flags().GetBool("tail")
   843  
   844  		since := time.Now()
   845  		etag, err := cli.Delete(cmd.Context(), client, names...)
   846  		if err != nil {
   847  			if connect.CodeOf(err) == connect.CodeNotFound {
   848  				// Show a warning (not an error) if the service was not found
   849  				term.Warn(" !", prettyError(err))
   850  				return nil
   851  			}
   852  			return err
   853  		}
   854  
   855  		term.Info(" * Deleted service", names, "with deployment ID", etag)
   856  
   857  		if !tail {
   858  			printDefangHint("To track the update, do:", "tail --etag "+etag)
   859  			return nil
   860  		}
   861  
   862  		term.Info(" * Tailing logs for update; press Ctrl+C to detach:")
   863  		return cli.Tail(cmd.Context(), client, "", etag, since, false)
   864  	},
   865  }
   866  
   867  var restartCmd = &cobra.Command{
   868  	Use:         "restart SERVICE...",
   869  	Annotations: authNeededAnnotation,
   870  	Args:        cobra.MinimumNArgs(1),
   871  	Short:       "Restart one or more services",
   872  	RunE: func(cmd *cobra.Command, args []string) error {
   873  		etag, err := cli.Restart(cmd.Context(), client, args...)
   874  		if err != nil {
   875  			return err
   876  		}
   877  		term.Info(" * Restarted service", args, "with deployment ID", etag)
   878  		return nil
   879  	},
   880  }
   881  
   882  var sendCmd = &cobra.Command{
   883  	Use:         "send",
   884  	Hidden:      true, // not available in private beta
   885  	Annotations: authNeededAnnotation,
   886  	Args:        cobra.NoArgs,
   887  	Aliases:     []string{"msg", "message", "publish", "pub"},
   888  	Short:       "Send a message to a service",
   889  	RunE: func(cmd *cobra.Command, args []string) error {
   890  		var id, _ = cmd.Flags().GetString("id")
   891  		var _type, _ = cmd.Flags().GetString("type")
   892  		var data, _ = cmd.Flags().GetString("data")
   893  		var contenttype, _ = cmd.Flags().GetString("content-type")
   894  		var subject, _ = cmd.Flags().GetString("subject")
   895  
   896  		return cli.SendMsg(cmd.Context(), client, subject, _type, id, []byte(data), contenttype)
   897  	},
   898  }
   899  
   900  var tokenCmd = &cobra.Command{
   901  	Use:         "token",
   902  	Annotations: authNeededAnnotation,
   903  	Args:        cobra.NoArgs,
   904  	Short:       "Manage personal access tokens",
   905  	RunE: func(cmd *cobra.Command, args []string) error {
   906  		var s, _ = cmd.Flags().GetString("scope")
   907  		var expires, _ = cmd.Flags().GetDuration("expires")
   908  
   909  		// TODO: should default to use the current tenant, not the default tenant
   910  		return cli.Token(cmd.Context(), client, gitHubClientId, types.DEFAULT_TENANT, expires, scope.Scope(s))
   911  	},
   912  }
   913  
   914  var logoutCmd = &cobra.Command{
   915  	Use:     "logout",
   916  	Args:    cobra.NoArgs,
   917  	Aliases: []string{"logoff", "revoke"},
   918  	Short:   "Log out",
   919  	RunE: func(cmd *cobra.Command, args []string) error {
   920  		if err := cli.Logout(cmd.Context(), client); err != nil {
   921  			return err
   922  		}
   923  		term.Info(" * Successfully logged out")
   924  		return nil
   925  	},
   926  }
   927  
   928  var bootstrapCmd = &cobra.Command{
   929  	Use:     "cd",
   930  	Aliases: []string{"bootstrap"},
   931  	Args:    cobra.NoArgs,
   932  	Short:   "Manually run a command with the CD task",
   933  }
   934  
   935  var bootstrapDestroyCmd = &cobra.Command{
   936  	Use:   "destroy",
   937  	Args:  cobra.NoArgs,
   938  	Short: "Destroy the service stack",
   939  	RunE: func(cmd *cobra.Command, args []string) error {
   940  		return cli.BootstrapCommand(cmd.Context(), client, "destroy")
   941  	},
   942  }
   943  
   944  var bootstrapDownCmd = &cobra.Command{
   945  	Use:   "down",
   946  	Args:  cobra.NoArgs,
   947  	Short: "Refresh and then destroy the service stack",
   948  	RunE: func(cmd *cobra.Command, args []string) error {
   949  		return cli.BootstrapCommand(cmd.Context(), client, "down")
   950  	},
   951  }
   952  
   953  var bootstrapRefreshCmd = &cobra.Command{
   954  	Use:   "refresh",
   955  	Args:  cobra.NoArgs,
   956  	Short: "Refresh the service stack",
   957  	RunE: func(cmd *cobra.Command, args []string) error {
   958  		return cli.BootstrapCommand(cmd.Context(), client, "refresh")
   959  	},
   960  }
   961  
   962  var bootstrapTearDownCmd = &cobra.Command{
   963  	Use:   "teardown",
   964  	Args:  cobra.NoArgs,
   965  	Short: "Destroy the CD cluster without destroying the services",
   966  	RunE: func(cmd *cobra.Command, args []string) error {
   967  		term.Warn(` ! Deleting the CD cluster; this does not delete the services!`)
   968  		return cli.TearDown(cmd.Context(), client)
   969  	},
   970  }
   971  
   972  var bootstrapListCmd = &cobra.Command{
   973  	Use:     "ls",
   974  	Args:    cobra.NoArgs,
   975  	Aliases: []string{"list"},
   976  	Short:   "List all the projects and stacks in the CD cluster",
   977  	RunE: func(cmd *cobra.Command, args []string) error {
   978  		return cli.BootstrapList(cmd.Context(), client)
   979  	},
   980  }
   981  
   982  var bootstrapCancelCmd = &cobra.Command{
   983  	Use:   "cancel",
   984  	Args:  cobra.NoArgs,
   985  	Short: "Cancel the current CD operation",
   986  	RunE: func(cmd *cobra.Command, args []string) error {
   987  		return cli.BootstrapCommand(cmd.Context(), client, "cancel")
   988  	},
   989  }
   990  
   991  var tosCmd = &cobra.Command{
   992  	Use:         "terms",
   993  	Aliases:     []string{"tos", "eula", "tac", "tou"},
   994  	Annotations: authNeededAnnotation, // TODO: only need auth when agreeing to the terms
   995  	Args:        cobra.NoArgs,
   996  	Short:       "Read and/or agree the Defang terms of service",
   997  	RunE: func(cmd *cobra.Command, args []string) error {
   998  		agree, _ := cmd.Flags().GetBool("agree-tos")
   999  
  1000  		if agree {
  1001  			return cli.NonInteractiveAgreeToS(cmd.Context(), client)
  1002  		}
  1003  
  1004  		if !nonInteractive {
  1005  			return cli.InteractiveAgreeToS(cmd.Context(), client)
  1006  		}
  1007  
  1008  		printDefangHint("To agree to the terms of service, do:", cmd.CalledAs()+" --agree-tos")
  1009  		return nil
  1010  	},
  1011  }
  1012  
  1013  func awsInEnv() bool {
  1014  	return os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_SECRET_ACCESS_KEY") != ""
  1015  }