diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..48b0e40 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# CLAUDE.md + +## Layering + +`cmd/` is Cobra wiring only — command definitions, flag reading, client init. **There are no test files in `cmd/`; don't add any.** Logic worth testing goes in `internal/commands/` as a `RunX(...)` function, with dependencies injected so tests can fake them: a narrow `API` interface plus `NewAPI(client)`, `io.Reader` for confirmation prompts, `io.Writer` for output. Wanting to unit-test a `cmd/` helper means the logic is in the wrong package. + +## The SDK owns auth and config + +`massdriver/config` in the SDK is the sole authority on the config file — its schema, location, version, and which profile is active. Never resolve credentials or profiles in the CLI, and never add config keys the SDK doesn't read. If the CLI needs behavior the SDK lacks, change the SDK. The CLI only writes the file (`internal/configfile`) and passes `--profile` through as `massdriver.WithProfile`. + +## Before finishing + +- `make check` — tests and lint. +- `make docs` whenever a command, flag, or helpdoc changes. CI regenerates and fails on any diff. + +## Comments + +Default to none. Write one only when a reader who already understands the code still can't see *why*. Never narrate what the code says, and never narrate what changed or used to be there — git holds the history. No doc comments on unexported helpers. + +## Tests + +Table-driven, stdlib `testing`, hand-rolled fakes. One `_test.go` per source file — append cases rather than adding a file per scenario. For unexported access, switch the file to `package foo //nolint:testpackage // reason` instead of splitting the tests in two. diff --git a/cmd/bundle.go b/cmd/bundle.go index 5153ca1..d50a94c 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -68,7 +68,7 @@ func NewCmdBundle() *cobra.Command { //nolint:funlen // cobra command builders a Long: helpdocs.MustRender("bundle/list"), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return runBundleList(&bundleListInput) + return runBundleList(cmd, &bundleListInput) }, } bundleListCmd.Flags().StringVarP(&bundleListInput.search, "search", "s", "", "Search bundles by name, readme, and changelog") @@ -111,7 +111,7 @@ func NewCmdBundle() *cobra.Command { //nolint:funlen // cobra command builders a Long: helpdocs.MustRender("bundle/new"), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return runBundleNew(&bundleNewInput) + return runBundleNew(cmd, &bundleNewInput) }, } bundleNewCmd.Flags().StringVarP(&bundleNewInput.name, "name", "n", "", "Name of the new bundle. Setting this along with --template-name will disable the interactive prompt.") @@ -200,7 +200,7 @@ func runBundleCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -280,14 +280,14 @@ func runBundleNewFlags(input *bundleNew) (*templates.TemplateData, error) { return templateData, nil } -func runBundleNew(input *bundleNew) error { +func runBundleNew(cmd *cobra.Command, input *bundleNew) error { ctx := context.Background() var templateData *templates.TemplateData var runErr error if input.name == "" || input.templateName == "" { // run the interactive prompt - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -357,7 +357,7 @@ func runBundleBuild(cmd *cobra.Command, args []string) error { return err } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -391,7 +391,7 @@ func runBundleLint(cmd *cobra.Command, args []string) error { return err } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -451,7 +451,7 @@ func runBundlePublish(cmd *cobra.Command, args []string) error { return err } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -517,7 +517,7 @@ func runBundlePull(cmd *cobra.Command, args []string) error { } } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -530,10 +530,10 @@ func runBundlePull(cmd *cobra.Command, args []string) error { return nil } -func runBundleList(input *bundleList) error { +func runBundleList(cmd *cobra.Command, input *bundleList) error { ctx := context.Background() - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -595,7 +595,7 @@ func runBundleGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/client.go b/cmd/client.go new file mode 100644 index 0000000..0ce385c --- /dev/null +++ b/cmd/client.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + "github.com/spf13/cobra" +) + +// newMassdriverClient builds a client for the profile --profile names. An empty +// flag is not an override, leaving the SDK's own precedence untouched. +func newMassdriverClient(cmd *cobra.Command) (*massdriver.Client, error) { + profile, err := cmd.Flags().GetString("profile") + if err != nil { + return nil, err + } + return massdriver.NewClient(massdriver.WithProfile(profile)) +} diff --git a/cmd/component.go b/cmd/component.go index 0e718ec..5f6d003 100644 --- a/cmd/component.go +++ b/cmd/component.go @@ -8,7 +8,6 @@ import ( "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/component" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/components" "github.com/spf13/cobra" @@ -115,7 +114,7 @@ func runComponentAdd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -155,7 +154,7 @@ func runComponentUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -192,7 +191,7 @@ func runComponentRemove(cmd *cobra.Command, args []string) error { componentID := args[0] cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -229,7 +228,7 @@ func runComponentLink(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -270,7 +269,7 @@ func runComponentUnlink(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..db4eba7 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,295 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/massdriver-cloud/mass/docs/helpdocs" + cmdconfig "github.com/massdriver-cloud/mass/internal/commands/config" + "github.com/massdriver-cloud/mass/internal/configfile" + "github.com/spf13/cobra" +) + +// NewCmdConfig returns a cobra command for managing the CLI configuration file. +func NewCmdConfig() *cobra.Command { + configCmd := &cobra.Command{ + Use: "config", + Short: "Manage CLI configuration profiles", + Long: helpdocs.MustRender("config"), + Aliases: []string{"profile"}, + } + + configListCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List configured profiles", + Example: `mass config list`, + Long: helpdocs.MustRender("config/list"), + Args: cobra.NoArgs, + RunE: runConfigList, + } + configListCmd.Flags().StringP("output", "o", "table", "Output format (table, json)") + configListCmd.Flags().Bool("show-secrets", false, "Print API keys in full instead of masking them") + + configGetCmd := &cobra.Command{ + Use: "get [profile]", + Short: "Show a profile's settings (defaults to the active profile)", + Example: `mass config get staging`, + Long: helpdocs.MustRender("config/get"), + Args: cobra.MaximumNArgs(1), + RunE: runConfigGet, + } + configGetCmd.Flags().StringP("output", "o", "text", "Output format (text, json)") + configGetCmd.Flags().Bool("show-secrets", false, "Print the API key in full instead of masking it") + + configAddCmd := &cobra.Command{ + Use: "add [profile]", + Aliases: []string{"create"}, + Short: "Add a profile", + Example: `mass config add staging --org acme --api-key mds_xxx`, + Long: helpdocs.MustRender("config/add"), + Args: cobra.ExactArgs(1), + RunE: runConfigAdd, + } + configAddCmd.Flags().String("org", "", "Organization abbreviation") + configAddCmd.Flags().String("api-key", "", "API key or personal access token") + configAddCmd.Flags().String("url", "", "Massdriver API URL (defaults to https://api.massdriver.cloud)") + configAddCmd.Flags().String("templates-path", "", "Directory containing bundle templates") + configAddCmd.Flags().BoolP("force", "f", false, "Overwrite the profile if it already exists") + configAddCmd.Flags().Bool("no-verify", false, "Skip checking the credentials against the Massdriver API") + configAddCmd.Flags().Bool("use", false, "Make this the active profile after adding it") + + configSetCmd := &cobra.Command{ + Use: "set [profile]", + Aliases: []string{"update"}, + Short: "Update settings on an existing profile", + Example: `mass config set staging --url https://api.massdriver.cloud`, + Long: helpdocs.MustRender("config/set"), + Args: cobra.ExactArgs(1), + RunE: runConfigSet, + } + configSetCmd.Flags().String("org", "", "Organization abbreviation") + configSetCmd.Flags().String("api-key", "", "API key or personal access token") + configSetCmd.Flags().String("url", "", "Massdriver API URL (defaults to https://api.massdriver.cloud)") + configSetCmd.Flags().String("templates-path", "", "Directory containing bundle templates") + configSetCmd.Flags().Bool("no-verify", false, "Skip checking the credentials against the Massdriver API") + + configRemoveCmd := &cobra.Command{ + Use: "remove [profile]", + Aliases: []string{"rm", "delete"}, + Short: "Remove a profile", + Example: `mass config remove staging`, + Long: helpdocs.MustRender("config/remove"), + Args: cobra.ExactArgs(1), + RunE: runConfigRemove, + } + configRemoveCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + + configUseCmd := &cobra.Command{ + Use: "use [profile]", + Short: "Set the active profile", + Example: `mass config use staging`, + Long: helpdocs.MustRender("config/use"), + Args: cobra.ExactArgs(1), + RunE: runConfigUse, + } + + configPathCmd := &cobra.Command{ + Use: "path", + Short: "Print the path to the configuration file", + Example: `mass config path`, + Long: helpdocs.MustRender("config/path"), + Args: cobra.NoArgs, + RunE: runConfigPath, + } + + configCmd.AddCommand(configListCmd) + configCmd.AddCommand(configGetCmd) + configCmd.AddCommand(configAddCmd) + configCmd.AddCommand(configSetCmd) + configCmd.AddCommand(configRemoveCmd) + configCmd.AddCommand(configUseCmd) + configCmd.AddCommand(configPathCmd) + + return configCmd +} + +func runConfigList(cmd *cobra.Command, args []string) error { + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + showSecrets, err := cmd.Flags().GetBool("show-secrets") + if err != nil { + return err + } + profileOverride, err := cmd.Flags().GetString("profile") + if err != nil { + return err + } + + cmd.SilenceUsage = true + + file, err := configfile.Load() + if err != nil { + return err + } + + opts := cmdconfig.ListOptions{Output: output, ShowSecrets: showSecrets, ProfileOverride: profileOverride} + return cmdconfig.RunList(file, opts, os.Stdout) +} + +func runConfigGet(cmd *cobra.Command, args []string) error { + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + showSecrets, err := cmd.Flags().GetBool("show-secrets") + if err != nil { + return err + } + profileOverride, err := cmd.Flags().GetString("profile") + if err != nil { + return err + } + + var name string + if len(args) == 1 { + name = args[0] + } + + cmd.SilenceUsage = true + + file, err := configfile.Load() + if err != nil { + return err + } + + opts := cmdconfig.GetOptions{Output: output, ShowSecrets: showSecrets, ProfileOverride: profileOverride} + return cmdconfig.RunGet(file, name, opts, os.Stdout) +} + +func runConfigAdd(cmd *cobra.Command, args []string) error { + edits, err := profileEdits(cmd) + if err != nil { + return err + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + use, err := cmd.Flags().GetBool("use") + if err != nil { + return err + } + noVerify, err := cmd.Flags().GetBool("no-verify") + if err != nil { + return err + } + + cmd.SilenceUsage = true + + file, err := configfile.Load() + if err != nil { + return err + } + + opts := cmdconfig.AddOptions{ + Edits: edits, + Force: force, + Use: use, + Prompter: cmdconfig.NewPrompter(), + } + if !noVerify { + opts.Verifier = cmdconfig.NewVerifier() + } + return cmdconfig.RunAdd(context.Background(), file, args[0], opts, os.Stdout) +} + +func runConfigSet(cmd *cobra.Command, args []string) error { + edits, err := profileEdits(cmd) + if err != nil { + return err + } + noVerify, err := cmd.Flags().GetBool("no-verify") + if err != nil { + return err + } + + cmd.SilenceUsage = true + + file, err := configfile.Load() + if err != nil { + return err + } + + var verifier cmdconfig.Verifier + if !noVerify { + verifier = cmdconfig.NewVerifier() + } + return cmdconfig.RunSet(context.Background(), file, args[0], edits, verifier, os.Stdout) +} + +func runConfigRemove(cmd *cobra.Command, args []string) error { + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + + cmd.SilenceUsage = true + + file, err := configfile.Load() + if err != nil { + return err + } + + return cmdconfig.RunRemove(file, args[0], force, os.Stdin, os.Stdout) +} + +func runConfigUse(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + file, err := configfile.Load() + if err != nil { + return err + } + + return cmdconfig.RunUse(file, args[0], os.Stdout) +} + +func runConfigPath(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + path, err := configfile.Path() + if err != nil { + return err + } + fmt.Println(path) + return nil +} + +// profileEdits reads the value flags `add` and `set` share, recording only the +// ones actually passed so an update leaves the rest of the profile alone. +func profileEdits(cmd *cobra.Command) (cmdconfig.ProfileEdits, error) { + var edits cmdconfig.ProfileEdits + for _, field := range []struct { + flag string + dest **string + }{ + {"org", &edits.OrganizationID}, + {"api-key", &edits.APIKey}, + {"url", &edits.URL}, + {"templates-path", &edits.TemplatesPath}, + } { + if !cmd.Flags().Changed(field.flag) { + continue + } + value, err := cmd.Flags().GetString(field.flag) + if err != nil { + return edits, err + } + *field.dest = &value + } + return edits, nil +} diff --git a/cmd/deployment.go b/cmd/deployment.go index b67b1e7..e6cc745 100644 --- a/cmd/deployment.go +++ b/cmd/deployment.go @@ -18,7 +18,6 @@ import ( "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/prettylogs" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/deployments" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" @@ -133,7 +132,7 @@ func runDeploymentGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -174,7 +173,7 @@ func runDeploymentList(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -233,7 +232,7 @@ func runDeploymentLogs(cmd *cobra.Command, args []string) error { ctx, cancel := signalContext(context.Background()) defer cancel() - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -273,7 +272,7 @@ func runDeploymentCompare(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -342,7 +341,7 @@ func runDeploymentAbort(cmd *cobra.Command, args []string) error { } } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -361,7 +360,7 @@ func runDeploymentApprove(cmd *cobra.Command, args []string) error { deploymentID := args[0] cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -380,7 +379,7 @@ func runDeploymentReject(cmd *cobra.Command, args []string) error { deploymentID := args[0] cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/environment.go b/cmd/environment.go index ead1397..32ccb91 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -203,7 +203,7 @@ func runEnvironmentExport(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -222,7 +222,7 @@ func runEnvironmentGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -263,7 +263,7 @@ func runEnvironmentList(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -377,7 +377,7 @@ func runEnvironmentCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -419,7 +419,7 @@ func runEnvironmentUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -462,7 +462,7 @@ func runEnvironmentDelete(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -503,7 +503,7 @@ func runEnvironmentDefault(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -555,7 +555,7 @@ func runEnvironmentPreview(cmd *cobra.Command, args []string) error { return configErr } - mdClient, mdClientErr := massdriver.NewClient() + mdClient, mdClientErr := newMassdriverClient(cmd) if mdClientErr != nil { return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) } @@ -619,7 +619,7 @@ func runEnvironmentFork(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := massdriver.NewClient() + mdClient, mdClientErr := newMassdriverClient(cmd) if mdClientErr != nil { return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) } @@ -655,7 +655,7 @@ func runEnvironmentDeploy(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := massdriver.NewClient() + mdClient, mdClientErr := newMassdriverClient(cmd) if mdClientErr != nil { return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) } @@ -685,7 +685,7 @@ func runEnvironmentDecommission(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, mdClientErr := massdriver.NewClient() + mdClient, mdClientErr := newMassdriverClient(cmd) if mdClientErr != nil { return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) } @@ -720,7 +720,7 @@ func runEnvironmentCompare(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/instance.go b/cmd/instance.go index 4818b41..084386a 100644 --- a/cmd/instance.go +++ b/cmd/instance.go @@ -214,7 +214,7 @@ func runInstanceGet(cmd *cobra.Command, args []string) error { instanceID := args[0] - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -381,7 +381,7 @@ func runInstanceDeploy(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -465,7 +465,7 @@ func runInstanceCopy(cmd *cobra.Command, args []string) error { } } - mdClient, mdClientErr := massdriver.NewClient() + mdClient, mdClientErr := newMassdriverClient(cmd) if mdClientErr != nil { return fmt.Errorf("error initializing massdriver client: %w", mdClientErr) } @@ -493,7 +493,7 @@ func runInstanceExport(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -519,7 +519,7 @@ func runInstanceVersion(cmd *cobra.Command, args []string) error { instanceID := parts[0] version := parts[1] - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -564,7 +564,7 @@ func runInstanceOrphan(cmd *cobra.Command, args []string) error { } } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -584,7 +584,7 @@ func runInstanceRollback(cmd *cobra.Command, args []string) error { deploymentID := args[0] cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -610,7 +610,7 @@ func runInstanceRemoteReferenceSet(cmd *cobra.Command, args []string) error { resourceID := args[2] cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -631,7 +631,7 @@ func runInstanceRemoteReferenceRemove(cmd *cobra.Command, args []string) error { field := args[1] cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -660,7 +660,7 @@ func runInstanceList(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/project.go b/cmd/project.go index 9f87516..012e7f3 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -16,7 +16,6 @@ import ( "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/project" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/projects" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" @@ -124,7 +123,7 @@ func runProjectGet(cmd *cobra.Command, args []string) error { return err } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -160,7 +159,7 @@ func runProjectExport(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -178,7 +177,7 @@ func runProjectList(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") search, _ := cmd.Flags().GetString("search") - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -271,7 +270,7 @@ func runProjectCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -316,7 +315,7 @@ func runProjectClone(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -358,7 +357,7 @@ func runProjectUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -401,7 +400,7 @@ func runProjectDelete(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/repository.go b/cmd/repository.go index f58a5b2..665ec26 100644 --- a/cmd/repository.go +++ b/cmd/repository.go @@ -40,7 +40,7 @@ func NewCmdRepository() *cobra.Command { Short: "List OCI repositories", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - return runRepositoryList(&listInput, cmd.Flags().Changed("order")) + return runRepositoryList(cmd, &listInput, cmd.Flags().Changed("order")) }, } repositoryListCmd.Flags().StringVarP(&listInput.name, "name", "n", "", "Filter by exact repository name") @@ -105,10 +105,10 @@ type repositoryListInput struct { output string } -func runRepositoryList(input *repositoryListInput, orderChanged bool) error { +func runRepositoryList(cmd *cobra.Command, input *repositoryListInput, orderChanged bool) error { ctx := context.Background() - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -224,7 +224,7 @@ func runRepositoryGet(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -314,7 +314,7 @@ func runRepositoryCreate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -355,7 +355,7 @@ func runRepositoryUpdate(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -394,7 +394,7 @@ func runRepositoryDelete(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/resource.go b/cmd/resource.go index 4dd64de..cca5b7d 100644 --- a/cmd/resource.go +++ b/cmd/resource.go @@ -14,7 +14,6 @@ import ( "github.com/massdriver-cloud/mass/docs/helpdocs" "github.com/massdriver-cloud/mass/internal/cli" "github.com/massdriver-cloud/mass/internal/commands/resource" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resources" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" @@ -192,7 +191,7 @@ func runResourceList(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -265,7 +264,7 @@ func runResourceCreate(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -294,7 +293,7 @@ func runResourceUpdate(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -313,7 +312,7 @@ func runResourceDelete(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -331,7 +330,7 @@ func runResourceGet(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -370,7 +369,7 @@ func runResourceDownload(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/resource_type.go b/cmd/resource_type.go index b4be11d..f825a0b 100644 --- a/cmd/resource_type.go +++ b/cmd/resource_type.go @@ -19,7 +19,6 @@ import ( cmdresourcetype "github.com/massdriver-cloud/mass/internal/commands/resourcetype" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/resourcetype" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/ocirepos" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" "github.com/spf13/cobra" @@ -126,7 +125,7 @@ func runTypeCreate(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -152,7 +151,7 @@ func runTypeGet(cmd *cobra.Command, args []string) error { return errors.New("--schema requires -o json") } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -193,7 +192,7 @@ func runTypePublish(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -236,7 +235,7 @@ func runTypePull(cmd *cobra.Command, args []string) error { } } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -264,7 +263,7 @@ func runTypeList(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } @@ -308,7 +307,7 @@ func runTypeDelete(cmd *cobra.Command, args []string) error { } cmd.SilenceUsage = true - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/root.go b/cmd/root.go index 2dd6feb..6e11068 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -33,8 +33,11 @@ var rootCmd = &cobra.Command{ // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { + rootCmd.PersistentFlags().String("profile", "", "Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile)") + rootCmd.AddCommand(NewCmdBundle()) rootCmd.AddCommand(NewCmdComponent()) + rootCmd.AddCommand(NewCmdConfig()) rootCmd.AddCommand(NewCmdDeployment()) rootCmd.AddCommand(NewCmdDocs()) rootCmd.AddCommand(NewCmdEnvironment()) diff --git a/cmd/schema.go b/cmd/schema.go index 5652158..3bff0bb 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -11,7 +11,6 @@ import ( "github.com/massdriver-cloud/mass/internal/jsonschema" "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/resourcetype" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/spf13/cobra" ) @@ -75,7 +74,7 @@ func runSchemaDereference(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to decode JSON schema: %w", err) } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/cmd/version.go b/cmd/version.go index 8a3363b..cfde88e 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,7 +6,6 @@ import ( "github.com/massdriver-cloud/mass/internal/prettylogs" "github.com/massdriver-cloud/mass/internal/version" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/spf13/cobra" ) @@ -40,7 +39,7 @@ func runVersion(cmd *cobra.Command, args []string) { // Best-effort: if we can authenticate, show the Massdriver server version too. ctx := context.Background() - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return } diff --git a/cmd/whoami.go b/cmd/whoami.go index 10c4cab..2ed5e2d 100644 --- a/cmd/whoami.go +++ b/cmd/whoami.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/viewer" "github.com/spf13/cobra" ) @@ -32,7 +31,7 @@ func runWhoami(cmd *cobra.Command, args []string) error { return err } - mdClient, err := massdriver.NewClient() + mdClient, err := newMassdriverClient(cmd) if err != nil { return fmt.Errorf("error initializing massdriver client: %w", err) } diff --git a/docs/generated/mass.md b/docs/generated/mass.md index 8bf4b60..3a856b7 100644 --- a/docs/generated/mass.md +++ b/docs/generated/mass.md @@ -27,7 +27,8 @@ Configure and deploying infrastructure and applications. ### Options ``` - -h, --help help for mass + -h, --help help for mass + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) ``` ### SEE ALSO @@ -35,6 +36,7 @@ Configure and deploying infrastructure and applications. * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles * [mass completion](/cli/commands/mass_completion) - Generate the autocompletion script for the specified shell * [mass component](/cli/commands/mass_component) - Manage components in a project's blueprint +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles * [mass deployment](/cli/commands/mass_deployment) - Manage deployments * [mass docs](/cli/commands/mass_docs) - Gen docs * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_bundle.md b/docs/generated/mass_bundle.md index 4d1a14c..e7971d7 100644 --- a/docs/generated/mass_bundle.md +++ b/docs/generated/mass_bundle.md @@ -19,6 +19,12 @@ Generate and publish bundles -h, --help help for bundle ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_bundle_build.md b/docs/generated/mass_bundle_build.md index fa68f98..974f473 100644 --- a/docs/generated/mass_bundle_build.md +++ b/docs/generated/mass_bundle_build.md @@ -19,6 +19,12 @@ mass bundle build [path] [flags] -h, --help help for build ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_create.md b/docs/generated/mass_bundle_create.md index 0114c4d..1427ab9 100644 --- a/docs/generated/mass_bundle_create.md +++ b/docs/generated/mass_bundle_create.md @@ -25,6 +25,12 @@ mass bundle create aws-aurora-postgres -a owner=data,service=database -h, --help help for create ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_get.md b/docs/generated/mass_bundle_get.md index 32c16a0..9b96870 100644 --- a/docs/generated/mass_bundle_get.md +++ b/docs/generated/mass_bundle_get.md @@ -54,6 +54,12 @@ mass bundle get [@] [flags] -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_import.md b/docs/generated/mass_bundle_import.md index 8509e84..e81f737 100644 --- a/docs/generated/mass_bundle_import.md +++ b/docs/generated/mass_bundle_import.md @@ -41,6 +41,12 @@ mass bundle import [path] [flags] -h, --help help for import ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_lint.md b/docs/generated/mass_bundle_lint.md index b7adda3..0d30f13 100644 --- a/docs/generated/mass_bundle_lint.md +++ b/docs/generated/mass_bundle_lint.md @@ -19,6 +19,12 @@ mass bundle lint [path] [flags] -h, --help help for lint ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_list.md b/docs/generated/mass_bundle_list.md index c55972e..03ce17f 100644 --- a/docs/generated/mass_bundle_list.md +++ b/docs/generated/mass_bundle_list.md @@ -63,6 +63,12 @@ mass bundle list [flags] --sort string Sort field (name, created_at). Defaults to name, or relevance when using --search ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_new.md b/docs/generated/mass_bundle_new.md index 097daca..9ba6cf7 100644 --- a/docs/generated/mass_bundle_new.md +++ b/docs/generated/mass_bundle_new.md @@ -115,6 +115,12 @@ mass bundle new [flags] -t, --template-name string Name of the bundle template to use. Setting this along with --name will disable the interactive prompt. ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_publish.md b/docs/generated/mass_bundle_publish.md index 2d9012e..1083c97 100644 --- a/docs/generated/mass_bundle_publish.md +++ b/docs/generated/mass_bundle_publish.md @@ -22,6 +22,12 @@ mass bundle publish [path] [flags] -s, --skip-lint Skip linting ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_pull.md b/docs/generated/mass_bundle_pull.md index f8c56ef..89eb526 100644 --- a/docs/generated/mass_bundle_pull.md +++ b/docs/generated/mass_bundle_pull.md @@ -20,6 +20,12 @@ mass bundle pull [@] [flags] -h, --help help for pull ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_template.md b/docs/generated/mass_bundle_template.md index 2d91c64..cd077c1 100644 --- a/docs/generated/mass_bundle_template.md +++ b/docs/generated/mass_bundle_template.md @@ -44,6 +44,12 @@ For more information on bundle templates, see the [Bundle Templates Guide](https -h, --help help for template ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle](/cli/commands/mass_bundle) - Generate and publish bundles diff --git a/docs/generated/mass_bundle_template_list.md b/docs/generated/mass_bundle_template_list.md index 38d054e..f975389 100644 --- a/docs/generated/mass_bundle_template_list.md +++ b/docs/generated/mass_bundle_template_list.md @@ -67,6 +67,12 @@ mass bundle template list [flags] -o, --output string Output format (text, json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass bundle template](/cli/commands/mass_bundle_template) - Application template development tools diff --git a/docs/generated/mass_completion.md b/docs/generated/mass_completion.md index 80c18be..317f7ee 100644 --- a/docs/generated/mass_completion.md +++ b/docs/generated/mass_completion.md @@ -20,6 +20,12 @@ See each sub-command's help for details on how to use the generated script. -h, --help help for completion ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_completion_bash.md b/docs/generated/mass_completion_bash.md index c05dd99..6d8e879 100644 --- a/docs/generated/mass_completion_bash.md +++ b/docs/generated/mass_completion_bash.md @@ -43,6 +43,12 @@ mass completion bash --no-descriptions disable completion descriptions ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass completion](/cli/commands/mass_completion) - Generate the autocompletion script for the specified shell diff --git a/docs/generated/mass_completion_fish.md b/docs/generated/mass_completion_fish.md index 405396f..a6c771a 100644 --- a/docs/generated/mass_completion_fish.md +++ b/docs/generated/mass_completion_fish.md @@ -34,6 +34,12 @@ mass completion fish [flags] --no-descriptions disable completion descriptions ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass completion](/cli/commands/mass_completion) - Generate the autocompletion script for the specified shell diff --git a/docs/generated/mass_completion_powershell.md b/docs/generated/mass_completion_powershell.md index e3a8906..f1a2cd7 100644 --- a/docs/generated/mass_completion_powershell.md +++ b/docs/generated/mass_completion_powershell.md @@ -31,6 +31,12 @@ mass completion powershell [flags] --no-descriptions disable completion descriptions ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass completion](/cli/commands/mass_completion) - Generate the autocompletion script for the specified shell diff --git a/docs/generated/mass_completion_zsh.md b/docs/generated/mass_completion_zsh.md index 2eccc5a..72f707a 100644 --- a/docs/generated/mass_completion_zsh.md +++ b/docs/generated/mass_completion_zsh.md @@ -45,6 +45,12 @@ mass completion zsh [flags] --no-descriptions disable completion descriptions ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass completion](/cli/commands/mass_completion) - Generate the autocompletion script for the specified shell diff --git a/docs/generated/mass_component.md b/docs/generated/mass_component.md index 96d2e51..ea37403 100644 --- a/docs/generated/mass_component.md +++ b/docs/generated/mass_component.md @@ -30,6 +30,12 @@ Use these commands to manage components and links in a project's blueprint: -h, --help help for component ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_component_add.md b/docs/generated/mass_component_add.md index c6c422d..9ef1f03 100644 --- a/docs/generated/mass_component_add.md +++ b/docs/generated/mass_component_add.md @@ -55,6 +55,12 @@ mass component add ecomm aws-rds-cluster --id db --name "Primary Database" -n, --name string Display name (defaults to --id if not provided) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass component](/cli/commands/mass_component) - Manage components in a project's blueprint diff --git a/docs/generated/mass_component_link.md b/docs/generated/mass_component_link.md index be703da..b0a0844 100644 --- a/docs/generated/mass_component_link.md +++ b/docs/generated/mass_component_link.md @@ -55,6 +55,12 @@ mass component link ecomm-db.authentication ecomm-app.database --from-version ~1 --to-version string Version constraint for the destination component (default "latest") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass component](/cli/commands/mass_component) - Manage components in a project's blueprint diff --git a/docs/generated/mass_component_remove.md b/docs/generated/mass_component_remove.md index 8b2765f..8b3a1df 100644 --- a/docs/generated/mass_component_remove.md +++ b/docs/generated/mass_component_remove.md @@ -45,6 +45,12 @@ mass component remove ecomm-db -h, --help help for remove ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass component](/cli/commands/mass_component) - Manage components in a project's blueprint diff --git a/docs/generated/mass_component_unlink.md b/docs/generated/mass_component_unlink.md index 7f82c2a..af57a7c 100644 --- a/docs/generated/mass_component_unlink.md +++ b/docs/generated/mass_component_unlink.md @@ -43,6 +43,12 @@ mass component unlink ecomm-db.authentication ecomm-app.database -h, --help help for unlink ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass component](/cli/commands/mass_component) - Manage components in a project's blueprint diff --git a/docs/generated/mass_component_update.md b/docs/generated/mass_component_update.md index e6aad76..81b8db4 100644 --- a/docs/generated/mass_component_update.md +++ b/docs/generated/mass_component_update.md @@ -27,6 +27,12 @@ mass component update ecomm-db --name "Primary DB" -a priority=high -n, --name string New display name ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass component](/cli/commands/mass_component) - Manage components in a project's blueprint diff --git a/docs/generated/mass_config.md b/docs/generated/mass_config.md new file mode 100644 index 0000000..8878702 --- /dev/null +++ b/docs/generated/mass_config.md @@ -0,0 +1,59 @@ +--- +id: mass_config.md +slug: /cli/commands/mass_config +title: Mass Config +sidebar_label: Mass Config +--- +## mass config + +Manage CLI configuration profiles + +### Synopsis + +# Manage Configuration Profiles + +A profile holds the organization, credential, and API URL used to talk to Massdriver. Profiles live in `~/.config/massdriver/config.yaml` (or `$XDG_CONFIG_HOME/massdriver/config.yaml`), and these commands read and write that file for you. + +Use separate profiles to work across organizations or against a self-hosted installation without re-editing the file each time. + +## Choosing a profile + +A profile is selected in this order, highest precedence first: + +1. The `--profile` flag +2. The `MASSDRIVER_PROFILE` environment variable +3. The `current_profile` key in the config file, set by `mass config use` +4. The profile named `default` + +The first three name a profile explicitly, so a name that isn't in the file is an error rather than a silent fallback. The `default` fallback may be absent — that is how credentials supplied entirely through environment variables resolve. + +## Getting started + +```bash +mass config add default --org acme --api-key mds_xxx +mass config list +``` + + +### Options + +``` + -h, --help help for config +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass](/cli/commands/mass) - Massdriver Cloud CLI +* [mass config add](/cli/commands/mass_config_add) - Add a profile +* [mass config get](/cli/commands/mass_config_get) - Show a profile's settings (defaults to the active profile) +* [mass config list](/cli/commands/mass_config_list) - List configured profiles +* [mass config path](/cli/commands/mass_config_path) - Print the path to the configuration file +* [mass config remove](/cli/commands/mass_config_remove) - Remove a profile +* [mass config set](/cli/commands/mass_config_set) - Update settings on an existing profile +* [mass config use](/cli/commands/mass_config_use) - Set the active profile diff --git a/docs/generated/mass_config_add.md b/docs/generated/mass_config_add.md new file mode 100644 index 0000000..538a58a --- /dev/null +++ b/docs/generated/mass_config_add.md @@ -0,0 +1,75 @@ +--- +id: mass_config_add.md +slug: /cli/commands/mass_config_add +title: Mass Config Add +sidebar_label: Mass Config Add +--- +## mass config add + +Add a profile + +### Synopsis + +# Add a Profile + +Adds a profile to the configuration file. When run in a terminal, you are prompted for any required value you did not pass as a flag. + +The credentials are checked against the Massdriver API before the profile is written, so a mistyped key or organization fails here rather than on your next command. Pass `--no-verify` to skip that check. + +The first profile you add becomes the active profile. + +## Usage + +```bash +mass config add +``` + +## Examples + +```bash +# Add a profile interactively +mass config add staging + +# Add a profile non-interactively +mass config add staging --org acme --api-key mds_xxx + +# Add a profile pointing at a self-hosted installation +mass config add onprem --org acme --api-key mds_xxx --url https://api.massdriver.example.com + +# Add a profile and make it active +mass config add staging --org acme --api-key mds_xxx --use +``` + + +``` +mass config add [profile] [flags] +``` + +### Examples + +``` +mass config add staging --org acme --api-key mds_xxx +``` + +### Options + +``` + --api-key string API key or personal access token + -f, --force Overwrite the profile if it already exists + -h, --help help for add + --no-verify Skip checking the credentials against the Massdriver API + --org string Organization abbreviation + --templates-path string Directory containing bundle templates + --url string Massdriver API URL (defaults to https://api.massdriver.cloud) + --use Make this the active profile after adding it +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles diff --git a/docs/generated/mass_config_get.md b/docs/generated/mass_config_get.md new file mode 100644 index 0000000..2f7f559 --- /dev/null +++ b/docs/generated/mass_config_get.md @@ -0,0 +1,62 @@ +--- +id: mass_config_get.md +slug: /cli/commands/mass_config_get +title: Mass Config Get +sidebar_label: Mass Config Get +--- +## mass config get + +Show a profile's settings (defaults to the active profile) + +### Synopsis + +# Get a Profile + +Shows the settings for one profile. With no argument, shows the active profile. + +The API key is masked. Pass `--show-secrets` to print it in full. + +## Usage + +```bash +mass config get [profile] +``` + +## Examples + +```bash +# Show the active profile +mass config get + +# Show a specific profile +mass config get staging +``` + + +``` +mass config get [profile] [flags] +``` + +### Examples + +``` +mass config get staging +``` + +### Options + +``` + -h, --help help for get + -o, --output string Output format (text, json) (default "text") + --show-secrets Print the API key in full instead of masking it +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles diff --git a/docs/generated/mass_config_list.md b/docs/generated/mass_config_list.md new file mode 100644 index 0000000..5823bd7 --- /dev/null +++ b/docs/generated/mass_config_list.md @@ -0,0 +1,62 @@ +--- +id: mass_config_list.md +slug: /cli/commands/mass_config_list +title: Mass Config List +sidebar_label: Mass Config List +--- +## mass config list + +List configured profiles + +### Synopsis + +# List Profiles + +Lists every profile in the configuration file. The active profile is marked with `*`. + +API keys are masked. Pass `--show-secrets` to print them in full. + +## Usage + +```bash +mass config list +``` + +## Examples + +```bash +# List profiles +mass config list + +# List profiles as JSON +mass config list --output json +``` + + +``` +mass config list [flags] +``` + +### Examples + +``` +mass config list +``` + +### Options + +``` + -h, --help help for list + -o, --output string Output format (table, json) (default "table") + --show-secrets Print API keys in full instead of masking them +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles diff --git a/docs/generated/mass_config_path.md b/docs/generated/mass_config_path.md new file mode 100644 index 0000000..f5fdcd6 --- /dev/null +++ b/docs/generated/mass_config_path.md @@ -0,0 +1,60 @@ +--- +id: mass_config_path.md +slug: /cli/commands/mass_config_path +title: Mass Config Path +sidebar_label: Mass Config Path +--- +## mass config path + +Print the path to the configuration file + +### Synopsis + +# Print the Configuration File Path + +Prints where the CLI reads its configuration from, so you can open or back up the file directly. + +The location is `$XDG_CONFIG_HOME/massdriver/config.yaml` when `XDG_CONFIG_HOME` is set, and `~/.config/massdriver/config.yaml` otherwise. + +## Usage + +```bash +mass config path +``` + +## Examples + +```bash +# Print the path +mass config path + +# Open the file in your editor +$EDITOR "$(mass config path)" +``` + + +``` +mass config path [flags] +``` + +### Examples + +``` +mass config path +``` + +### Options + +``` + -h, --help help for path +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles diff --git a/docs/generated/mass_config_remove.md b/docs/generated/mass_config_remove.md new file mode 100644 index 0000000..15259cb --- /dev/null +++ b/docs/generated/mass_config_remove.md @@ -0,0 +1,61 @@ +--- +id: mass_config_remove.md +slug: /cli/commands/mass_config_remove +title: Mass Config Remove +sidebar_label: Mass Config Remove +--- +## mass config remove + +Remove a profile + +### Synopsis + +# Remove a Profile + +Removes a profile from the configuration file. You are asked to confirm by typing the profile name unless `--force` is passed. + +If the removed profile was the active one, `current_profile` is cleared so later commands fall back to the `default` profile instead of failing on a name that no longer exists. + +## Usage + +```bash +mass config remove +``` + +## Examples + +```bash +# Remove a profile +mass config remove staging + +# Remove without the confirmation prompt +mass config remove staging --force +``` + + +``` +mass config remove [profile] [flags] +``` + +### Examples + +``` +mass config remove staging +``` + +### Options + +``` + -f, --force Skip confirmation prompt + -h, --help help for remove +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles diff --git a/docs/generated/mass_config_set.md b/docs/generated/mass_config_set.md new file mode 100644 index 0000000..615ac2b --- /dev/null +++ b/docs/generated/mass_config_set.md @@ -0,0 +1,71 @@ +--- +id: mass_config_set.md +slug: /cli/commands/mass_config_set +title: Mass Config Set +sidebar_label: Mass Config Set +--- +## mass config set + +Update settings on an existing profile + +### Synopsis + +# Update a Profile + +Updates settings on an existing profile. Only the values you pass are changed; everything else is left alone. Pass an empty string to remove a setting. + +Credentials are verified against the Massdriver API before the change is written. Pass `--no-verify` to skip that check. + +## Usage + +```bash +mass config set +``` + +## Examples + +```bash +# Rotate the API key +mass config set staging --api-key mds_yyy + +# Point a profile at a self-hosted installation +mass config set staging --url https://api.massdriver.example.com + +# Set where `mass bundle new` looks for templates +mass config set staging --templates-path ~/massdriver/templates + +# Clear the templates path +mass config set staging --templates-path "" +``` + + +``` +mass config set [profile] [flags] +``` + +### Examples + +``` +mass config set staging --url https://api.massdriver.cloud +``` + +### Options + +``` + --api-key string API key or personal access token + -h, --help help for set + --no-verify Skip checking the credentials against the Massdriver API + --org string Organization abbreviation + --templates-path string Directory containing bundle templates + --url string Massdriver API URL (defaults to https://api.massdriver.cloud) +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles diff --git a/docs/generated/mass_config_use.md b/docs/generated/mass_config_use.md new file mode 100644 index 0000000..08dba18 --- /dev/null +++ b/docs/generated/mass_config_use.md @@ -0,0 +1,60 @@ +--- +id: mass_config_use.md +slug: /cli/commands/mass_config_use +title: Mass Config Use +sidebar_label: Mass Config Use +--- +## mass config use + +Set the active profile + +### Synopsis + +# Set the Active Profile + +Makes a profile the one every `mass` command uses. The choice is written to the `current_profile` key in the config file, so it persists across shells and sessions. + +`MASSDRIVER_PROFILE` and the `--profile` flag both take precedence over this setting. + +## Usage + +```bash +mass config use +``` + +## Examples + +```bash +# Switch to the staging profile +mass config use staging + +# Run a single command against a different profile +mass project list --profile production +``` + + +``` +mass config use [profile] [flags] +``` + +### Examples + +``` +mass config use staging +``` + +### Options + +``` + -h, --help help for use +``` + +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + +### SEE ALSO + +* [mass config](/cli/commands/mass_config) - Manage CLI configuration profiles diff --git a/docs/generated/mass_deployment.md b/docs/generated/mass_deployment.md index b6127b5..4222642 100644 --- a/docs/generated/mass_deployment.md +++ b/docs/generated/mass_deployment.md @@ -30,6 +30,12 @@ Use these commands to inspect and manage deployments: -h, --help help for deployment ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_deployment_abort.md b/docs/generated/mass_deployment_abort.md index b9bdf6b..2d6bda2 100644 --- a/docs/generated/mass_deployment_abort.md +++ b/docs/generated/mass_deployment_abort.md @@ -57,6 +57,12 @@ mass deployment abort 12345678-1234-1234-1234-123456789012 --force -h, --help help for abort ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_deployment_approve.md b/docs/generated/mass_deployment_approve.md index 07764f7..69d4662 100644 --- a/docs/generated/mass_deployment_approve.md +++ b/docs/generated/mass_deployment_approve.md @@ -45,6 +45,12 @@ mass deployment approve 12345678-1234-1234-1234-123456789012 -h, --help help for approve ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_deployment_compare.md b/docs/generated/mass_deployment_compare.md index 443846c..82bbcbc 100644 --- a/docs/generated/mass_deployment_compare.md +++ b/docs/generated/mass_deployment_compare.md @@ -61,6 +61,12 @@ mass deployment compare 1111... 2222... -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_deployment_get.md b/docs/generated/mass_deployment_get.md index 3ec7fa4..110514e 100644 --- a/docs/generated/mass_deployment_get.md +++ b/docs/generated/mass_deployment_get.md @@ -45,6 +45,12 @@ mass deployment get 12345678-1234-1234-1234-123456789012 -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_deployment_list.md b/docs/generated/mass_deployment_list.md index e36c6f4..bfa0956 100644 --- a/docs/generated/mass_deployment_list.md +++ b/docs/generated/mass_deployment_list.md @@ -53,6 +53,12 @@ mass deployment list ecomm-prod-db --limit 25 --status string Filter by status (pending, approved, running, completed, failed, aborted, rejected, proposed) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_deployment_logs.md b/docs/generated/mass_deployment_logs.md index 2649203..c59d927 100644 --- a/docs/generated/mass_deployment_logs.md +++ b/docs/generated/mass_deployment_logs.md @@ -43,6 +43,12 @@ mass deployment logs 12345678-1234-1234-1234-123456789012 -h, --help help for logs ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_deployment_reject.md b/docs/generated/mass_deployment_reject.md index 4783b70..0d35087 100644 --- a/docs/generated/mass_deployment_reject.md +++ b/docs/generated/mass_deployment_reject.md @@ -45,6 +45,12 @@ mass deployment reject 12345678-1234-1234-1234-123456789012 -h, --help help for reject ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass deployment](/cli/commands/mass_deployment) - Manage deployments diff --git a/docs/generated/mass_docs.md b/docs/generated/mass_docs.md index 1d0153d..60bfd50 100644 --- a/docs/generated/mass_docs.md +++ b/docs/generated/mass_docs.md @@ -24,6 +24,12 @@ mass docs [flags] --log-level string Set the log level for the server. Options are [debug, info, warn, error] (default "info") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_environment.md b/docs/generated/mass_environment.md index 60012f9..fa91280 100644 --- a/docs/generated/mass_environment.md +++ b/docs/generated/mass_environment.md @@ -23,6 +23,12 @@ Environments can be modeled by application stage (production, staging, developme -h, --help help for environment ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_environment_compare.md b/docs/generated/mass_environment_compare.md index 1546af6..230f6ae 100644 --- a/docs/generated/mass_environment_compare.md +++ b/docs/generated/mass_environment_compare.md @@ -61,6 +61,12 @@ mass environment compare ecomm-staging ecomm-production -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_create.md b/docs/generated/mass_environment_create.md index 8c705ad..6633d72 100644 --- a/docs/generated/mass_environment_create.md +++ b/docs/generated/mass_environment_create.md @@ -48,6 +48,12 @@ mass environment create [ID] [flags] -n, --name string Environment name (defaults to ID if not provided) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_decommission.md b/docs/generated/mass_environment_decommission.md index 642e85a..90ac859 100644 --- a/docs/generated/mass_environment_decommission.md +++ b/docs/generated/mass_environment_decommission.md @@ -75,6 +75,12 @@ mass environment decommission ecomm-pr42 --follow -h, --help help for decommission ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_default.md b/docs/generated/mass_environment_default.md index 2034bac..e3856c0 100644 --- a/docs/generated/mass_environment_default.md +++ b/docs/generated/mass_environment_default.md @@ -43,6 +43,12 @@ mass environment default [environment] [resource-id] [flags] -h, --help help for default ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_delete.md b/docs/generated/mass_environment_delete.md index df82155..d140581 100644 --- a/docs/generated/mass_environment_delete.md +++ b/docs/generated/mass_environment_delete.md @@ -60,6 +60,12 @@ mass environment delete ecomm-staging -h, --help help for delete ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_deploy.md b/docs/generated/mass_environment_deploy.md index fa136c6..c63c435 100644 --- a/docs/generated/mass_environment_deploy.md +++ b/docs/generated/mass_environment_deploy.md @@ -65,6 +65,12 @@ mass environment deploy ecomm-staging --follow -h, --help help for deploy ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_export.md b/docs/generated/mass_environment_export.md index bc308d1..feb343d 100644 --- a/docs/generated/mass_environment_export.md +++ b/docs/generated/mass_environment_export.md @@ -50,6 +50,12 @@ mass environment export [environment] [flags] -h, --help help for export ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_fork.md b/docs/generated/mass_environment_fork.md index 5ddc2f4..0ea0472 100644 --- a/docs/generated/mass_environment_fork.md +++ b/docs/generated/mass_environment_fork.md @@ -83,6 +83,12 @@ mass environment fork ecomm-production staging -n, --name string Environment name (defaults to new-ID if not provided) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_get.md b/docs/generated/mass_environment_get.md index dda996e..424a709 100644 --- a/docs/generated/mass_environment_get.md +++ b/docs/generated/mass_environment_get.md @@ -43,6 +43,12 @@ mass environment get [environment] [flags] -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_list.md b/docs/generated/mass_environment_list.md index 0f7455d..ae2c33a 100644 --- a/docs/generated/mass_environment_list.md +++ b/docs/generated/mass_environment_list.md @@ -40,6 +40,12 @@ mass environment list [flags] -o, --output string Output format (table, json) (default "table") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_preview.md b/docs/generated/mass_environment_preview.md index 2046842..405c49a 100644 --- a/docs/generated/mass_environment_preview.md +++ b/docs/generated/mass_environment_preview.md @@ -138,6 +138,12 @@ mass environment preview [ID] [flags] -n, --name string Environment name (defaults to ID if not provided) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_environment_update.md b/docs/generated/mass_environment_update.md index 3e80091..07f6428 100644 --- a/docs/generated/mass_environment_update.md +++ b/docs/generated/mass_environment_update.md @@ -21,6 +21,12 @@ mass environment update [environment] [flags] -n, --name string New environment name ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass environment](/cli/commands/mass_environment) - Environment management diff --git a/docs/generated/mass_instance.md b/docs/generated/mass_instance.md index 7db52be..0709aa8 100644 --- a/docs/generated/mass_instance.md +++ b/docs/generated/mass_instance.md @@ -27,6 +27,12 @@ Instances are used to: -h, --help help for instance ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_instance_copy.md b/docs/generated/mass_instance_copy.md index 261a2cd..ac51c30 100644 --- a/docs/generated/mass_instance_copy.md +++ b/docs/generated/mass_instance_copy.md @@ -83,6 +83,12 @@ mass instance promote ecomm-staging-db --to ecomm-production-db --copy-secrets --to string Destination instance (required). Must be built from the same component as the source. ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_deploy.md b/docs/generated/mass_instance_deploy.md index a65203f..752424a 100644 --- a/docs/generated/mass_instance_deploy.md +++ b/docs/generated/mass_instance_deploy.md @@ -92,6 +92,12 @@ mass instance deploy ecomm-prod-vpc --propose Create the deployment in PROPOSED status, awaiting approval before it runs ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_destroy.md b/docs/generated/mass_instance_destroy.md index 5fce422..b886210 100644 --- a/docs/generated/mass_instance_destroy.md +++ b/docs/generated/mass_instance_destroy.md @@ -33,6 +33,12 @@ mass instance destroy api-prod-db --force -P, --patch stringArray Patch the last deployed configuration using a JQ expression. Can be specified multiple times. ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_export.md b/docs/generated/mass_instance_export.md index e17f231..f3039e1 100644 --- a/docs/generated/mass_instance_export.md +++ b/docs/generated/mass_instance_export.md @@ -63,6 +63,12 @@ mass instance export ecomm-prod-vpc -h, --help help for export ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_get.md b/docs/generated/mass_instance_get.md index 61696c5..c398367 100644 --- a/docs/generated/mass_instance_get.md +++ b/docs/generated/mass_instance_get.md @@ -49,6 +49,12 @@ mass instance get ecomm-prod-vpc -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_list.md b/docs/generated/mass_instance_list.md index aaacb92..8d5ccf5 100644 --- a/docs/generated/mass_instance_list.md +++ b/docs/generated/mass_instance_list.md @@ -48,6 +48,12 @@ mass instance list ecomm-prod --status string Filter by lifecycle status (initialized, provisioned, decommissioned, failed) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_orphan.md b/docs/generated/mass_instance_orphan.md index 4275e16..bf71765 100644 --- a/docs/generated/mass_instance_orphan.md +++ b/docs/generated/mass_instance_orphan.md @@ -55,6 +55,12 @@ mass instance orphan api-prod-db --force -h, --help help for orphan ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_remote-reference.md b/docs/generated/mass_instance_remote-reference.md index d6e6399..fac3480 100644 --- a/docs/generated/mass_instance_remote-reference.md +++ b/docs/generated/mass_instance_remote-reference.md @@ -30,6 +30,12 @@ See `mass instance remote-reference set --help` and `mass instance remote-refere -h, --help help for remote-reference ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_remote-reference_remove.md b/docs/generated/mass_instance_remote-reference_remove.md index 6b88603..94c5712 100644 --- a/docs/generated/mass_instance_remote-reference_remove.md +++ b/docs/generated/mass_instance_remote-reference_remove.md @@ -47,6 +47,12 @@ mass instance remote-reference remove ecomm-prod-api database -h, --help help for remove ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance remote-reference](/cli/commands/mass_instance_remote-reference) - Manage an instance's remote-reference connection overrides diff --git a/docs/generated/mass_instance_remote-reference_set.md b/docs/generated/mass_instance_remote-reference_set.md index bda3166..17ab77c 100644 --- a/docs/generated/mass_instance_remote-reference_set.md +++ b/docs/generated/mass_instance_remote-reference_set.md @@ -54,6 +54,12 @@ mass instance remote-reference set ecomm-prod-api database ecomm-prod-db.postgre -h, --help help for set ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance remote-reference](/cli/commands/mass_instance_remote-reference) - Manage an instance's remote-reference connection overrides diff --git a/docs/generated/mass_instance_rollback.md b/docs/generated/mass_instance_rollback.md index aef85d3..d44e1ee 100644 --- a/docs/generated/mass_instance_rollback.md +++ b/docs/generated/mass_instance_rollback.md @@ -51,6 +51,12 @@ mass instance rollback 12345678-1234-1234-1234-123456789012 -h, --help help for rollback ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_instance_version.md b/docs/generated/mass_instance_version.md index 63cef66..a6b9377 100644 --- a/docs/generated/mass_instance_version.md +++ b/docs/generated/mass_instance_version.md @@ -48,6 +48,12 @@ mass instance version api-prod-db@latest -h, --help help for version ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass instance](/cli/commands/mass_instance) - Manage instances of IaC deployed in environments. diff --git a/docs/generated/mass_project.md b/docs/generated/mass_project.md index 31ea143..9b4e846 100644 --- a/docs/generated/mass_project.md +++ b/docs/generated/mass_project.md @@ -23,6 +23,12 @@ A project can encompass many environments (permanent or ephemeral) and manages t -h, --help help for project ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_project_clone.md b/docs/generated/mass_project_clone.md index 5b11121..3b84c13 100644 --- a/docs/generated/mass_project_clone.md +++ b/docs/generated/mass_project_clone.md @@ -61,6 +61,12 @@ mass project clone ecomm ecomm-copy -n, --name string New project name (defaults to new-id if not provided) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass project](/cli/commands/mass_project) - Project management diff --git a/docs/generated/mass_project_create.md b/docs/generated/mass_project_create.md index a1cbedc..f5c71ec 100644 --- a/docs/generated/mass_project_create.md +++ b/docs/generated/mass_project_create.md @@ -48,6 +48,12 @@ mass project create [slug] [flags] -n, --name string Project name (defaults to slug if not provided) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass project](/cli/commands/mass_project) - Project management diff --git a/docs/generated/mass_project_delete.md b/docs/generated/mass_project_delete.md index db2523c..d1f05ad 100644 --- a/docs/generated/mass_project_delete.md +++ b/docs/generated/mass_project_delete.md @@ -55,6 +55,12 @@ mass project delete [project] [flags] -h, --help help for delete ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass project](/cli/commands/mass_project) - Project management diff --git a/docs/generated/mass_project_export.md b/docs/generated/mass_project_export.md index 9d3f5cf..e84614d 100644 --- a/docs/generated/mass_project_export.md +++ b/docs/generated/mass_project_export.md @@ -57,6 +57,12 @@ mass project export [project] [flags] -h, --help help for export ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass project](/cli/commands/mass_project) - Project management diff --git a/docs/generated/mass_project_get.md b/docs/generated/mass_project_get.md index 5ec4af3..665d441 100644 --- a/docs/generated/mass_project_get.md +++ b/docs/generated/mass_project_get.md @@ -43,6 +43,12 @@ mass project get [project] [flags] -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass project](/cli/commands/mass_project) - Project management diff --git a/docs/generated/mass_project_list.md b/docs/generated/mass_project_list.md index 86832c4..368fb73 100644 --- a/docs/generated/mass_project_list.md +++ b/docs/generated/mass_project_list.md @@ -56,6 +56,12 @@ mass project list [flags] --search string Free-text search across project name and description ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass project](/cli/commands/mass_project) - Project management diff --git a/docs/generated/mass_project_update.md b/docs/generated/mass_project_update.md index bcadf63..cdc9fbf 100644 --- a/docs/generated/mass_project_update.md +++ b/docs/generated/mass_project_update.md @@ -21,6 +21,12 @@ mass project update [project] [flags] -n, --name string New project name ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass project](/cli/commands/mass_project) - Project management diff --git a/docs/generated/mass_repository.md b/docs/generated/mass_repository.md index ea71c6d..d2ab6c1 100644 --- a/docs/generated/mass_repository.md +++ b/docs/generated/mass_repository.md @@ -14,6 +14,12 @@ Manage OCI repositories (bundles and resource types) -h, --help help for repository ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_repository_create.md b/docs/generated/mass_repository_create.md index 700f362..5e382fa 100644 --- a/docs/generated/mass_repository_create.md +++ b/docs/generated/mass_repository_create.md @@ -20,6 +20,12 @@ mass repository create [flags] -t, --type string Artifact type (bundle, resource-type) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass repository](/cli/commands/mass_repository) - Manage OCI repositories (bundles and resource types) diff --git a/docs/generated/mass_repository_delete.md b/docs/generated/mass_repository_delete.md index bf8a4a2..94fe9be 100644 --- a/docs/generated/mass_repository_delete.md +++ b/docs/generated/mass_repository_delete.md @@ -19,6 +19,12 @@ mass repository delete [flags] -h, --help help for delete ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass repository](/cli/commands/mass_repository) - Manage OCI repositories (bundles and resource types) diff --git a/docs/generated/mass_repository_get.md b/docs/generated/mass_repository_get.md index 5481b06..20e093f 100644 --- a/docs/generated/mass_repository_get.md +++ b/docs/generated/mass_repository_get.md @@ -20,6 +20,12 @@ mass repository get [flags] -n, --tags int Number of recent tags to display (newest first) (default 10) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass repository](/cli/commands/mass_repository) - Manage OCI repositories (bundles and resource types) diff --git a/docs/generated/mass_repository_list.md b/docs/generated/mass_repository_list.md index f47ee88..076e968 100644 --- a/docs/generated/mass_repository_list.md +++ b/docs/generated/mass_repository_list.md @@ -25,6 +25,12 @@ mass repository list [flags] -t, --type string Filter by artifact type (bundle, resource-type) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass repository](/cli/commands/mass_repository) - Manage OCI repositories (bundles and resource types) diff --git a/docs/generated/mass_repository_update.md b/docs/generated/mass_repository_update.md index 9cae73e..af5e17b 100644 --- a/docs/generated/mass_repository_update.md +++ b/docs/generated/mass_repository_update.md @@ -19,6 +19,12 @@ mass repository update [flags] -h, --help help for update ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass repository](/cli/commands/mass_repository) - Manage OCI repositories (bundles and resource types) diff --git a/docs/generated/mass_resource-type.md b/docs/generated/mass_resource-type.md index 1986a29..4ac85df 100644 --- a/docs/generated/mass_resource-type.md +++ b/docs/generated/mass_resource-type.md @@ -27,6 +27,12 @@ Resource types are used to: -h, --help help for resource-type ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_resource-type_convert.md b/docs/generated/mass_resource-type_convert.md index 4a1a424..25d570b 100644 --- a/docs/generated/mass_resource-type_convert.md +++ b/docs/generated/mass_resource-type_convert.md @@ -48,6 +48,12 @@ mass resource-type convert [flags] -o, --output string Path to write the massdriver.yaml (default: alongside the input file) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_create.md b/docs/generated/mass_resource-type_create.md index 55b16fb..442e5c8 100644 --- a/docs/generated/mass_resource-type_create.md +++ b/docs/generated/mass_resource-type_create.md @@ -50,6 +50,12 @@ mass resource-type create my-resource-type -a owner=data,service=database -h, --help help for create ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_delete.md b/docs/generated/mass_resource-type_delete.md index 4993007..c1bf07e 100644 --- a/docs/generated/mass_resource-type_delete.md +++ b/docs/generated/mass_resource-type_delete.md @@ -63,6 +63,12 @@ mass resource-type delete [resource-type] [flags] -h, --help help for delete ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_get.md b/docs/generated/mass_resource-type_get.md index 11149f2..a71201e 100644 --- a/docs/generated/mass_resource-type_get.md +++ b/docs/generated/mass_resource-type_get.md @@ -48,6 +48,12 @@ mass resource-type get [resource-type] [flags] --schema With -o json, output only the resolved JSON schema ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_list.md b/docs/generated/mass_resource-type_list.md index 02cad45..ae377f6 100644 --- a/docs/generated/mass_resource-type_list.md +++ b/docs/generated/mass_resource-type_list.md @@ -39,6 +39,12 @@ mass resource-type list [flags] -o, --output string Output format (table, json) (default "table") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_publish.md b/docs/generated/mass_resource-type_publish.md index 7ed10fe..05325fa 100644 --- a/docs/generated/mass_resource-type_publish.md +++ b/docs/generated/mass_resource-type_publish.md @@ -79,6 +79,12 @@ mass resource-type publish ./my-resource-type -h, --help help for publish ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource-type_pull.md b/docs/generated/mass_resource-type_pull.md index f7b806d..81edeb2 100644 --- a/docs/generated/mass_resource-type_pull.md +++ b/docs/generated/mass_resource-type_pull.md @@ -47,6 +47,12 @@ mass resource-type pull [@] [flags] -h, --help help for pull ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource-type](/cli/commands/mass_resource-type) - Resource type management diff --git a/docs/generated/mass_resource.md b/docs/generated/mass_resource.md index df56c0b..b4d6158 100644 --- a/docs/generated/mass_resource.md +++ b/docs/generated/mass_resource.md @@ -21,6 +21,12 @@ Resources represent infrastructure outputs and connections in Massdriver. They c -h, --help help for resource ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_resource_create.md b/docs/generated/mass_resource_create.md index 15badce..a5dbb61 100644 --- a/docs/generated/mass_resource_create.md +++ b/docs/generated/mass_resource_create.md @@ -34,6 +34,12 @@ mass resource create [flags] -t, --type string Resource type ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource](/cli/commands/mass_resource) - Manage resources diff --git a/docs/generated/mass_resource_delete.md b/docs/generated/mass_resource_delete.md index b32600d..2067595 100644 --- a/docs/generated/mass_resource_delete.md +++ b/docs/generated/mass_resource_delete.md @@ -29,6 +29,12 @@ mass resource delete [resource-id] [flags] -h, --help help for delete ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource](/cli/commands/mass_resource) - Manage resources diff --git a/docs/generated/mass_resource_download.md b/docs/generated/mass_resource_download.md index 98109c6..4ecb7c7 100644 --- a/docs/generated/mass_resource_download.md +++ b/docs/generated/mass_resource_download.md @@ -78,6 +78,12 @@ mass resource download [resource-id] [flags] -h, --help help for download ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource](/cli/commands/mass_resource) - Manage resources diff --git a/docs/generated/mass_resource_get.md b/docs/generated/mass_resource_get.md index 7c9a4ef..ffb375c 100644 --- a/docs/generated/mass_resource_get.md +++ b/docs/generated/mass_resource_get.md @@ -78,6 +78,12 @@ mass resource get [resource-id] [flags] -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource](/cli/commands/mass_resource) - Manage resources diff --git a/docs/generated/mass_resource_list.md b/docs/generated/mass_resource_list.md index 25e8a66..e4c37c1 100644 --- a/docs/generated/mass_resource_list.md +++ b/docs/generated/mass_resource_list.md @@ -80,6 +80,12 @@ mass resource list [flags] -t, --type string Filter by resource type id (e.g. aws-iam-role) ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource](/cli/commands/mass_resource) - Manage resources diff --git a/docs/generated/mass_resource_update.md b/docs/generated/mass_resource_update.md index 8f5f64c..6f20e6e 100644 --- a/docs/generated/mass_resource_update.md +++ b/docs/generated/mass_resource_update.md @@ -44,6 +44,12 @@ mass resource update [resource-id] [flags] -n, --name string New resource name ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass resource](/cli/commands/mass_resource) - Manage resources diff --git a/docs/generated/mass_schema.md b/docs/generated/mass_schema.md index a4dc468..0c41b11 100644 --- a/docs/generated/mass_schema.md +++ b/docs/generated/mass_schema.md @@ -23,6 +23,12 @@ mass schema validate --schema=my-json-schema.json --document=my-document.json -h, --help help for schema ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_schema_dereference.md b/docs/generated/mass_schema_dereference.md index 034aec6..99e3661 100644 --- a/docs/generated/mass_schema_dereference.md +++ b/docs/generated/mass_schema_dereference.md @@ -40,6 +40,12 @@ mass schema dereference [file] [flags] -h, --help help for dereference ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass schema](/cli/commands/mass_schema) - Manage JSON Schemas diff --git a/docs/generated/mass_schema_validate.md b/docs/generated/mass_schema_validate.md index cb19b74..681c587 100644 --- a/docs/generated/mass_schema_validate.md +++ b/docs/generated/mass_schema_validate.md @@ -69,6 +69,12 @@ mass schema validate [flags] -s, --schema string Path to JSON Schema (default "./schema.json") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass schema](/cli/commands/mass_schema) - Manage JSON Schemas diff --git a/docs/generated/mass_version.md b/docs/generated/mass_version.md index 8ef8b01..1be0dff 100644 --- a/docs/generated/mass_version.md +++ b/docs/generated/mass_version.md @@ -18,6 +18,12 @@ mass version [flags] -h, --help help for version ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/generated/mass_whoami.md b/docs/generated/mass_whoami.md index b3e85f8..6ddd64d 100644 --- a/docs/generated/mass_whoami.md +++ b/docs/generated/mass_whoami.md @@ -19,6 +19,12 @@ mass whoami [flags] -o, --output string Output format (text or json) (default "text") ``` +### Options inherited from parent commands + +``` + --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) +``` + ### SEE ALSO * [mass](/cli/commands/mass) - Massdriver Cloud CLI diff --git a/docs/helpdocs/config.md b/docs/helpdocs/config.md new file mode 100644 index 0000000..22729af --- /dev/null +++ b/docs/helpdocs/config.md @@ -0,0 +1,23 @@ +# Manage Configuration Profiles + +A profile holds the organization, credential, and API URL used to talk to Massdriver. Profiles live in `~/.config/massdriver/config.yaml` (or `$XDG_CONFIG_HOME/massdriver/config.yaml`), and these commands read and write that file for you. + +Use separate profiles to work across organizations or against a self-hosted installation without re-editing the file each time. + +## Choosing a profile + +A profile is selected in this order, highest precedence first: + +1. The `--profile` flag +2. The `MASSDRIVER_PROFILE` environment variable +3. The `current_profile` key in the config file, set by `mass config use` +4. The profile named `default` + +The first three name a profile explicitly, so a name that isn't in the file is an error rather than a silent fallback. The `default` fallback may be absent — that is how credentials supplied entirely through environment variables resolve. + +## Getting started + +```bash +mass config add default --org acme --api-key mds_xxx +mass config list +``` diff --git a/docs/helpdocs/config/add.md b/docs/helpdocs/config/add.md new file mode 100644 index 0000000..0dbe28f --- /dev/null +++ b/docs/helpdocs/config/add.md @@ -0,0 +1,29 @@ +# Add a Profile + +Adds a profile to the configuration file. When run in a terminal, you are prompted for any required value you did not pass as a flag. + +The credentials are checked against the Massdriver API before the profile is written, so a mistyped key or organization fails here rather than on your next command. Pass `--no-verify` to skip that check. + +The first profile you add becomes the active profile. + +## Usage + +```bash +mass config add +``` + +## Examples + +```bash +# Add a profile interactively +mass config add staging + +# Add a profile non-interactively +mass config add staging --org acme --api-key mds_xxx + +# Add a profile pointing at a self-hosted installation +mass config add onprem --org acme --api-key mds_xxx --url https://api.massdriver.example.com + +# Add a profile and make it active +mass config add staging --org acme --api-key mds_xxx --use +``` diff --git a/docs/helpdocs/config/get.md b/docs/helpdocs/config/get.md new file mode 100644 index 0000000..bbc8e6b --- /dev/null +++ b/docs/helpdocs/config/get.md @@ -0,0 +1,21 @@ +# Get a Profile + +Shows the settings for one profile. With no argument, shows the active profile. + +The API key is masked. Pass `--show-secrets` to print it in full. + +## Usage + +```bash +mass config get [profile] +``` + +## Examples + +```bash +# Show the active profile +mass config get + +# Show a specific profile +mass config get staging +``` diff --git a/docs/helpdocs/config/list.md b/docs/helpdocs/config/list.md new file mode 100644 index 0000000..af12c4c --- /dev/null +++ b/docs/helpdocs/config/list.md @@ -0,0 +1,21 @@ +# List Profiles + +Lists every profile in the configuration file. The active profile is marked with `*`. + +API keys are masked. Pass `--show-secrets` to print them in full. + +## Usage + +```bash +mass config list +``` + +## Examples + +```bash +# List profiles +mass config list + +# List profiles as JSON +mass config list --output json +``` diff --git a/docs/helpdocs/config/path.md b/docs/helpdocs/config/path.md new file mode 100644 index 0000000..d0649ff --- /dev/null +++ b/docs/helpdocs/config/path.md @@ -0,0 +1,21 @@ +# Print the Configuration File Path + +Prints where the CLI reads its configuration from, so you can open or back up the file directly. + +The location is `$XDG_CONFIG_HOME/massdriver/config.yaml` when `XDG_CONFIG_HOME` is set, and `~/.config/massdriver/config.yaml` otherwise. + +## Usage + +```bash +mass config path +``` + +## Examples + +```bash +# Print the path +mass config path + +# Open the file in your editor +$EDITOR "$(mass config path)" +``` diff --git a/docs/helpdocs/config/remove.md b/docs/helpdocs/config/remove.md new file mode 100644 index 0000000..1fb2ed5 --- /dev/null +++ b/docs/helpdocs/config/remove.md @@ -0,0 +1,21 @@ +# Remove a Profile + +Removes a profile from the configuration file. You are asked to confirm by typing the profile name unless `--force` is passed. + +If the removed profile was the active one, `current_profile` is cleared so later commands fall back to the `default` profile instead of failing on a name that no longer exists. + +## Usage + +```bash +mass config remove +``` + +## Examples + +```bash +# Remove a profile +mass config remove staging + +# Remove without the confirmation prompt +mass config remove staging --force +``` diff --git a/docs/helpdocs/config/set.md b/docs/helpdocs/config/set.md new file mode 100644 index 0000000..09bea6d --- /dev/null +++ b/docs/helpdocs/config/set.md @@ -0,0 +1,27 @@ +# Update a Profile + +Updates settings on an existing profile. Only the values you pass are changed; everything else is left alone. Pass an empty string to remove a setting. + +Credentials are verified against the Massdriver API before the change is written. Pass `--no-verify` to skip that check. + +## Usage + +```bash +mass config set +``` + +## Examples + +```bash +# Rotate the API key +mass config set staging --api-key mds_yyy + +# Point a profile at a self-hosted installation +mass config set staging --url https://api.massdriver.example.com + +# Set where `mass bundle new` looks for templates +mass config set staging --templates-path ~/massdriver/templates + +# Clear the templates path +mass config set staging --templates-path "" +``` diff --git a/docs/helpdocs/config/use.md b/docs/helpdocs/config/use.md new file mode 100644 index 0000000..8831332 --- /dev/null +++ b/docs/helpdocs/config/use.md @@ -0,0 +1,21 @@ +# Set the Active Profile + +Makes a profile the one every `mass` command uses. The choice is written to the `current_profile` key in the config file, so it persists across shells and sessions. + +`MASSDRIVER_PROFILE` and the `--profile` flag both take precedence over this setting. + +## Usage + +```bash +mass config use +``` + +## Examples + +```bash +# Switch to the staging profile +mass config use staging + +# Run a single command against a different profile +mass project list --profile production +``` diff --git a/go.mod b/go.mod index 07dd96e..554450d 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/itchyny/gojq v0.12.16 github.com/manifoldco/promptui v0.9.0 github.com/massdriver-cloud/airlock v0.0.10 - github.com/massdriver-cloud/massdriver-sdk-go v0.3.2 + github.com/massdriver-cloud/massdriver-sdk-go v0.3.3 github.com/mattn/go-runewidth v0.0.24 github.com/opencontainers/image-spec v1.1.1 github.com/osteele/liquid v1.7.0 diff --git a/go.sum b/go.sum index 56a26be..df1df16 100644 --- a/go.sum +++ b/go.sum @@ -143,6 +143,10 @@ github.com/massdriver-cloud/airlock v0.0.10 h1:05wz7kovH09X1VMfHcjLWylYanoLFcxuD github.com/massdriver-cloud/airlock v0.0.10/go.mod h1:igJm33JvINiUtbyEspUeKUWyWewG+jYyxO1UDHqLp9Q= github.com/massdriver-cloud/massdriver-sdk-go v0.3.2 h1:ydloDF6jJEk7Ptic87MlHnnZGMX/ClO8feDQ/1UX/Xs= github.com/massdriver-cloud/massdriver-sdk-go v0.3.2/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.3.3-0.20260910212831-5bbcd375bee5 h1:cIKaalwgrat1srgpXvFwXkT0JcjmOL2C61ZjMxkvDFU= +github.com/massdriver-cloud/massdriver-sdk-go v0.3.3-0.20260910212831-5bbcd375bee5/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.3.3 h1:zKNeWJEHYfduGYPytiLvjTBwIqQvTdSDvNO2nTLnHd0= +github.com/massdriver-cloud/massdriver-sdk-go v0.3.3/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2 h1:Jc7BrhFHLbK7Epig6ShiEVMzQPPHVIOx0/BatvtEwtY= github.com/massdriver-cloud/terraform-config-inspect v0.0.2/go.mod h1:3AbDpWxIRMdMAg7FDmTJuVBhCGNwdm49cBIOmUHjqRg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= diff --git a/internal/commands/config/add.go b/internal/commands/config/add.go new file mode 100644 index 0000000..07dce1f --- /dev/null +++ b/internal/commands/config/add.go @@ -0,0 +1,90 @@ +package config + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/massdriver-cloud/mass/internal/configfile" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +// AddOptions carries everything `mass config add` needs beyond the profile name. +type AddOptions struct { + Edits ProfileEdits + Force bool + Use bool + // Prompter fills in required values the caller omitted; nil disables + // prompting and turns a missing value into an error. + Prompter Prompter + // Verifier checks the credentials before they are written; nil skips the + // check. + Verifier Verifier +} + +// RunAdd writes a new profile to the config file. +func RunAdd(ctx context.Context, file *configfile.File, name string, opts AddOptions, out io.Writer) error { + if _, exists := file.Profile(name); exists && !opts.Force { + return fmt.Errorf("profile %q already exists; use `mass config set %s` to change it, or --force to overwrite", name, name) + } + + var profile sdkconfig.Profile + opts.Edits.Apply(&profile) + + if err := promptRequired(&profile, opts.Prompter); err != nil { + return err + } + if profile.OrganizationID == "" { + return errors.New("organization is required: pass --org") + } + if profile.APIKey == "" { + return errors.New("API key is required: pass --api-key") + } + + if err := verify(ctx, opts.Verifier, profile, out); err != nil { + return err + } + if err := file.SetProfile(name, profile); err != nil { + return err + } + + // The first profile added is the one every command would otherwise fail + // looking for, so adopt it as active without being asked. + use := opts.Use || len(file.Profiles()) == 1 + if use { + if err := file.SetCurrentProfile(name); err != nil { + return err + } + } + if err := file.Save(); err != nil { + return err + } + + fmt.Fprintf(out, "✅ Profile `%s` saved to %s\n", name, file.Path()) + if use { + fmt.Fprintf(out, " Active profile is now `%s`\n", name) + } + return nil +} + +func promptRequired(profile *sdkconfig.Profile, prompter Prompter) error { + if prompter == nil { + return nil + } + if profile.OrganizationID == "" { + value, err := prompter.Prompt("Organization abbreviation", false) + if err != nil { + return err + } + profile.OrganizationID = value + } + if profile.APIKey == "" { + value, err := prompter.Prompt("API key or personal access token", true) + if err != nil { + return err + } + profile.APIKey = value + } + return nil +} diff --git a/internal/commands/config/add_test.go b/internal/commands/config/add_test.go new file mode 100644 index 0000000..87351ad --- /dev/null +++ b/internal/commands/config/add_test.go @@ -0,0 +1,247 @@ +package config_test + +import ( + "bytes" + "errors" + "os" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/config" + "github.com/massdriver-cloud/mass/internal/configfile" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +func TestRunAddWritesProfile(t *testing.T) { + file := loadConfig(t, "") + + opts := config.AddOptions{Edits: config.ProfileEdits{ + OrganizationID: ptr("acme"), + APIKey: ptr("mds_key123456789"), + URL: ptr("https://api.massdriver.example.com"), + }} + if err := config.RunAdd(t.Context(), file, "staging", opts, &bytes.Buffer{}); err != nil { + t.Fatalf("RunAdd: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("SDK could not read the file we wrote: %v", err) + } + want := sdkconfig.Profile{ + OrganizationID: "acme", + APIKey: "mds_key123456789", + URL: "https://api.massdriver.example.com", + } + if got := saved.Profiles["staging"]; got != want { + t.Errorf("saved profile = %+v, want %+v", got, want) + } +} + +// TestRunAddAdoptsFirstProfile covers the case where not setting +// current_profile would leave every command looking for a "default" that +// doesn't exist. +func TestRunAddAdoptsFirstProfile(t *testing.T) { + file := loadConfig(t, "") + + opts := config.AddOptions{Edits: config.ProfileEdits{ + OrganizationID: ptr("acme"), + APIKey: ptr("mds_key123456789"), + }} + var out bytes.Buffer + if err := config.RunAdd(t.Context(), file, "staging", opts, &out); err != nil { + t.Fatalf("RunAdd: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if saved.CurrentProfile != "staging" { + t.Errorf("current_profile = %q, want staging", saved.CurrentProfile) + } + if !strings.Contains(out.String(), "Active profile is now `staging`") { + t.Errorf("output = %q, want it to report the active profile", out.String()) + } +} + +func TestRunAddLeavesActiveProfileAloneByDefault(t *testing.T) { + file := loadConfig(t, twoProfiles) + + opts := config.AddOptions{Edits: config.ProfileEdits{ + OrganizationID: ptr("acme"), + APIKey: ptr("mds_key123456789"), + }} + if err := config.RunAdd(t.Context(), file, "production", opts, &bytes.Buffer{}); err != nil { + t.Fatalf("RunAdd: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if saved.CurrentProfile != "staging" { + t.Errorf("current_profile = %q, want staging to be untouched", saved.CurrentProfile) + } +} + +func TestRunAddUseFlagSwitchesActiveProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + opts := config.AddOptions{ + Edits: config.ProfileEdits{OrganizationID: ptr("acme"), APIKey: ptr("mds_key123456789")}, + Use: true, + } + if err := config.RunAdd(t.Context(), file, "production", opts, &bytes.Buffer{}); err != nil { + t.Fatalf("RunAdd: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if saved.CurrentProfile != "production" { + t.Errorf("current_profile = %q, want production", saved.CurrentProfile) + } +} + +func TestRunAddRefusesToOverwriteWithoutForce(t *testing.T) { + file := loadConfig(t, twoProfiles) + + opts := config.AddOptions{Edits: config.ProfileEdits{ + OrganizationID: ptr("other"), + APIKey: ptr("mds_other12345678"), + }} + err := config.RunAdd(t.Context(), file, "staging", opts, &bytes.Buffer{}) + if err == nil { + t.Fatal("RunAdd overwrote an existing profile without --force") + } + if !strings.Contains(err.Error(), "mass config set staging") { + t.Errorf("error = %v, want it to point at `mass config set`", err) + } + + opts.Force = true + if err = config.RunAdd(t.Context(), file, "staging", opts, &bytes.Buffer{}); err != nil { + t.Fatalf("RunAdd with --force: %v", err) + } + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if saved.Profiles["staging"].OrganizationID != "other" { + t.Error("--force did not overwrite the profile") + } +} + +func TestRunAddRequiresCredentials(t *testing.T) { + tests := []struct { + name string + edits config.ProfileEdits + want string + }{ + {name: "missing organization", edits: config.ProfileEdits{APIKey: ptr("mds_key123456789")}, want: "--org"}, + {name: "missing API key", edits: config.ProfileEdits{OrganizationID: ptr("acme")}, want: "--api-key"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + file := loadConfig(t, "") + + err := config.RunAdd(t.Context(), file, "staging", config.AddOptions{Edits: tc.edits}, &bytes.Buffer{}) + if err == nil { + t.Fatal("RunAdd succeeded with incomplete credentials") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %v, want it to name %s", err, tc.want) + } + }) + } +} + +func TestRunAddPromptsForMissingValues(t *testing.T) { + file := loadConfig(t, "") + + prompter := &stubPrompter{answers: []string{"acme", "mds_key123456789"}} + opts := config.AddOptions{Prompter: prompter} + if err := config.RunAdd(t.Context(), file, "staging", opts, &bytes.Buffer{}); err != nil { + t.Fatalf("RunAdd: %v", err) + } + + if len(prompter.labels) != 2 { + t.Fatalf("prompted %d times, want 2: %v", len(prompter.labels), prompter.labels) + } + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if saved.Profiles["staging"].OrganizationID != "acme" { + t.Errorf("prompted organization was not saved: %+v", saved.Profiles["staging"]) + } +} + +func TestRunAddDoesNotPromptForValuesAlreadyPassed(t *testing.T) { + file := loadConfig(t, "") + + prompter := &stubPrompter{} + opts := config.AddOptions{ + Edits: config.ProfileEdits{OrganizationID: ptr("acme"), APIKey: ptr("mds_key123456789")}, + Prompter: prompter, + } + if err := config.RunAdd(t.Context(), file, "staging", opts, &bytes.Buffer{}); err != nil { + t.Fatalf("RunAdd: %v", err) + } + if len(prompter.labels) != 0 { + t.Errorf("prompted for values that were passed as flags: %v", prompter.labels) + } +} + +func TestRunAddVerifiesBeforeWriting(t *testing.T) { + file := loadConfig(t, "") + + verifier := &stubVerifier{identity: "someone@example.com"} + opts := config.AddOptions{ + Edits: config.ProfileEdits{OrganizationID: ptr("acme"), APIKey: ptr("mds_key123456789")}, + Verifier: verifier, + } + var out bytes.Buffer + if err := config.RunAdd(t.Context(), file, "staging", opts, &out); err != nil { + t.Fatalf("RunAdd: %v", err) + } + + if verifier.calls != 1 { + t.Errorf("verifier called %d times, want 1", verifier.calls) + } + if verifier.got.APIKey != "mds_key123456789" { + t.Errorf("verified %+v, want the profile being added", verifier.got) + } + if !strings.Contains(out.String(), "someone@example.com") { + t.Errorf("output = %q, want the verified identity", out.String()) + } +} + +// TestRunAddDoesNotWriteWhenVerificationFails is the guarantee that a mistyped +// key never lands in the config file. +func TestRunAddDoesNotWriteWhenVerificationFails(t *testing.T) { + file := loadConfig(t, "") + + verifier := &stubVerifier{err: errors.New("unauthorized")} + opts := config.AddOptions{ + Edits: config.ProfileEdits{OrganizationID: ptr("acme"), APIKey: ptr("mds_key123456789")}, + Verifier: verifier, + } + err := config.RunAdd(t.Context(), file, "staging", opts, &bytes.Buffer{}) + if err == nil { + t.Fatal("RunAdd succeeded despite failed verification") + } + if !strings.Contains(err.Error(), "--no-verify") { + t.Errorf("error = %v, want it to mention --no-verify", err) + } + + path, err := configfile.Path() + if err != nil { + t.Fatalf("Path: %v", err) + } + if _, statErr := os.Stat(path); statErr == nil { + t.Error("config file was created despite failed verification") + } +} diff --git a/internal/commands/config/config.go b/internal/commands/config/config.go new file mode 100644 index 0000000..d6e384f --- /dev/null +++ b/internal/commands/config/config.go @@ -0,0 +1,180 @@ +// Package config provides command implementations for managing the CLI +// configuration file's profiles. +package config + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/manifoldco/promptui" + "github.com/massdriver-cloud/mass/internal/cli" + "github.com/massdriver-cloud/mass/internal/configfile" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +// ProfileEdits are the profile fields a command was asked to change. A nil +// field was not passed and leaves the stored value alone, which is what lets +// `mass config set` change one setting without blanking the rest. +type ProfileEdits struct { + OrganizationID *string + APIKey *string + URL *string + TemplatesPath *string +} + +// Any reports whether any field was passed. +func (e ProfileEdits) Any() bool { + return e.OrganizationID != nil || e.APIKey != nil || e.URL != nil || e.TemplatesPath != nil +} + +// Apply writes the passed fields onto p. +func (e ProfileEdits) Apply(p *sdkconfig.Profile) { + for _, field := range []struct { + edit *string + value *string + }{ + {e.OrganizationID, &p.OrganizationID}, + {e.APIKey, &p.APIKey}, + {e.URL, &p.URL}, + {e.TemplatesPath, &p.TemplatesPath}, + } { + if field.edit != nil { + *field.value = *field.edit + } + } +} + +// Prompter collects a value the user did not pass as a flag. +type Prompter interface { + Prompt(label string, secret bool) (string, error) +} + +// NewPrompter returns a terminal Prompter, or nil when stdout is not +// interactive so scripted callers fail on the missing flag rather than block +// on a prompt nobody can answer. +func NewPrompter() Prompter { + if !cli.IsInteractive(os.Stdout) { + return nil + } + return terminalPrompter{} +} + +type terminalPrompter struct{} + +func (terminalPrompter) Prompt(label string, secret bool) (string, error) { + prompt := promptui.Prompt{ + Label: label, + Validate: func(input string) error { + if strings.TrimSpace(input) == "" { + return fmt.Errorf("%s cannot be empty", label) + } + return nil + }, + } + if secret { + prompt.Mask = '*' + } + result, err := prompt.Run() + if err != nil { + return "", err + } + return strings.TrimSpace(result), nil +} + +// Verifier confirms a profile's credentials authenticate, returning the +// identity they belong to. +type Verifier interface { + Verify(ctx context.Context, p sdkconfig.Profile) (string, error) +} + +// NewVerifier returns a Verifier that authenticates against Massdriver. +func NewVerifier() Verifier { return apiVerifier{} } + +type apiVerifier struct{} + +func (apiVerifier) Verify(ctx context.Context, p sdkconfig.Profile) (string, error) { + mdClient, err := massdriver.NewClient( + massdriver.WithOrganizationID(p.OrganizationID), + massdriver.WithAPIKey(p.APIKey), + massdriver.WithBaseURL(p.URL), + ) + if err != nil { + return "", err + } + + v, err := mdClient.Viewer.Get(ctx) + if err != nil { + return "", err + } + if v.Email != "" { + return v.Email, nil + } + return v.Name, nil +} + +// verify runs the credential check unless the caller opted out by passing a nil +// Verifier, pointing at --no-verify in the failure so the profile can still be +// saved when the API is unreachable. +func verify(ctx context.Context, verifier Verifier, p sdkconfig.Profile, out io.Writer) error { + if verifier == nil { + return nil + } + identity, err := verifier.Verify(ctx, p) + if err != nil { + return fmt.Errorf("could not verify credentials: %w (pass --no-verify to save anyway)", err) + } + fmt.Fprintf(out, "✅ Authenticated as %s\n", identity) + return nil +} + +// profileOutput is the rendered shape of a profile, with the API key masked +// unless the caller opted into seeing it. +type profileOutput struct { + Name string `json:"name"` + Active bool `json:"active"` + OrganizationID string `json:"organization_id"` + APIKey string `json:"api_key"` + URL string `json:"url"` + TemplatesPath string `json:"templates_path,omitempty"` +} + +func newProfileOutput(p configfile.NamedProfile, active, showSecrets bool) profileOutput { + return profileOutput{ + Name: p.Name, + Active: active, + OrganizationID: p.OrganizationID, + APIKey: secret(p.APIKey, showSecrets), + URL: p.URL, + TemplatesPath: p.TemplatesPath, + } +} + +// secret masks all but the last four characters of a credential, keeping +// enough to tell two keys apart without printing either. +func secret(value string, show bool) string { + if value == "" || show { + return value + } + if len(value) <= 8 { + return "****" + } + return "****" + value[len(value)-4:] +} + +// unknownProfileError reports a missing profile alongside the names that do +// exist, since the usual cause is a typo or the wrong config file. It wraps the +// SDK's sentinel so a caller classifying this matches the error the SDK raises +// for the same mistake. +func unknownProfileError(file *configfile.File, name string) error { + names := file.ProfileNames() + if len(names) == 0 { + return fmt.Errorf("%w: %q, and %s defines no profiles — add one with `mass config add %s`", + sdkconfig.ErrProfileNotFound, name, file.Path(), name) + } + return fmt.Errorf("%w: %q is not in %s, which defines %s", + sdkconfig.ErrProfileNotFound, name, file.Path(), strings.Join(names, ", ")) +} diff --git a/internal/commands/config/config_test.go b/internal/commands/config/config_test.go new file mode 100644 index 0000000..6a5a8f2 --- /dev/null +++ b/internal/commands/config/config_test.go @@ -0,0 +1,151 @@ +package config_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/config" + "github.com/massdriver-cloud/mass/internal/configfile" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +// loadConfig points config resolution at a temp dir holding contents, clears +// the environment variables that would otherwise leak the developer's own +// credentials into a test, and returns the loaded file. +func loadConfig(t *testing.T, contents string) *configfile.File { + t.Helper() + + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("MASSDRIVER_PROFILE", "") + t.Setenv("MASSDRIVER_ORGANIZATION_ID", "") + t.Setenv("MASSDRIVER_ORG_ID", "") + t.Setenv("MASSDRIVER_API_KEY", "") + t.Setenv("MASSDRIVER_URL", "") + t.Setenv("MASSDRIVER_TEMPLATES_PATH", "") + + if contents != "" { + path := filepath.Join(dir, "massdriver", "config.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + } + + file, err := configfile.Load() + if err != nil { + t.Fatalf("configfile.Load: %v", err) + } + return file +} + +// twoProfiles is the fixture most tests read: a default and a staging profile +// with staging active. +const twoProfiles = `version: 1 +current_profile: staging +profiles: + default: + organization_id: acme + api_key: mds_defaultkey1234 + staging: + organization_id: acme + api_key: mds_stagingkey1234 + url: https://api.massdriver.example.com +` + +func ptr(s string) *string { return &s } + +// stubVerifier records what it was asked to check so tests can assert the +// credentials are verified before they reach disk. +type stubVerifier struct { + identity string + err error + got sdkconfig.Profile + calls int +} + +func (s *stubVerifier) Verify(_ context.Context, p sdkconfig.Profile) (string, error) { + s.calls++ + s.got = p + return s.identity, s.err +} + +// stubPrompter answers prompts from a queue. +type stubPrompter struct { + answers []string + labels []string +} + +func (s *stubPrompter) Prompt(label string, _ bool) (string, error) { + s.labels = append(s.labels, label) + if len(s.answers) == 0 { + return "", errors.New("no answer queued") + } + answer := s.answers[0] + s.answers = s.answers[1:] + return answer, nil +} + +func TestProfileEditsApply(t *testing.T) { + base := sdkconfig.Profile{ + OrganizationID: "acme", + APIKey: "mds_old", + URL: "https://api.massdriver.example.com", + TemplatesPath: "/tmp/templates", + } + + tests := []struct { + name string + edits config.ProfileEdits + want sdkconfig.Profile + }{ + { + name: "no edits leaves the profile alone", + edits: config.ProfileEdits{}, + want: base, + }, + { + name: "one edit touches only that field", + edits: config.ProfileEdits{APIKey: ptr("mds_new")}, + want: sdkconfig.Profile{ + OrganizationID: "acme", + APIKey: "mds_new", + URL: "https://api.massdriver.example.com", + TemplatesPath: "/tmp/templates", + }, + }, + { + name: "an explicit empty string clears a field", + edits: config.ProfileEdits{TemplatesPath: ptr("")}, + want: sdkconfig.Profile{ + OrganizationID: "acme", + APIKey: "mds_old", + URL: "https://api.massdriver.example.com", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := base + tc.edits.Apply(&got) + if got != tc.want { + t.Errorf("Apply() = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestProfileEditsAny(t *testing.T) { + if (config.ProfileEdits{}).Any() { + t.Error("Any() = true for empty edits") + } + if !(config.ProfileEdits{URL: ptr("")}).Any() { + t.Error("Any() = false for an explicitly cleared field") + } +} diff --git a/internal/commands/config/get.go b/internal/commands/config/get.go new file mode 100644 index 0000000..5daa789 --- /dev/null +++ b/internal/commands/config/get.go @@ -0,0 +1,60 @@ +package config + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/massdriver-cloud/mass/internal/configfile" +) + +// GetOptions controls how RunGet renders the profile. +type GetOptions struct { + Output string + ShowSecrets bool + // ProfileOverride is the --profile flag, which decides the profile shown + // when the caller names none. + ProfileOverride string +} + +// RunGet writes one profile's settings. An empty name shows the active profile. +func RunGet(file *configfile.File, name string, opts GetOptions, out io.Writer) error { + active := file.ActiveProfileName(opts.ProfileOverride) + if name == "" { + name = active + } + + profile, found := file.Profile(name) + if !found { + return unknownProfileError(file, name) + } + rendered := newProfileOutput(configfile.NamedProfile{Name: name, Profile: profile}, name == active, opts.ShowSecrets) + + switch opts.Output { + case "json": + //nolint:gosec // profileOutput.APIKey is masked unless ShowSecrets was set + encoded, err := json.MarshalIndent(rendered, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal profile to JSON: %w", err) + } + fmt.Fprintln(out, string(encoded)) + return nil + case "text": + fmt.Fprintf(out, "Profile: %s", rendered.Name) + if rendered.Active { + fmt.Fprint(out, " (active)") + } + fmt.Fprintln(out) + fmt.Fprintf(out, " Organization: %s\n", rendered.OrganizationID) + fmt.Fprintf(out, " API Key: %s\n", rendered.APIKey) + if rendered.URL != "" { + fmt.Fprintf(out, " URL: %s\n", rendered.URL) + } + if rendered.TemplatesPath != "" { + fmt.Fprintf(out, " Templates Path: %s\n", rendered.TemplatesPath) + } + return nil + default: + return fmt.Errorf("unsupported output format: %s", opts.Output) + } +} diff --git a/internal/commands/config/get_test.go b/internal/commands/config/get_test.go new file mode 100644 index 0000000..6088342 --- /dev/null +++ b/internal/commands/config/get_test.go @@ -0,0 +1,73 @@ +package config_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/config" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +func TestRunGetDefaultsToActiveProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var out bytes.Buffer + if err := config.RunGet(file, "", config.GetOptions{Output: "text"}, &out); err != nil { + t.Fatalf("RunGet: %v", err) + } + if !strings.Contains(out.String(), "Profile: staging (active)") { + t.Errorf("output = %q, want the active profile", out.String()) + } +} + +func TestRunGetNamedProfileIsNotMarkedActive(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var out bytes.Buffer + if err := config.RunGet(file, "default", config.GetOptions{Output: "text"}, &out); err != nil { + t.Fatalf("RunGet: %v", err) + } + if strings.Contains(out.String(), "(active)") { + t.Errorf("output = %q, want default not marked active", out.String()) + } +} + +func TestRunGetMasksAPIKey(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var out bytes.Buffer + if err := config.RunGet(file, "staging", config.GetOptions{Output: "text"}, &out); err != nil { + t.Fatalf("RunGet: %v", err) + } + if strings.Contains(out.String(), "mds_stagingkey1234") { + t.Errorf("API key printed in full by default:\n%s", out.String()) + } +} + +// TestRunGetUnknownProfileWrapsSDKError keeps the CLI's message classifiable +// the same way the SDK's own profile miss is. +func TestRunGetUnknownProfileWrapsSDKError(t *testing.T) { + file := loadConfig(t, twoProfiles) + + err := config.RunGet(file, "nope", config.GetOptions{Output: "text"}, &bytes.Buffer{}) + if !errors.Is(err, sdkconfig.ErrProfileNotFound) { + t.Fatalf("RunGet error = %v, want ErrProfileNotFound", err) + } + if !strings.Contains(err.Error(), "default, staging") { + t.Errorf("error = %v, want it to list the configured profiles", err) + } +} + +func TestRunGetWithNoProfilesSuggestsAdd(t *testing.T) { + file := loadConfig(t, "") + + err := config.RunGet(file, "staging", config.GetOptions{Output: "text"}, &bytes.Buffer{}) + if !errors.Is(err, sdkconfig.ErrProfileNotFound) { + t.Fatalf("RunGet error = %v, want ErrProfileNotFound", err) + } + if !strings.Contains(err.Error(), "mass config add staging") { + t.Errorf("error = %v, want it to suggest adding the profile", err) + } +} diff --git a/internal/commands/config/list.go b/internal/commands/config/list.go new file mode 100644 index 0000000..e3636d1 --- /dev/null +++ b/internal/commands/config/list.go @@ -0,0 +1,59 @@ +package config + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/massdriver-cloud/mass/internal/cli" + "github.com/massdriver-cloud/mass/internal/configfile" +) + +// ListOptions controls how RunList renders the profiles. +type ListOptions struct { + Output string + ShowSecrets bool + // ProfileOverride is the --profile flag, which decides the profile the + // listing marks as active. + ProfileOverride string +} + +// RunList writes every configured profile, marking the active one. +func RunList(file *configfile.File, opts ListOptions, out io.Writer) error { + profiles := file.Profiles() + active := file.ActiveProfileName(opts.ProfileOverride) + + switch opts.Output { + case "json": + rendered := make([]profileOutput, 0, len(profiles)) + for _, p := range profiles { + rendered = append(rendered, newProfileOutput(p, p.Name == active, opts.ShowSecrets)) + } + //nolint:gosec // profileOutput.APIKey is masked unless ShowSecrets was set + encoded, err := json.MarshalIndent(rendered, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal profiles to JSON: %w", err) + } + fmt.Fprintln(out, string(encoded)) + return nil + case "table": + if len(profiles) == 0 { + fmt.Fprintf(out, "No profiles configured in %s\n", file.Path()) + fmt.Fprintln(out, "Add one with `mass config add default --org --api-key `") + return nil + } + fmt.Fprintf(out, "Profiles in %s (* = active)\n\n", file.Path()) + tbl := cli.NewTable("", "Name", "Organization", "API Key", "URL", "Templates Path").WithWriter(out) + for _, p := range profiles { + marker := "" + if p.Name == active { + marker = "*" + } + tbl.AddRow(marker, p.Name, p.OrganizationID, secret(p.APIKey, opts.ShowSecrets), p.URL, p.TemplatesPath) + } + tbl.Print() + return nil + default: + return fmt.Errorf("unsupported output format: %s", opts.Output) + } +} diff --git a/internal/commands/config/list_test.go b/internal/commands/config/list_test.go new file mode 100644 index 0000000..40804b3 --- /dev/null +++ b/internal/commands/config/list_test.go @@ -0,0 +1,118 @@ +package config_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/config" +) + +func TestRunListMarksActiveProfile(t *testing.T) { + tests := []struct { + name string + override string + want string + }{ + {name: "marks current_profile", want: "staging"}, + {name: "the --profile override moves the marker", override: "default", want: "default"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var out bytes.Buffer + opts := config.ListOptions{Output: "json", ProfileOverride: tc.override} + if err := config.RunList(file, opts, &out); err != nil { + t.Fatalf("RunList: %v", err) + } + + var rendered []struct { + Name string `json:"name"` + Active bool `json:"active"` + } + if err := json.Unmarshal(out.Bytes(), &rendered); err != nil { + t.Fatalf("Unmarshal: %v\n%s", err, out.String()) + } + if len(rendered) != 2 { + t.Fatalf("listed %d profiles, want 2", len(rendered)) + } + for _, p := range rendered { + if p.Active != (p.Name == tc.want) { + t.Errorf("profile %q active = %v, want %v", p.Name, p.Active, p.Name == tc.want) + } + } + }) + } +} + +func TestRunListMasksAPIKeys(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var masked bytes.Buffer + if err := config.RunList(file, config.ListOptions{Output: "table"}, &masked); err != nil { + t.Fatalf("RunList: %v", err) + } + if strings.Contains(masked.String(), "mds_stagingkey1234") { + t.Errorf("API key printed in full by default:\n%s", masked.String()) + } + if !strings.Contains(masked.String(), "****1234") { + t.Errorf("masked key not shown:\n%s", masked.String()) + } + + var shown bytes.Buffer + if err := config.RunList(file, config.ListOptions{Output: "table", ShowSecrets: true}, &shown); err != nil { + t.Fatalf("RunList: %v", err) + } + if !strings.Contains(shown.String(), "mds_stagingkey1234") { + t.Errorf("--show-secrets did not print the key:\n%s", shown.String()) + } +} + +// TestRunListHeaderNamesTheFileNotTheProfile guards against the header reading +// as though the config path were the active profile's name. +func TestRunListHeaderNamesTheFileNotTheProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var out bytes.Buffer + if err := config.RunList(file, config.ListOptions{Output: "table"}, &out); err != nil { + t.Fatalf("RunList: %v", err) + } + + header, _, found := strings.Cut(out.String(), "\n") + if !found { + t.Fatalf("output has no header line:\n%s", out.String()) + } + if !strings.Contains(header, file.Path()) { + t.Errorf("header = %q, want it to name the config file", header) + } + if !strings.Contains(header, "* = active") { + t.Errorf("header = %q, want it to explain the * marker", header) + } + if strings.Contains(header, "active profile") { + t.Errorf("header = %q reads as though the path were the active profile", header) + } +} + +func TestRunListWithNoProfiles(t *testing.T) { + file := loadConfig(t, "") + + var out bytes.Buffer + if err := config.RunList(file, config.ListOptions{Output: "table"}, &out); err != nil { + t.Fatalf("RunList: %v", err) + } + if !strings.Contains(out.String(), "No profiles configured") { + t.Errorf("output = %q, want a message about having no profiles", out.String()) + } +} + +func TestRunListRejectsUnknownOutputFormat(t *testing.T) { + file := loadConfig(t, twoProfiles) + + err := config.RunList(file, config.ListOptions{Output: "yaml"}, &bytes.Buffer{}) + if err == nil { + t.Fatal("RunList with an unsupported format succeeded, want an error") + } +} diff --git a/internal/commands/config/remove.go b/internal/commands/config/remove.go new file mode 100644 index 0000000..c89ae7d --- /dev/null +++ b/internal/commands/config/remove.go @@ -0,0 +1,43 @@ +package config + +import ( + "bufio" + "fmt" + "io" + "strings" + + "github.com/massdriver-cloud/mass/internal/configfile" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +// RunRemove deletes a profile, prompting for confirmation unless force is set. +// The prompt requires the caller to retype the profile name to proceed — +// matches the safety pattern used for `mass resource delete`. +func RunRemove(file *configfile.File, name string, force bool, in io.Reader, out io.Writer) error { + if _, found := file.Profile(name); !found { + return unknownProfileError(file, name) + } + + if !force { + fmt.Fprintf(out, "WARNING: This will permanently remove profile `%s` from %s.\n", name, file.Path()) + fmt.Fprintf(out, "Type `%s` to confirm removal: ", name) + reader := bufio.NewReader(in) + answer, _ := reader.ReadString('\n') + if strings.TrimSpace(answer) != name { + fmt.Fprintln(out, "Removal cancelled.") + return nil + } + } + + wasActive := file.CurrentProfile() == name + file.RemoveProfile(name) + if err := file.Save(); err != nil { + return err + } + + fmt.Fprintf(out, "✅ Profile `%s` removed\n", name) + if wasActive { + fmt.Fprintf(out, " No active profile is set; commands will fall back to `%s`\n", sdkconfig.DefaultProfileName) + } + return nil +} diff --git a/internal/commands/config/remove_test.go b/internal/commands/config/remove_test.go new file mode 100644 index 0000000..fb46ba5 --- /dev/null +++ b/internal/commands/config/remove_test.go @@ -0,0 +1,101 @@ +package config_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/config" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +func TestRunRemoveWithForce(t *testing.T) { + file := loadConfig(t, twoProfiles) + + if err := config.RunRemove(file, "default", true, strings.NewReader(""), &bytes.Buffer{}); err != nil { + t.Fatalf("RunRemove: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if _, exists := saved.Profiles["default"]; exists { + t.Error("profile was not removed") + } + if _, exists := saved.Profiles["staging"]; !exists { + t.Error("removing default also removed staging") + } +} + +func TestRunRemoveRequiresTypedConfirmation(t *testing.T) { + tests := []struct { + name string + input string + removed bool + }{ + {name: "matching name confirms", input: "default\n", removed: true}, + {name: "wrong name cancels", input: "nope\n", removed: false}, + {name: "empty input cancels", input: "\n", removed: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var out bytes.Buffer + if err := config.RunRemove(file, "default", false, strings.NewReader(tc.input), &out); err != nil { + t.Fatalf("RunRemove: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + _, exists := saved.Profiles["default"] + if exists == tc.removed { + t.Errorf("profile exists = %v, want removed = %v", exists, tc.removed) + } + if !tc.removed && !strings.Contains(out.String(), "cancelled") { + t.Errorf("output = %q, want a cancellation message", out.String()) + } + }) + } +} + +// TestRunRemoveClearsDanglingCurrentProfile matters because the SDK treats +// current_profile as an explicit selection: left pointing at a deleted +// profile, every later command fails with ErrProfileNotFound. +func TestRunRemoveClearsDanglingCurrentProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + var out bytes.Buffer + if err := config.RunRemove(file, "staging", true, strings.NewReader(""), &out); err != nil { + t.Fatalf("RunRemove: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if saved.CurrentProfile != "" { + t.Errorf("current_profile = %q, want it cleared", saved.CurrentProfile) + } + if !strings.Contains(out.String(), sdkconfig.DefaultProfileName) { + t.Errorf("output = %q, want it to name the fallback profile", out.String()) + } + + if _, loadErr := sdkconfig.Get(); loadErr != nil { + t.Errorf("SDK could not resolve after removing the active profile: %v", loadErr) + } +} + +func TestRunRemoveUnknownProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + err := config.RunRemove(file, "nope", true, strings.NewReader(""), &bytes.Buffer{}) + if !errors.Is(err, sdkconfig.ErrProfileNotFound) { + t.Errorf("RunRemove error = %v, want ErrProfileNotFound", err) + } +} diff --git a/internal/commands/config/set.go b/internal/commands/config/set.go new file mode 100644 index 0000000..60206dc --- /dev/null +++ b/internal/commands/config/set.go @@ -0,0 +1,36 @@ +package config + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/massdriver-cloud/mass/internal/configfile" +) + +// RunSet updates settings on an existing profile. A nil Verifier skips the +// credential check. +func RunSet(ctx context.Context, file *configfile.File, name string, edits ProfileEdits, verifier Verifier, out io.Writer) error { + profile, found := file.Profile(name) + if !found { + return unknownProfileError(file, name) + } + if !edits.Any() { + return errors.New("nothing to update: pass at least one of --org, --api-key, --url, --templates-path") + } + edits.Apply(&profile) + + if err := verify(ctx, verifier, profile, out); err != nil { + return err + } + if err := file.SetProfile(name, profile); err != nil { + return err + } + if err := file.Save(); err != nil { + return err + } + + fmt.Fprintf(out, "✅ Profile `%s` updated in %s\n", name, file.Path()) + return nil +} diff --git a/internal/commands/config/set_test.go b/internal/commands/config/set_test.go new file mode 100644 index 0000000..6be571d --- /dev/null +++ b/internal/commands/config/set_test.go @@ -0,0 +1,118 @@ +package config_test + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/config" + "github.com/massdriver-cloud/mass/internal/configfile" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +func TestRunSetChangesOnlyWhatWasPassed(t *testing.T) { + file := loadConfig(t, twoProfiles) + + edits := config.ProfileEdits{APIKey: ptr("mds_rotated123456")} + if err := config.RunSet(t.Context(), file, "staging", edits, nil, &bytes.Buffer{}); err != nil { + t.Fatalf("RunSet: %v", err) + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + want := sdkconfig.Profile{ + OrganizationID: "acme", + APIKey: "mds_rotated123456", + URL: "https://api.massdriver.example.com", + } + if got := saved.Profiles["staging"]; got != want { + t.Errorf("saved profile = %+v, want %+v", got, want) + } +} + +func TestRunSetClearsFieldOnExplicitEmptyValue(t *testing.T) { + file := loadConfig(t, `version: 1 +profiles: + staging: + organization_id: acme + api_key: mds_stagingkey1234 + templates_path: /tmp/templates +`) + + edits := config.ProfileEdits{TemplatesPath: ptr("")} + if err := config.RunSet(t.Context(), file, "staging", edits, nil, &bytes.Buffer{}); err != nil { + t.Fatalf("RunSet: %v", err) + } + + path, err := configfile.Path() + if err != nil { + t.Fatalf("Path: %v", err) + } + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if strings.Contains(string(data), "templates_path") { + t.Errorf("cleared field was written to the file:\n%s", data) + } +} + +func TestRunSetRequiresAtLeastOneEdit(t *testing.T) { + file := loadConfig(t, twoProfiles) + + err := config.RunSet(t.Context(), file, "staging", config.ProfileEdits{}, nil, &bytes.Buffer{}) + if err == nil { + t.Fatal("RunSet with no edits succeeded, want an error") + } + if !strings.Contains(err.Error(), "--org") { + t.Errorf("error = %v, want it to list the value flags", err) + } +} + +func TestRunSetUnknownProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + err := config.RunSet(t.Context(), file, "nope", config.ProfileEdits{URL: ptr("https://x")}, nil, &bytes.Buffer{}) + if !errors.Is(err, sdkconfig.ErrProfileNotFound) { + t.Errorf("RunSet error = %v, want ErrProfileNotFound", err) + } +} + +// TestRunSetVerifiesMergedProfile checks the credentials that are verified are +// the ones that will be stored, not just the fields that changed. +func TestRunSetVerifiesMergedProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + verifier := &stubVerifier{identity: "someone@example.com"} + edits := config.ProfileEdits{APIKey: ptr("mds_rotated123456")} + if err := config.RunSet(t.Context(), file, "staging", edits, verifier, &bytes.Buffer{}); err != nil { + t.Fatalf("RunSet: %v", err) + } + + if verifier.got.OrganizationID != "acme" || verifier.got.APIKey != "mds_rotated123456" { + t.Errorf("verified %+v, want the merged profile", verifier.got) + } +} + +func TestRunSetDoesNotWriteWhenVerificationFails(t *testing.T) { + file := loadConfig(t, twoProfiles) + + verifier := &stubVerifier{err: errors.New("unauthorized")} + edits := config.ProfileEdits{APIKey: ptr("mds_rotated123456")} + if err := config.RunSet(t.Context(), file, "staging", edits, verifier, &bytes.Buffer{}); err == nil { + t.Fatal("RunSet succeeded despite failed verification") + } + + saved, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if saved.Profiles["staging"].APIKey != "mds_stagingkey1234" { + t.Error("profile was modified despite failed verification") + } +} diff --git a/internal/commands/config/use.go b/internal/commands/config/use.go new file mode 100644 index 0000000..2665a13 --- /dev/null +++ b/internal/commands/config/use.go @@ -0,0 +1,31 @@ +package config + +import ( + "fmt" + "io" + "os" + + "github.com/massdriver-cloud/mass/internal/configfile" +) + +// RunUse points the config file's current_profile at name. The profile must +// exist: the SDK treats current_profile as an explicit selection and fails +// every later command when it names nothing. +func RunUse(file *configfile.File, name string, out io.Writer) error { + if _, found := file.Profile(name); !found { + return unknownProfileError(file, name) + } + if err := file.SetCurrentProfile(name); err != nil { + return err + } + if err := file.Save(); err != nil { + return err + } + + fmt.Fprintf(out, "✅ Active profile is now `%s`\n", name) + if env := os.Getenv(configfile.EnvProfile); env != "" && env != name { + fmt.Fprintf(out, "⚠️ %s is set to `%s` in this shell and takes precedence; unset it to use `%s`\n", + configfile.EnvProfile, env, name) + } + return nil +} diff --git a/internal/commands/config/use_test.go b/internal/commands/config/use_test.go new file mode 100644 index 0000000..3db4369 --- /dev/null +++ b/internal/commands/config/use_test.go @@ -0,0 +1,64 @@ +package config_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/commands/config" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +// TestRunUseIsHonoredBySDK is the contract this command rests on: it writes +// current_profile, and the SDK — with no override and no environment variable +// — selects it. +func TestRunUseIsHonoredBySDK(t *testing.T) { + file := loadConfig(t, twoProfiles) + + if err := config.RunUse(file, "default", &bytes.Buffer{}); err != nil { + t.Fatalf("RunUse: %v", err) + } + + cfg, err := sdkconfig.Get() + if err != nil { + t.Fatalf("sdkconfig.Get: %v", err) + } + if cfg.Profile != "default" { + t.Errorf("SDK selected %q, want default", cfg.Profile) + } +} + +// TestRunUseRejectsUnknownProfile keeps a typo from writing a current_profile +// that would fail every later command. +func TestRunUseRejectsUnknownProfile(t *testing.T) { + file := loadConfig(t, twoProfiles) + + err := config.RunUse(file, "nope", &bytes.Buffer{}) + if !errors.Is(err, sdkconfig.ErrProfileNotFound) { + t.Fatalf("RunUse error = %v, want ErrProfileNotFound", err) + } + + saved, readErr := sdkconfig.ReadFile() + if readErr != nil { + t.Fatalf("ReadFile: %v", readErr) + } + if saved.CurrentProfile != "staging" { + t.Errorf("current_profile = %q, want staging to be untouched", saved.CurrentProfile) + } +} + +// TestRunUseWarnsWhenEnvironmentOverrides tells the user why their switch +// appears to have had no effect. +func TestRunUseWarnsWhenEnvironmentOverrides(t *testing.T) { + file := loadConfig(t, twoProfiles) + t.Setenv("MASSDRIVER_PROFILE", "staging") + + var out bytes.Buffer + if err := config.RunUse(file, "default", &out); err != nil { + t.Fatalf("RunUse: %v", err) + } + if !strings.Contains(out.String(), "MASSDRIVER_PROFILE") { + t.Errorf("output = %q, want a warning about the environment variable", out.String()) + } +} diff --git a/internal/configfile/config.go b/internal/configfile/config.go new file mode 100644 index 0000000..da8d900 --- /dev/null +++ b/internal/configfile/config.go @@ -0,0 +1,341 @@ +// Package configfile edits the Massdriver config file. +// +// The SDK's massdriver/config package owns the schema, the file location, and +// how the file resolves into credentials: this package reads through +// [sdkconfig.ReadFile] and writes the types the SDK declares, so there is one +// definition of a profile and one notion of which profile is active. +// +// Writing is the CLI's job — the SDK only reads. Edits are applied to the +// parsed yaml.Node tree rather than by re-marshaling a struct, so comments, +// key order, and keys this CLI doesn't model survive a write. +package configfile + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" + "gopkg.in/yaml.v3" +) + +// EnvProfile selects the active profile. It mirrors the envconfig tag the SDK +// resolves ahead of current_profile; the SDK exports no constant for it. Read +// only to label output — the SDK is what acts on it. +const EnvProfile = "MASSDRIVER_PROFILE" + +const ( + profilesKey = "profiles" + versionKey = "version" + currentProfileKey = "current_profile" + + // fileMode keeps the file owner-only: it holds API keys. + fileMode os.FileMode = 0600 +) + +// NamedProfile is a profile together with the key it is stored under. +type NamedProfile struct { + Name string `json:"name"` + sdkconfig.Profile +} + +// File is an editable view of the config file. +type File struct { + path string + // parsed is the SDK's view, used for every read. Nil when the file does + // not exist. + parsed *sdkconfig.File + // doc is the same file as a node tree, used for every write. + doc *yaml.Node +} + +// Path returns the config file location the SDK will read. +func Path() (string, error) { + return sdkconfig.FilePath() +} + +// Load reads the config file. A missing file is not an error — it yields an +// empty File that Save will create. A file the SDK cannot parse is, so the +// CLI never edits a file the SDK would reject. +func Load() (*File, error) { + path, err := Path() + if err != nil { + return nil, err + } + + parsed, readErr := sdkconfig.ReadFile() + if readErr != nil { + return nil, readErr + } + + f := &File{path: path, parsed: parsed} + if parsed == nil { + return f, nil + } + + contents, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("could not read config file %s: %w", path, err) + } + var doc yaml.Node + if err = yaml.Unmarshal(contents, &doc); err != nil { + return nil, fmt.Errorf("could not parse config file %s: %w", path, err) + } + if doc.Kind != 0 { + f.doc = &doc + } + + return f, nil +} + +// Path returns the file's location on disk. +func (f *File) Path() string { return f.path } + +// Exists reports whether the file was present when loaded. +func (f *File) Exists() bool { return f.parsed != nil } + +// Profiles returns every configured profile, sorted by name. +func (f *File) Profiles() []NamedProfile { + names := f.parsed.ProfileNames() + profiles := make([]NamedProfile, 0, len(names)) + for _, name := range names { + profiles = append(profiles, NamedProfile{Name: name, Profile: f.parsed.Profiles[name]}) + } + return profiles +} + +// ProfileNames returns the configured profile names, sorted. +func (f *File) ProfileNames() []string { return f.parsed.ProfileNames() } + +// Profile returns the named profile and whether it exists. +func (f *File) Profile(name string) (sdkconfig.Profile, bool) { + if f.parsed == nil { + return sdkconfig.Profile{}, false + } + p, exists := f.parsed.Profiles[name] + return p, exists +} + +// CurrentProfile returns the file's current_profile, or "" when unset. +func (f *File) CurrentProfile() string { + if f.parsed == nil { + return "" + } + return f.parsed.CurrentProfile +} + +// ActiveProfileName reports which profile the SDK will select, so command +// output can label it. override is the --profile flag, which the CLI passes to +// the SDK as [massdriver.WithProfile]. +// +// This mirrors the precedence documented on [sdkconfig.Load], which is +// authoritative. TestActiveProfileNameMatchesSDK holds the two in agreement. +func (f *File) ActiveProfileName(override string) string { + if override != "" { + return override + } + if env := os.Getenv(EnvProfile); env != "" { + return env + } + if current := f.CurrentProfile(); current != "" { + return current + } + return sdkconfig.DefaultProfileName +} + +// SetProfile creates or updates a profile. Empty fields are removed from the +// file rather than written as blanks, so clearing a value and never setting +// one look the same on disk. +func (f *File) SetProfile(name string, p sdkconfig.Profile) error { + if name == "" { + return errors.New("profile name is required") + } + root, err := f.rootMap() + if err != nil { + return err + } + mapSet(root, versionKey, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(sdkconfig.Version)}) + + profiles := mapGet(root, profilesKey) + if profiles == nil || profiles.Kind != yaml.MappingNode { + profiles = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + mapSet(root, profilesKey, profiles) + } + + node := mapGet(profiles, name) + if node == nil || node.Kind != yaml.MappingNode { + node = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + mapSet(profiles, name, node) + } + + setOrDelete(node, "organization_id", p.OrganizationID) + setOrDelete(node, "api_key", p.APIKey) + setOrDelete(node, "url", p.URL) + setOrDelete(node, "templates_path", p.TemplatesPath) + + f.syncProfile(name, p) + return nil +} + +// RemoveProfile deletes a profile, reporting whether it was present. The +// current_profile pointer is cleared when it named the removed profile, since +// the SDK now fails rather than falling back when it points at nothing. +func (f *File) RemoveProfile(name string) bool { + root, err := f.rootMap() + if err != nil { + return false + } + if !mapDelete(mapGet(root, profilesKey), name) { + return false + } + if f.CurrentProfile() == name { + mapDelete(root, currentProfileKey) + f.parsed.CurrentProfile = "" + } + delete(f.parsed.Profiles, name) + return true +} + +// SetCurrentProfile points current_profile at name. The SDK reads this key, so +// the selection applies to every tool built on it, not just this CLI. +func (f *File) SetCurrentProfile(name string) error { + root, err := f.rootMap() + if err != nil { + return err + } + mapSet(root, currentProfileKey, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}) + f.parsed.CurrentProfile = name + return nil +} + +// Save writes the file, creating parent directories as needed. The write is +// atomic and the result is owner-readable only. +func (f *File) Save() error { + if f.doc == nil { + return errors.New("nothing to write") + } + if err := os.MkdirAll(filepath.Dir(f.path), 0750); err != nil { + return fmt.Errorf("could not create config directory: %w", err) + } + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(f.doc); err != nil { + return fmt.Errorf("could not encode config file: %w", err) + } + if err := encoder.Close(); err != nil { + return fmt.Errorf("could not encode config file: %w", err) + } + + tmp, err := os.CreateTemp(filepath.Dir(f.path), ".config.yaml.*") + if err != nil { + return fmt.Errorf("could not create temporary config file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if err = tmp.Chmod(fileMode); err != nil { + _ = tmp.Close() + return fmt.Errorf("could not set permissions on config file: %w", err) + } + if _, err = tmp.Write(buf.Bytes()); err != nil { + _ = tmp.Close() + return fmt.Errorf("could not write config file: %w", err) + } + if err = tmp.Close(); err != nil { + return fmt.Errorf("could not write config file: %w", err) + } + if err = os.Rename(tmpName, f.path); err != nil { + return fmt.Errorf("could not save config file %s: %w", f.path, err) + } + + return nil +} + +// syncProfile keeps the read view consistent with an edit, so a command can +// write a profile and then read it back without reloading. +func (f *File) syncProfile(name string, p sdkconfig.Profile) { + if f.parsed == nil { + f.parsed = &sdkconfig.File{Version: sdkconfig.Version} + } + if f.parsed.Profiles == nil { + f.parsed.Profiles = map[string]sdkconfig.Profile{} + } + f.parsed.Profiles[name] = p +} + +// rootMap returns the top-level mapping, creating the document skeleton for a +// file that does not exist yet. +func (f *File) rootMap() (*yaml.Node, error) { + if f.doc == nil { + f.doc = &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}, + } + } + if len(f.doc.Content) == 0 { + f.doc.Content = append(f.doc.Content, &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}) + } + root := f.doc.Content[0] + if root.Kind != yaml.MappingNode { + return nil, fmt.Errorf("config file %s must contain a YAML mapping at the top level", f.path) + } + return root, nil +} + +func mapGet(m *yaml.Node, key string) *yaml.Node { + if m == nil || m.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} + +// mapSet replaces a key's value in place, carrying over any comments attached +// to the value it displaces, or appends the pair when the key is new. +func mapSet(m *yaml.Node, key string, val *yaml.Node) { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + old := m.Content[i+1] + val.HeadComment = old.HeadComment + val.LineComment = old.LineComment + val.FootComment = old.FootComment + m.Content[i+1] = val + return + } + } + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + val, + ) +} + +func mapDelete(m *yaml.Node, key string) bool { + if m == nil || m.Kind != yaml.MappingNode { + return false + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + m.Content = slices.Delete(m.Content, i, i+2) + return true + } + } + return false +} + +func setOrDelete(m *yaml.Node, key, value string) { + if value == "" { + mapDelete(m, key) + return + } + mapSet(m, key, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}) +} diff --git a/internal/configfile/config_test.go b/internal/configfile/config_test.go new file mode 100644 index 0000000..f3a323d --- /dev/null +++ b/internal/configfile/config_test.go @@ -0,0 +1,499 @@ +package configfile_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/massdriver-cloud/mass/internal/configfile" + sdkconfig "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/config" +) + +// withConfigDir points config resolution at a temp dir and clears the +// environment variables that would otherwise leak the developer's own +// credentials into a test. +func withConfigDir(t *testing.T, contents string) string { + t.Helper() + + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("MASSDRIVER_PROFILE", "") + t.Setenv("MASSDRIVER_ORGANIZATION_ID", "") + t.Setenv("MASSDRIVER_ORG_ID", "") + t.Setenv("MASSDRIVER_API_KEY", "") + t.Setenv("MASSDRIVER_URL", "") + t.Setenv("MASSDRIVER_TEMPLATES_PATH", "") + + path := filepath.Join(dir, "massdriver", "config.yaml") + if contents != "" { + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + } + return path +} + +func TestPathMatchesSDK(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/custom/config") + + got, err := configfile.Path() + if err != nil { + t.Fatalf("Path: %v", err) + } + want, err := sdkconfig.FilePath() + if err != nil { + t.Fatalf("sdkconfig.FilePath: %v", err) + } + if got != want { + t.Errorf("Path() = %q, want %q (the SDK's location)", got, want) + } +} + +func TestLoadMissingFile(t *testing.T) { + withConfigDir(t, "") + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if file.Exists() { + t.Error("Exists() = true for a file that was never created") + } + if got := file.Profiles(); len(got) != 0 { + t.Errorf("Profiles() = %v, want empty", got) + } + if got := file.ActiveProfileName(""); got != sdkconfig.DefaultProfileName { + t.Errorf("ActiveProfileName() = %q, want %q", got, sdkconfig.DefaultProfileName) + } +} + +// TestLoadRefusesFileTheSDKRejects keeps the CLI from editing a file the SDK +// cannot read, which would otherwise let `mass config` write into a file no +// other command can load. +func TestLoadRefusesFileTheSDKRejects(t *testing.T) { + withConfigDir(t, "version: 2\nprofiles: {}\n") + + _, err := configfile.Load() + if err == nil { + t.Fatal("Load() succeeded, want the SDK's unsupported-version error") + } + if !strings.Contains(err.Error(), "version") { + t.Errorf("Load() error = %v, want it to mention the version", err) + } +} + +func TestProfilesAreSortedByName(t *testing.T) { + withConfigDir(t, `version: 1 +profiles: + staging: + organization_id: acme + api_key: mds_staging + default: + organization_id: acme + api_key: mds_default + url: https://api.massdriver.example.com + templates_path: /tmp/templates +`) + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + profiles := file.Profiles() + if len(profiles) != 2 { + t.Fatalf("Profiles() returned %d profiles, want 2", len(profiles)) + } + if profiles[0].Name != "default" || profiles[1].Name != "staging" { + t.Errorf("Profiles() names = %q, %q, want default, staging", profiles[0].Name, profiles[1].Name) + } + + want := sdkconfig.Profile{ + OrganizationID: "acme", + APIKey: "mds_default", + URL: "https://api.massdriver.example.com", + TemplatesPath: "/tmp/templates", + } + if profiles[0].Profile != want { + t.Errorf("Profiles()[0] = %+v, want %+v", profiles[0].Profile, want) + } +} + +func TestProfileNotFound(t *testing.T) { + withConfigDir(t, "version: 1\nprofiles:\n default:\n organization_id: acme\n") + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, found := file.Profile("nope"); found { + t.Error("Profile(\"nope\") reported found") + } +} + +// TestSetProfilePreservesComments is the reason edits go through yaml.Node: +// users have been hand-editing this file, and an update must not wipe their +// annotations, key ordering, or keys this CLI doesn't model. +func TestSetProfilePreservesComments(t *testing.T) { + path := withConfigDir(t, `# Massdriver CLI configuration +version: 1 +profiles: + # our shared staging org + staging: + organization_id: acme + api_key: mds_old # rotate quarterly + custom_key: keep-me +`) + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + profile, found := file.Profile("staging") + if !found { + t.Fatal("Profile(\"staging\") not found") + } + profile.APIKey = "mds_new" + if setErr := file.SetProfile("staging", profile); setErr != nil { + t.Fatalf("SetProfile: %v", setErr) + } + if saveErr := file.Save(); saveErr != nil { + t.Fatalf("Save: %v", saveErr) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + got := string(data) + + for _, want := range []string{ + "# Massdriver CLI configuration", + "# our shared staging org", + "# rotate quarterly", + "custom_key: keep-me", + "mds_new", + } { + if !strings.Contains(got, want) { + t.Errorf("saved file is missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "mds_old") { + t.Errorf("saved file still contains the old API key:\n%s", got) + } +} + +func TestSetProfileRemovesEmptyFields(t *testing.T) { + path := withConfigDir(t, `version: 1 +profiles: + staging: + organization_id: acme + api_key: mds_key + templates_path: /tmp/templates +`) + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + profile, _ := file.Profile("staging") + profile.TemplatesPath = "" + if setErr := file.SetProfile("staging", profile); setErr != nil { + t.Fatalf("SetProfile: %v", setErr) + } + if saveErr := file.Save(); saveErr != nil { + t.Fatalf("Save: %v", saveErr) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if strings.Contains(string(data), "templates_path") { + t.Errorf("cleared field was written to the file:\n%s", data) + } +} + +func TestSetProfileCreatesFileWithVersion(t *testing.T) { + withConfigDir(t, "") + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if setErr := file.SetProfile("default", sdkconfig.Profile{OrganizationID: "acme", APIKey: "mds_key"}); setErr != nil { + t.Fatalf("SetProfile: %v", setErr) + } + if saveErr := file.Save(); saveErr != nil { + t.Fatalf("Save: %v", saveErr) + } + + // Reading back through the SDK proves the version we stamped is the one + // the SDK accepts. + reloaded, err := sdkconfig.ReadFile() + if err != nil { + t.Fatalf("SDK could not read the file we created: %v", err) + } + profile, found := reloaded.Profiles["default"] + if !found { + t.Fatal("profile was not persisted") + } + if profile.OrganizationID != "acme" || profile.APIKey != "mds_key" { + t.Errorf("reloaded profile = %+v", profile) + } +} + +func TestSetProfileRequiresName(t *testing.T) { + withConfigDir(t, "") + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if err = file.SetProfile("", sdkconfig.Profile{OrganizationID: "acme"}); err == nil { + t.Error("SetProfile with an empty name succeeded, want an error") + } +} + +// TestSaveIsOwnerOnly guards the file that holds API keys. +func TestSaveIsOwnerOnly(t *testing.T) { + path := withConfigDir(t, "version: 1\nprofiles:\n default:\n organization_id: acme\n") + + if err := os.Chmod(path, 0644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if setErr := file.SetProfile("default", sdkconfig.Profile{OrganizationID: "acme", APIKey: "mds_key"}); setErr != nil { + t.Fatalf("SetProfile: %v", setErr) + } + if saveErr := file.Save(); saveErr != nil { + t.Fatalf("Save: %v", saveErr) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("config file mode = %04o, want 0600", perm) + } +} + +// TestRemoveProfileClearsCurrentProfile matters more now that the SDK treats +// current_profile as an explicit selection: leaving it pointed at a deleted +// profile would fail every subsequent command with ErrProfileNotFound. +func TestRemoveProfileClearsCurrentProfile(t *testing.T) { + path := withConfigDir(t, `version: 1 +current_profile: staging +profiles: + default: + organization_id: acme + api_key: mds_default + staging: + organization_id: acme + api_key: mds_staging +`) + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !file.RemoveProfile("staging") { + t.Fatal("RemoveProfile(\"staging\") = false, want true") + } + if file.RemoveProfile("staging") { + t.Error("RemoveProfile on an already-removed profile = true, want false") + } + if current := file.CurrentProfile(); current != "" { + t.Errorf("CurrentProfile() = %q after removing the active profile, want empty", current) + } + if _, found := file.Profile("default"); !found { + t.Error("removing staging also removed default") + } + if saveErr := file.Save(); saveErr != nil { + t.Fatalf("Save: %v", saveErr) + } + if data, readErr := os.ReadFile(path); readErr == nil && strings.Contains(string(data), "current_profile") { + t.Errorf("current_profile still points at the removed profile:\n%s", data) + } + + // The SDK must now resolve without error rather than tripping over a + // dangling current_profile. + if _, loadErr := sdkconfig.Get(); loadErr != nil { + t.Errorf("SDK could not resolve after removing the active profile: %v", loadErr) + } +} + +// TestSDKHonorsCurrentProfileWeWrite is the contract this feature rests on: +// `mass config use` writes current_profile, and the SDK — with no override and +// no environment variable — selects it. +func TestSDKHonorsCurrentProfileWeWrite(t *testing.T) { + withConfigDir(t, "") + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if setErr := file.SetProfile("default", sdkconfig.Profile{OrganizationID: "other", APIKey: "mds_other"}); setErr != nil { + t.Fatalf("SetProfile: %v", setErr) + } + if setErr := file.SetProfile("staging", sdkconfig.Profile{ + OrganizationID: "acme", + APIKey: "mds_key", + URL: "https://api.massdriver.example.com", + TemplatesPath: "/tmp/templates", + }); setErr != nil { + t.Fatalf("SetProfile: %v", setErr) + } + if setErr := file.SetCurrentProfile("staging"); setErr != nil { + t.Fatalf("SetCurrentProfile: %v", setErr) + } + if saveErr := file.Save(); saveErr != nil { + t.Fatalf("Save: %v", saveErr) + } + + cfg, err := sdkconfig.Get() + if err != nil { + t.Fatalf("SDK could not load the config we wrote: %v", err) + } + if cfg.Profile != "staging" { + t.Errorf("SDK selected profile %q, want staging (current_profile was ignored)", cfg.Profile) + } + if cfg.OrganizationID != "acme" { + t.Errorf("SDK OrganizationID = %q, want acme", cfg.OrganizationID) + } + if cfg.URL != "https://api.massdriver.example.com" { + t.Errorf("SDK URL = %q", cfg.URL) + } + if cfg.TemplatesPath != "/tmp/templates" { + t.Errorf("SDK TemplatesPath = %q", cfg.TemplatesPath) + } + if cfg.Credentials.Source != sdkconfig.SourceProfile { + t.Errorf("SDK credential source = %q, want profile", cfg.Credentials.Source) + } +} + +// TestActiveProfileNameMatchesSDK pins the label shown by `mass config list` +// and `mass config get` to the profile the SDK actually selects. The SDK's +// precedence is authoritative; this test is what keeps the display helper from +// drifting away from it. +func TestActiveProfileNameMatchesSDK(t *testing.T) { + tests := []struct { + name string + contents string + envProfile string + override string + want string + }{ + { + name: "falls back to the default profile", + contents: `version: 1 +profiles: + default: + organization_id: acme + api_key: mds_default +`, + want: "default", + }, + { + name: "honors current_profile", + contents: `version: 1 +current_profile: staging +profiles: + default: + organization_id: acme + api_key: mds_default + staging: + organization_id: acme + api_key: mds_staging +`, + want: "staging", + }, + { + name: "MASSDRIVER_PROFILE beats current_profile", + contents: `version: 1 +current_profile: staging +profiles: + staging: + organization_id: acme + api_key: mds_staging + production: + organization_id: acme + api_key: mds_production +`, + envProfile: "production", + want: "production", + }, + { + name: "the --profile override beats everything", + contents: `version: 1 +current_profile: staging +profiles: + staging: + organization_id: acme + api_key: mds_staging + production: + organization_id: acme + api_key: mds_production +`, + envProfile: "staging", + override: "production", + want: "production", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + withConfigDir(t, tc.contents) + if tc.envProfile != "" { + t.Setenv("MASSDRIVER_PROFILE", tc.envProfile) + } + + file, err := configfile.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + got := file.ActiveProfileName(tc.override) + if got != tc.want { + t.Errorf("ActiveProfileName(%q) = %q, want %q", tc.override, got, tc.want) + } + + // Overrides.Profile is what newMassdriverClient passes for + // --profile, so this is the selection the CLI actually gets. + cfg, err := sdkconfig.Load(sdkconfig.Overrides{Profile: tc.override}) + if err != nil { + t.Fatalf("sdkconfig.Load: %v", err) + } + if cfg.Profile != got { + t.Errorf("ActiveProfileName(%q) = %q but the SDK selected %q", tc.override, got, cfg.Profile) + } + }) + } +} + +// TestSDKFailsOnDanglingCurrentProfile documents why `mass config use` +// validates the profile exists before writing it. +func TestSDKFailsOnDanglingCurrentProfile(t *testing.T) { + withConfigDir(t, `version: 1 +current_profile: gone +profiles: + default: + organization_id: acme + api_key: mds_default +`) + + _, err := sdkconfig.Get() + if !errors.Is(err, sdkconfig.ErrProfileNotFound) { + t.Errorf("sdkconfig.Get() error = %v, want ErrProfileNotFound", err) + } +}