From 88bf22bcaf0765ac7c3622b1623a3a0cf9aadbdb Mon Sep 17 00:00:00 2001 From: chrisghill Date: Thu, 10 Sep 2026 16:19:16 -0600 Subject: [PATCH] Add # Manage Configuration Profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 mass config add default --org acme --api-key mds_xxx mass config list Usage: mass config [command] Aliases: config, profile Available Commands: add Add a profile get Show a profile's settings (defaults to the active profile) list List configured profiles path Print the path to the configuration file remove Remove a profile set Update settings on an existing profile use Set the active profile Flags: -h, --help help for config Global Flags: --profile string Configuration profile to use (overrides MASSDRIVER_PROFILE and the active profile) Use "mass config [command] --help" for more information about a command. commands for managing config file --- CLAUDE.md | 22 + cmd/bundle.go | 24 +- cmd/client.go | 16 + cmd/component.go | 11 +- cmd/config.go | 295 +++++++++++ cmd/deployment.go | 15 +- cmd/environment.go | 24 +- cmd/instance.go | 20 +- cmd/project.go | 15 +- cmd/repository.go | 14 +- cmd/resource.go | 13 +- cmd/resource_type.go | 13 +- cmd/root.go | 3 + cmd/schema.go | 3 +- cmd/version.go | 3 +- cmd/whoami.go | 3 +- docs/generated/mass.md | 4 +- docs/generated/mass_bundle.md | 6 + docs/generated/mass_bundle_build.md | 6 + docs/generated/mass_bundle_create.md | 6 + docs/generated/mass_bundle_get.md | 6 + docs/generated/mass_bundle_import.md | 6 + docs/generated/mass_bundle_lint.md | 6 + docs/generated/mass_bundle_list.md | 6 + docs/generated/mass_bundle_new.md | 6 + docs/generated/mass_bundle_publish.md | 6 + docs/generated/mass_bundle_pull.md | 6 + docs/generated/mass_bundle_template.md | 6 + docs/generated/mass_bundle_template_list.md | 6 + docs/generated/mass_completion.md | 6 + docs/generated/mass_completion_bash.md | 6 + docs/generated/mass_completion_fish.md | 6 + docs/generated/mass_completion_powershell.md | 6 + docs/generated/mass_completion_zsh.md | 6 + docs/generated/mass_component.md | 6 + docs/generated/mass_component_add.md | 6 + docs/generated/mass_component_link.md | 6 + docs/generated/mass_component_remove.md | 6 + docs/generated/mass_component_unlink.md | 6 + docs/generated/mass_component_update.md | 6 + docs/generated/mass_config.md | 59 +++ docs/generated/mass_config_add.md | 75 +++ docs/generated/mass_config_get.md | 62 +++ docs/generated/mass_config_list.md | 62 +++ docs/generated/mass_config_path.md | 60 +++ docs/generated/mass_config_remove.md | 61 +++ docs/generated/mass_config_set.md | 71 +++ docs/generated/mass_config_use.md | 60 +++ docs/generated/mass_deployment.md | 6 + docs/generated/mass_deployment_abort.md | 6 + docs/generated/mass_deployment_approve.md | 6 + docs/generated/mass_deployment_compare.md | 6 + docs/generated/mass_deployment_get.md | 6 + docs/generated/mass_deployment_list.md | 6 + docs/generated/mass_deployment_logs.md | 6 + docs/generated/mass_deployment_reject.md | 6 + docs/generated/mass_docs.md | 6 + docs/generated/mass_environment.md | 6 + docs/generated/mass_environment_compare.md | 6 + docs/generated/mass_environment_create.md | 6 + .../mass_environment_decommission.md | 6 + docs/generated/mass_environment_default.md | 6 + docs/generated/mass_environment_delete.md | 6 + docs/generated/mass_environment_deploy.md | 6 + docs/generated/mass_environment_export.md | 6 + docs/generated/mass_environment_fork.md | 6 + docs/generated/mass_environment_get.md | 6 + docs/generated/mass_environment_list.md | 6 + docs/generated/mass_environment_preview.md | 6 + docs/generated/mass_environment_update.md | 6 + docs/generated/mass_instance.md | 6 + docs/generated/mass_instance_copy.md | 6 + docs/generated/mass_instance_deploy.md | 6 + docs/generated/mass_instance_destroy.md | 6 + docs/generated/mass_instance_export.md | 6 + docs/generated/mass_instance_get.md | 6 + docs/generated/mass_instance_list.md | 6 + docs/generated/mass_instance_orphan.md | 6 + .../mass_instance_remote-reference.md | 6 + .../mass_instance_remote-reference_remove.md | 6 + .../mass_instance_remote-reference_set.md | 6 + docs/generated/mass_instance_rollback.md | 6 + docs/generated/mass_instance_version.md | 6 + docs/generated/mass_project.md | 6 + docs/generated/mass_project_clone.md | 6 + docs/generated/mass_project_create.md | 6 + docs/generated/mass_project_delete.md | 6 + docs/generated/mass_project_export.md | 6 + docs/generated/mass_project_get.md | 6 + docs/generated/mass_project_list.md | 6 + docs/generated/mass_project_update.md | 6 + docs/generated/mass_repository.md | 6 + docs/generated/mass_repository_create.md | 6 + docs/generated/mass_repository_delete.md | 6 + docs/generated/mass_repository_get.md | 6 + docs/generated/mass_repository_list.md | 6 + docs/generated/mass_repository_update.md | 6 + docs/generated/mass_resource-type.md | 6 + docs/generated/mass_resource-type_convert.md | 6 + docs/generated/mass_resource-type_create.md | 6 + docs/generated/mass_resource-type_delete.md | 6 + docs/generated/mass_resource-type_get.md | 6 + docs/generated/mass_resource-type_list.md | 6 + docs/generated/mass_resource-type_publish.md | 6 + docs/generated/mass_resource-type_pull.md | 6 + docs/generated/mass_resource.md | 6 + docs/generated/mass_resource_create.md | 6 + docs/generated/mass_resource_delete.md | 6 + docs/generated/mass_resource_download.md | 6 + docs/generated/mass_resource_get.md | 6 + docs/generated/mass_resource_list.md | 6 + docs/generated/mass_resource_update.md | 6 + docs/generated/mass_schema.md | 6 + docs/generated/mass_schema_dereference.md | 6 + docs/generated/mass_schema_validate.md | 6 + docs/generated/mass_version.md | 6 + docs/generated/mass_whoami.md | 6 + docs/helpdocs/config.md | 23 + docs/helpdocs/config/add.md | 29 + docs/helpdocs/config/get.md | 21 + docs/helpdocs/config/list.md | 21 + docs/helpdocs/config/path.md | 21 + docs/helpdocs/config/remove.md | 21 + docs/helpdocs/config/set.md | 27 + docs/helpdocs/config/use.md | 21 + go.mod | 2 +- go.sum | 4 + internal/commands/config/add.go | 90 ++++ internal/commands/config/add_test.go | 247 +++++++++ internal/commands/config/config.go | 180 +++++++ internal/commands/config/config_test.go | 151 ++++++ internal/commands/config/get.go | 60 +++ internal/commands/config/get_test.go | 73 +++ internal/commands/config/list.go | 59 +++ internal/commands/config/list_test.go | 118 +++++ internal/commands/config/remove.go | 43 ++ internal/commands/config/remove_test.go | 101 ++++ internal/commands/config/set.go | 36 ++ internal/commands/config/set_test.go | 118 +++++ internal/commands/config/use.go | 31 ++ internal/commands/config/use_test.go | 64 +++ internal/configfile/config.go | 341 ++++++++++++ internal/configfile/config_test.go | 499 ++++++++++++++++++ 143 files changed, 3876 insertions(+), 85 deletions(-) create mode 100644 CLAUDE.md create mode 100644 cmd/client.go create mode 100644 cmd/config.go create mode 100644 docs/generated/mass_config.md create mode 100644 docs/generated/mass_config_add.md create mode 100644 docs/generated/mass_config_get.md create mode 100644 docs/generated/mass_config_list.md create mode 100644 docs/generated/mass_config_path.md create mode 100644 docs/generated/mass_config_remove.md create mode 100644 docs/generated/mass_config_set.md create mode 100644 docs/generated/mass_config_use.md create mode 100644 docs/helpdocs/config.md create mode 100644 docs/helpdocs/config/add.md create mode 100644 docs/helpdocs/config/get.md create mode 100644 docs/helpdocs/config/list.md create mode 100644 docs/helpdocs/config/path.md create mode 100644 docs/helpdocs/config/remove.md create mode 100644 docs/helpdocs/config/set.md create mode 100644 docs/helpdocs/config/use.md create mode 100644 internal/commands/config/add.go create mode 100644 internal/commands/config/add_test.go create mode 100644 internal/commands/config/config.go create mode 100644 internal/commands/config/config_test.go create mode 100644 internal/commands/config/get.go create mode 100644 internal/commands/config/get_test.go create mode 100644 internal/commands/config/list.go create mode 100644 internal/commands/config/list_test.go create mode 100644 internal/commands/config/remove.go create mode 100644 internal/commands/config/remove_test.go create mode 100644 internal/commands/config/set.go create mode 100644 internal/commands/config/set_test.go create mode 100644 internal/commands/config/use.go create mode 100644 internal/commands/config/use_test.go create mode 100644 internal/configfile/config.go create mode 100644 internal/configfile/config_test.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..48b0e40c --- /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 5153ca17..d50a94c1 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 00000000..0ce385c9 --- /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 0e718eca..5f6d0036 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 00000000..db4eba7a --- /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 b67b1e77..e6cc745c 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 ead1397b..32ccb912 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 4818b418..084386ad 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 9f87516f..012e7f3f 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 f58a5b25..665ec26b 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 4dd64de8..cca5b7dd 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 b4be11d6..f825a0bd 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 2dd6feb5..6e110686 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 56521582..3bff0bb1 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 8a3363bf..cfde88e4 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 10c4cab4..2ed5e2d7 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 8bf4b60c..3a856b73 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 4d1a14c6..e7971d77 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 fa68f984..974f473f 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 0114c4d9..1427ab94 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 32c16a08..9b968702 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 8509e84e..e81f7376 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 b7adda32..0d30f136 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 c55972e1..03ce17f2 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 097daca5..9ba6cf76 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 2d9012ed..1083c978 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 f8c56ef6..89eb5262 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 2d91c649..cd077c1a 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 38d054e5..f9753898 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 80c18be3..317f7eec 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 c05dd991..6d8e8798 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 405396fc..a6c771ae 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 e3a8906f..f1a2cd74 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 2eccc5a1..72f707a9 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 96d2e511..ea37403f 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 c6c422d2..9ef1f03b 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 be703da6..b0a0844f 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 8b2765f5..8b3a1df0 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 7f82c2a0..af57a7c2 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 e6aad768..81b8db48 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 00000000..88787022 --- /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 00000000..538a58a9 --- /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 00000000..2f7f5592 --- /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 00000000..5823bd75 --- /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 00000000..f5fdcd67 --- /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 00000000..15259cb9 --- /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 00000000..615ac2b5 --- /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 00000000..08dba184 --- /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 b6127b5e..4222642d 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 b9bdf6b7..2d6bda29 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 07764f7e..69d46622 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 443846c8..82bbcbcd 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 3ec7fa41..110514e9 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 e36c6f44..bfa09567 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 2649203c..c59d9278 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 4783b70c..0d35087a 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 1d0153d6..60bfd505 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 60012f91..fa91280d 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 1546af64..230f6aec 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 8c705ade..6633d721 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 642e85ad..90ac859a 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 2034bac4..e3856c08 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 df82155b..d140581c 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 fa136c6a..c63c4351 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 bc308d15..feb343d6 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 5ddc2f45..0ea04726 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 dda996ec..424a7097 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 0f7455d2..ae2c33a8 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 2046842b..405c49aa 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 3e800917..07f64285 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 7db52be2..0709aa86 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 261a2cd6..ac51c300 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 a65203f5..752424a8 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 5fce4224..b8862102 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 e17f231c..f3039e1f 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 61696c53..c3983675 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 aaacb929..8d5ccf59 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 4275e16c..bf717652 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 d6e63993..fac34808 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 6b886034..94c57125 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 bda3166a..17ab77c3 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 aef85d3a..d44e1eee 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 63cef664..a6b9377a 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 31ea1439..9b4e846a 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 5b11121b..3b84c137 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 a1cbedca..f5c71ecb 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 db2523ce..d1f05ad6 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 9d3f5cf1..e84614d8 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 5ec4af34..665d4410 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 86832c4c..368fb733 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 bcadf638..cdc9fbf0 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 ea71c6dc..d2ab6c1b 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 700f3628..5e382faa 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 bf8a4a2b..94fe9be8 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 5481b068..20e093f6 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 f47ee88d..076e9680 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 9cae73e0..af5e17bc 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 1986a29c..4ac85dff 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 4a1a4243..25d570b7 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 55b16fb2..442e5c8e 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 49930075..c1bf07eb 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 11149f26..a71201e4 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 02cad451..ae377f65 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 7ed10fe0..05325fa7 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 f7b806d3..81edeb27 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 df56c0b6..b4d61583 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 15badce2..a5dbb61b 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 b32600d6..20675955 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 98109c61..4ecb7c76 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 7c9a4ef9..ffb375c5 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 25e8a661..e4c37c14 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 8f5f64cf..6f20e6ec 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 a4dc4686..0c41b116 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 034aec65..99e3661f 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 cb19b74c..681c587e 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 8ef8b011..1be0dff2 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 b3e85f83..6ddd64d2 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 00000000..22729af2 --- /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 00000000..0dbe28f1 --- /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 00000000..bbc8e6b3 --- /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 00000000..af12c4ce --- /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 00000000..d0649ff9 --- /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 00000000..1fb2ed56 --- /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 00000000..09bea6dc --- /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 00000000..88313325 --- /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 07dd96e8..554450d6 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 56a26bec..df1df160 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 00000000..07dce1f6 --- /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 00000000..87351ad2 --- /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 00000000..d6e384fe --- /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 00000000..6a5a8f2d --- /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 00000000..5daa7891 --- /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 00000000..6088342f --- /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 00000000..e3636d1c --- /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 00000000..40804b39 --- /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 00000000..c89ae7da --- /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 00000000..fb46ba58 --- /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 00000000..60206dcf --- /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 00000000..6be571d5 --- /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 00000000..2665a138 --- /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 00000000..3db43692 --- /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 00000000..da8d900c --- /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 00000000..f3a323d8 --- /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) + } +}