From c229162281c32c60aa60b34e9625560ce3122a5e Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Mon, 3 Nov 2025 16:19:07 +1000 Subject: [PATCH 01/15] First pass before testing --- go.mod | 2 +- go.sum | 2 + pkg/cmd/ephemeralenvironment/create/create.go | 151 ++++++++++++++++++ .../ephemeralenvironment.go | 27 ++++ pkg/cmd/root/root.go | 2 + 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/ephemeralenvironment/create/create.go create mode 100644 pkg/cmd/ephemeralenvironment/ephemeralenvironment.go diff --git a/go.mod b/go.mod index f491d657..9e638d9e 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 03532249..7b276a35 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4 github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2 h1:dBTkP2Uxxn/gGOC0i0RPQzyJOqmL1Sv/ntja2u6Jhwo= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21 h1:8YVMZEu/U8sLWuI1M76e1esEr9yz4ctK379o4O8oH2M= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go new file mode 100644 index 00000000..8c2e8792 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -0,0 +1,151 @@ +// Package create handles creating new environments in Octopus Deploy +package create + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" // Interactive prompts for user input + "github.com/MakeNowJust/heredoc/v2" // Multi-line string formatting + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" // Output formatting utilities + "github.com/OctopusDeploy/cli/pkg/question" // Common question prompts + "github.com/OctopusDeploy/cli/pkg/util/flag" // Flag management utilities + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" // Octopus Deploy environments API + "github.com/spf13/cobra" // Command-line interface framework +) + +// Constants defining the command-line flag names +const ( + FlagName = "name" + FlagProject = "project" +) + +// CreateFlags holds all the command-line flags for the create command +type CreateFlags struct { + Name *flag.Flag[string] // Environment name + Project *flag.Flag[string] // Project name +} + +// NewCreateFlags creates and initializes a new CreateFlags struct with default values +func NewCreateFlags() *CreateFlags { + return &CreateFlags{ + Name: flag.New[string](FlagName, false), // Create string flag for name + Project: flag.New[string](FlagProject, false), // Create string flag for project + } +} + +// CreateOptions combines the command flags with common dependencies like API client and output +type CreateOptions struct { + *CreateFlags // Embeds all the command flags + *cmd.Dependencies // Embeds common dependencies (API client, output writer, etc.) +} + +// NewCreateOptions creates a new CreateOptions struct with the provided flags and dependencies +func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { + return &CreateOptions{ + CreateFlags: createFlags, // Store the command flags + Dependencies: dependencies, // Store the common dependencies + } +} + +// NewCmdCreate creates the cobra command for creating environments +func NewCmdCreate(f factory.Factory) *cobra.Command { + createFlags := NewCreateFlags() // Initialize the command flags + + // Create the cobra command with its configuration + cmd := &cobra.Command{ + Use: "create", // Command name + Short: "Create an ephemeral environment", // Short description + Long: "Create an ephemeral environment in Octopus Deploy", // Long description + Example: heredoc.Docf("$ %s ephemeralenvironment create", constants.ExecutableName), // Usage example + Aliases: []string{"new"}, // Alternative command names + RunE: func(c *cobra.Command, _ []string) error { // Function to run when command is executed + opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) // Create options with flags and dependencies + + return createRun(opts) // Execute the main create logic + }, + } + + // Set up command-line flags + flags := cmd.Flags() + flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "Name of the environment") // -n, --name flag + flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name of the project") // -p, --project flag + + return cmd // Return the configured command +} + +// createRun contains the main logic for creating an environment +func createRun(opts *CreateOptions) error { + // If prompting is enabled, ask user for any missing values + if !opts.NoPrompt { + err := PromptMissing(opts) + if err != nil { + return err + } + } + + // Create a new environment command and send to Octopus deploy + createEnv, err := ephemeralenvironments.Add(opts.Client, opts.Space.Slug, opts.Project.Value, opts.Name.Value) + if err != nil { + return err + } + + // Print success message with environment name and ID + _, err = fmt.Fprintf(opts.Out, "\nSuccessfully created ephemeral environment '%s` with id '%s'.\n", opts.Name.Value, createEnv.Id) + if err != nil { + return err + } + + // Generate and display a clickable link to view the environment in Octopus Deploy web UI + link := output.Bluef("%s/app#/%s/infrastructure/environments/%s", opts.Host, opts.Space.GetID(), createEnv.Id) // cc check if this link works! + fmt.Fprintf(opts.Out, "View this environment on Octopus Deploy: %s\n", link) + + // If prompting is enabled, show the equivalent automation command for future use + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.Project) + fmt.Fprintf(opts.Out, "%s\n", autoCmd) + } + + return nil // Success - no error +} + +// PromptMissing prompts the user for any missing required values +func PromptMissing(opts *CreateOptions) error { + // Ask for environment name if not provided + err := question.AskName(opts.Ask, "", "environment", &opts.Name.Value) + if err != nil { + return err + } + + // Ask for project name if not provided + if opts.Project.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Project Name", + Help: "The name of the environment to associate the ephemeral environment with.", + }, &opts.Project.Value, survey.WithValidator(survey.ComposeValidators( + survey.Required, // cc can we check the project exists? + ))); err != nil { + return err + } + } + + return nil +} + +// cc repurpose this for confirmation check! +// promptBool shows a yes/no prompt to the user for boolean values +func promptBool(opts *CreateOptions, value *bool, defaultValue bool, message string, help string) (bool, error) { + // If the value is already different from default, don't prompt (it was set via flag) + if *value != defaultValue { + return *value, nil + } + // Show interactive confirmation prompt + err := opts.Ask(&survey.Confirm{ + Message: message, // The question to display + Help: help, // Help text explaining the option + Default: defaultValue, // Default value if user just presses Enter + }, value) + return *value, err // Return the user's choice and any error +} diff --git a/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go new file mode 100644 index 00000000..248fa7f2 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go @@ -0,0 +1,27 @@ +package ephemeralenvironment + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/create" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/constants/annotations" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdEphemeralEnvironment(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "ephemeralenvironment ", + Short: "Manage ephemeral environments", + Long: "Manage ephemeral environments in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s ephemeralenvironment create --name "EE1" --project "MyProject" + `, constants.ExecutableName), + Annotations: map[string]string{ + annotations.IsInfrastructure: "true", + }, + } + + cmd.AddCommand(cmdCreate.NewCmdCreate(f)) + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 2e5ea668..630a96d7 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -7,6 +7,7 @@ import ( channelCmd "github.com/OctopusDeploy/cli/pkg/cmd/channel" configCmd "github.com/OctopusDeploy/cli/pkg/cmd/config" environmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/environment" + ephemeralEnvironmentCmd "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment" loginCmd "github.com/OctopusDeploy/cli/pkg/cmd/login" logoutCmd "github.com/OctopusDeploy/cli/pkg/cmd/logout" packageCmd "github.com/OctopusDeploy/cli/pkg/cmd/package" @@ -51,6 +52,7 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro // infrastructure cmd.AddCommand(accountCmd.NewCmdAccount(f)) cmd.AddCommand(environmentCmd.NewCmdEnvironment(f)) + cmd.AddCommand(ephemeralEnvironmentCmd.NewCmdEphemeralEnvironment(f)) cmd.AddCommand(packageCmd.NewCmdPackage(f)) cmd.AddCommand(buildInfoCmd.NewCmdBuildInformation(f)) cmd.AddCommand(deploymentTargetCmd.NewCmdDeploymentTarget(f)) From d1d0df57533a797558d592325cb44bee8c4be0e5 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Mon, 3 Nov 2025 16:44:04 +1000 Subject: [PATCH 02/15] First working draft! --- pkg/cmd/ephemeralenvironment/create/create.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 8c2e8792..584b9bcd 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -71,7 +71,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { // Set up command-line flags flags := cmd.Flags() flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "Name of the environment") // -n, --name flag - flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name of the project") // -p, --project flag + flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name of the project") // -p, --project flag // cc currently takes the ID!! TODO fix! return cmd // Return the configured command } @@ -87,7 +87,7 @@ func createRun(opts *CreateOptions) error { } // Create a new environment command and send to Octopus deploy - createEnv, err := ephemeralenvironments.Add(opts.Client, opts.Space.Slug, opts.Project.Value, opts.Name.Value) + createEnv, err := ephemeralenvironments.Add(opts.Client, opts.Space.ID, opts.Project.Value, opts.Name.Value) if err != nil { return err } @@ -99,8 +99,8 @@ func createRun(opts *CreateOptions) error { } // Generate and display a clickable link to view the environment in Octopus Deploy web UI - link := output.Bluef("%s/app#/%s/infrastructure/environments/%s", opts.Host, opts.Space.GetID(), createEnv.Id) // cc check if this link works! - fmt.Fprintf(opts.Out, "View this environment on Octopus Deploy: %s\n", link) + link := output.Bluef("%s/app#/%s/projects/%s/ephemeral-environments", opts.Host, opts.Space.GetID(), opts.Project.Value) // cc check link works after you fix project id/name issue + fmt.Fprintf(opts.Out, "View this ephemeral environments for project `%s` on Octopus Deploy: %s\n", opts.Project.Value, link) // cc fix text to show project name // If prompting is enabled, show the equivalent automation command for future use if !opts.NoPrompt { From a6f19c3cca62690e7c0bb39027d7ffe95dc76146 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Mon, 3 Nov 2025 16:53:22 +1000 Subject: [PATCH 03/15] Take project name instead of id --- pkg/cmd/ephemeralenvironment/create/create.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 584b9bcd..910beb55 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -13,6 +13,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/question" // Common question prompts "github.com/OctopusDeploy/cli/pkg/util/flag" // Flag management utilities "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" // Octopus Deploy environments API + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" // Octopus Deploy projects API "github.com/spf13/cobra" // Command-line interface framework ) @@ -71,7 +72,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { // Set up command-line flags flags := cmd.Flags() flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "Name of the environment") // -n, --name flag - flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name of the project") // -p, --project flag // cc currently takes the ID!! TODO fix! + flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name of the project") // -p, --project flag return cmd // Return the configured command } @@ -86,8 +87,16 @@ func createRun(opts *CreateOptions) error { } } + // Get the project by name to retrieve its ID + projectResource, err := projects.GetByName(opts.Client, opts.Space.ID, opts.Project.Value) + if err != nil { + return fmt.Errorf("failed to find project '%s': %w", opts.Project.Value, err) + } + // Update the project value to use the ID instead of name for the API call + projectId := projectResource.GetID() + // Create a new environment command and send to Octopus deploy - createEnv, err := ephemeralenvironments.Add(opts.Client, opts.Space.ID, opts.Project.Value, opts.Name.Value) + createEnv, err := ephemeralenvironments.Add(opts.Client, opts.Space.ID, projectId, opts.Name.Value) if err != nil { return err } @@ -99,7 +108,7 @@ func createRun(opts *CreateOptions) error { } // Generate and display a clickable link to view the environment in Octopus Deploy web UI - link := output.Bluef("%s/app#/%s/projects/%s/ephemeral-environments", opts.Host, opts.Space.GetID(), opts.Project.Value) // cc check link works after you fix project id/name issue + link := output.Bluef("%s/app#/%s/projects/%s/ephemeral-environments", opts.Host, opts.Space.GetID(), projectId) // cc check link works after you fix project id/name issue fmt.Fprintf(opts.Out, "View this ephemeral environments for project `%s` on Octopus Deploy: %s\n", opts.Project.Value, link) // cc fix text to show project name // If prompting is enabled, show the equivalent automation command for future use @@ -125,7 +134,7 @@ func PromptMissing(opts *CreateOptions) error { Message: "Project Name", Help: "The name of the environment to associate the ephemeral environment with.", }, &opts.Project.Value, survey.WithValidator(survey.ComposeValidators( - survey.Required, // cc can we check the project exists? + survey.Required, ))); err != nil { return err } From d33865fc594ca982cd2aeecb11c873b5d8301b3e Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 11:09:43 +1000 Subject: [PATCH 04/15] Add tests --- pkg/cmd/ephemeralenvironment/create/create.go | 20 +------ .../create/create_test.go | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 18 deletions(-) create mode 100644 pkg/cmd/ephemeralenvironment/create/create_test.go diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 910beb55..920c3ca2 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -123,7 +123,7 @@ func createRun(opts *CreateOptions) error { // PromptMissing prompts the user for any missing required values func PromptMissing(opts *CreateOptions) error { // Ask for environment name if not provided - err := question.AskName(opts.Ask, "", "environment", &opts.Name.Value) + err := question.AskName(opts.Ask, "", "ephemeral environment", &opts.Name.Value) if err != nil { return err } @@ -132,7 +132,7 @@ func PromptMissing(opts *CreateOptions) error { if opts.Project.Value == "" { if err := opts.Ask(&survey.Input{ Message: "Project Name", - Help: "The name of the environment to associate the ephemeral environment with.", + Help: "The name of the project to associate the ephemeral environment with.", }, &opts.Project.Value, survey.WithValidator(survey.ComposeValidators( survey.Required, ))); err != nil { @@ -142,19 +142,3 @@ func PromptMissing(opts *CreateOptions) error { return nil } - -// cc repurpose this for confirmation check! -// promptBool shows a yes/no prompt to the user for boolean values -func promptBool(opts *CreateOptions, value *bool, defaultValue bool, message string, help string) (bool, error) { - // If the value is already different from default, don't prompt (it was set via flag) - if *value != defaultValue { - return *value, nil - } - // Show interactive confirmation prompt - err := opts.Ask(&survey.Confirm{ - Message: message, // The question to display - Help: help, // Help text explaining the option - Default: defaultValue, // Default value if user just presses Enter - }, value) - return *value, err // Return the user's choice and any error -} diff --git a/pkg/cmd/ephemeralenvironment/create/create_test.go b/pkg/cmd/ephemeralenvironment/create/create_test.go new file mode 100644 index 00000000..10d008f0 --- /dev/null +++ b/pkg/cmd/ephemeralenvironment/create/create_test.go @@ -0,0 +1,56 @@ +package create_test + +import ( + "testing" + + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/create" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/stretchr/testify/assert" +) + +func TestPromptMissing_AllOptionsSupplied(t *testing.T) { + pa := []*testutil.PA{} + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + flags := create.NewCreateFlags() + flags.Name.Value = "Hello Ephemeral Environment" + flags.Project.Value = "Hello Project" + + opts := &create.CreateOptions{ + CreateFlags: flags, + Dependencies: &cmd.Dependencies{Ask: asker}, + // GetAllSpacesCallback: func() ([]*spaces.Space, error) { + // return []*spaces.Space{ + // spaces.NewSpace("Explored space")}, nil + // }, + } + + // Verify that no unexpected prompts were triggered + create.PromptMissing(opts) + checkRemainingPrompts() +} + +func TestPromptMissing_NoOptionsSupplied(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewInputPrompt("Name", "A short, memorable, unique name for this ephemeral environment.", "Hello Ephemeral Environment"), + testutil.NewInputPrompt("Project Name", "The name of the project to associate the ephemeral environment with.", "Hello Project"), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + flags := create.NewCreateFlags() + + opts := &create.CreateOptions{ + CreateFlags: flags, + Dependencies: &cmd.Dependencies{Ask: asker}, + } + + create.PromptMissing(opts) + + // Verify that all expected prompts were called + checkRemainingPrompts() + assert.Equal(t, "Hello Ephemeral Environment", flags.Name.Value) + assert.Equal(t, "Hello Project", flags.Project.Value) +} From 595a06adb95f103707108648de6cf617f0067d98 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 11:19:41 +1000 Subject: [PATCH 05/15] First pass at project select --- pkg/cmd/ephemeralenvironment/create/create.go | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 920c3ca2..7efff6c1 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -4,17 +4,20 @@ package create import ( "fmt" - "github.com/AlecAivazis/survey/v2" // Interactive prompts for user input + // "github.com/AlecAivazis/survey/v2" // Interactive prompts for user input "github.com/MakeNowJust/heredoc/v2" // Multi-line string formatting "github.com/OctopusDeploy/cli/pkg/cmd" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/output" // Output formatting utilities "github.com/OctopusDeploy/cli/pkg/question" // Common question prompts + "github.com/OctopusDeploy/cli/pkg/question/selectors" // Project selectors "github.com/OctopusDeploy/cli/pkg/util/flag" // Flag management utilities "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" // Octopus Deploy environments API "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" // Octopus Deploy projects API "github.com/spf13/cobra" // Command-line interface framework + + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" // For GetAllProjects function ) // Constants defining the command-line flag names @@ -39,15 +42,17 @@ func NewCreateFlags() *CreateFlags { // CreateOptions combines the command flags with common dependencies like API client and output type CreateOptions struct { - *CreateFlags // Embeds all the command flags - *cmd.Dependencies // Embeds common dependencies (API client, output writer, etc.) + *CreateFlags // Embeds all the command flags + *cmd.Dependencies // Embeds common dependencies (API client, output writer, etc.) + GetAllProjectsCallback func() ([]*projects.Project, error) // Callback to retrieve all projects } // NewCreateOptions creates a new CreateOptions struct with the provided flags and dependencies func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { return &CreateOptions{ - CreateFlags: createFlags, // Store the command flags - Dependencies: dependencies, // Store the common dependencies + CreateFlags: createFlags, // Store the command flags + Dependencies: dependencies, // Store the common dependencies + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, } } @@ -130,14 +135,11 @@ func PromptMissing(opts *CreateOptions) error { // Ask for project name if not provided if opts.Project.Value == "" { - if err := opts.Ask(&survey.Input{ - Message: "Project Name", - Help: "The name of the project to associate the ephemeral environment with.", - }, &opts.Project.Value, survey.WithValidator(survey.ComposeValidators( - survey.Required, - ))); err != nil { + project, err := selectors.Select(opts.Ask, "Select the project to associate the ephemeral environment with:", opts.GetAllProjectsCallback, func(project *projects.Project) string { return project.GetName() }) + if err != nil { return err } + opts.Project.Value = project.GetName() } return nil From 211c7857fd46c2c05e51244103ca513214ea6704 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 11:41:51 +1000 Subject: [PATCH 06/15] Adjust tests for project select --- .../create/create_test.go | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create_test.go b/pkg/cmd/ephemeralenvironment/create/create_test.go index 10d008f0..dcf1a1b0 100644 --- a/pkg/cmd/ephemeralenvironment/create/create_test.go +++ b/pkg/cmd/ephemeralenvironment/create/create_test.go @@ -5,11 +5,17 @@ import ( "github.com/OctopusDeploy/cli/pkg/cmd" "github.com/OctopusDeploy/cli/pkg/cmd/ephemeralenvironment/create" + "github.com/OctopusDeploy/cli/test/fixtures" "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/stretchr/testify/assert" ) func TestPromptMissing_AllOptionsSupplied(t *testing.T) { + + project1 := fixtures.NewProject("Spaces-1", "Projects-1", "Test1", "Lifecycles-1", "ProjectGroups-1", "DeploymentProcesses-1") + project2 := fixtures.NewProject("Spaces-1", "Projects-2", "Test2", "Lifecycles-1", "ProjectGroups-1", "DeploymentProcesses-2") + pa := []*testutil.PA{} asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) @@ -21,10 +27,9 @@ func TestPromptMissing_AllOptionsSupplied(t *testing.T) { opts := &create.CreateOptions{ CreateFlags: flags, Dependencies: &cmd.Dependencies{Ask: asker}, - // GetAllSpacesCallback: func() ([]*spaces.Space, error) { - // return []*spaces.Space{ - // spaces.NewSpace("Explored space")}, nil - // }, + } + opts.GetAllProjectsCallback = func() ([]*projects.Project, error) { + return []*projects.Project{project1, project2}, nil } // Verify that no unexpected prompts were triggered @@ -33,9 +38,12 @@ func TestPromptMissing_AllOptionsSupplied(t *testing.T) { } func TestPromptMissing_NoOptionsSupplied(t *testing.T) { + project1 := fixtures.NewProject("Spaces-1", "Projects-1", "Hello Project 1", "Lifecycles-1", "ProjectGroups-1", "DeploymentProcesses-1") + project2 := fixtures.NewProject("Spaces-1", "Projects-2", "Hello Project 2", "Lifecycles-1", "ProjectGroups-1", "DeploymentProcesses-2") + pa := []*testutil.PA{ testutil.NewInputPrompt("Name", "A short, memorable, unique name for this ephemeral environment.", "Hello Ephemeral Environment"), - testutil.NewInputPrompt("Project Name", "The name of the project to associate the ephemeral environment with.", "Hello Project"), + testutil.NewSelectPrompt("Select the project to associate the ephemeral environment with:", "", []string{project1.Name, project2.Name}, project1.Name), } asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) @@ -45,6 +53,9 @@ func TestPromptMissing_NoOptionsSupplied(t *testing.T) { opts := &create.CreateOptions{ CreateFlags: flags, Dependencies: &cmd.Dependencies{Ask: asker}, + GetAllProjectsCallback: func() ([]*projects.Project, error) { + return []*projects.Project{project1, project2}, nil + }, } create.PromptMissing(opts) @@ -52,5 +63,5 @@ func TestPromptMissing_NoOptionsSupplied(t *testing.T) { // Verify that all expected prompts were called checkRemainingPrompts() assert.Equal(t, "Hello Ephemeral Environment", flags.Name.Value) - assert.Equal(t, "Hello Project", flags.Project.Value) + assert.Equal(t, project1.Name, flags.Project.Value) } From b993b7c3fd0e3f90ab835417868a4eee3e961a64 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 12:01:42 +1000 Subject: [PATCH 07/15] Remove additional comments --- pkg/cmd/ephemeralenvironment/create/create.go | 92 ++++++++----------- .../create/create_test.go | 4 +- 2 files changed, 38 insertions(+), 58 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 7efff6c1..643374c8 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -1,90 +1,78 @@ -// Package create handles creating new environments in Octopus Deploy package create import ( "fmt" - // "github.com/AlecAivazis/survey/v2" // Interactive prompts for user input - "github.com/MakeNowJust/heredoc/v2" // Multi-line string formatting + "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/factory" - "github.com/OctopusDeploy/cli/pkg/output" // Output formatting utilities - "github.com/OctopusDeploy/cli/pkg/question" // Common question prompts - "github.com/OctopusDeploy/cli/pkg/question/selectors" // Project selectors - "github.com/OctopusDeploy/cli/pkg/util/flag" // Flag management utilities - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" // Octopus Deploy environments API - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" // Octopus Deploy projects API - "github.com/spf13/cobra" // Command-line interface framework - - "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" // For GetAllProjects function + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" ) -// Constants defining the command-line flag names const ( FlagName = "name" FlagProject = "project" ) -// CreateFlags holds all the command-line flags for the create command type CreateFlags struct { - Name *flag.Flag[string] // Environment name - Project *flag.Flag[string] // Project name + Name *flag.Flag[string] + Project *flag.Flag[string] } -// NewCreateFlags creates and initializes a new CreateFlags struct with default values func NewCreateFlags() *CreateFlags { return &CreateFlags{ - Name: flag.New[string](FlagName, false), // Create string flag for name - Project: flag.New[string](FlagProject, false), // Create string flag for project + Name: flag.New[string](FlagName, false), + Project: flag.New[string](FlagProject, false), } } -// CreateOptions combines the command flags with common dependencies like API client and output type CreateOptions struct { - *CreateFlags // Embeds all the command flags - *cmd.Dependencies // Embeds common dependencies (API client, output writer, etc.) - GetAllProjectsCallback func() ([]*projects.Project, error) // Callback to retrieve all projects + *CreateFlags + *cmd.Dependencies + GetAllProjectsCallback func() ([]*projects.Project, error) } -// NewCreateOptions creates a new CreateOptions struct with the provided flags and dependencies func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { return &CreateOptions{ - CreateFlags: createFlags, // Store the command flags - Dependencies: dependencies, // Store the common dependencies + CreateFlags: createFlags, + Dependencies: dependencies, GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, } } -// NewCmdCreate creates the cobra command for creating environments func NewCmdCreate(f factory.Factory) *cobra.Command { - createFlags := NewCreateFlags() // Initialize the command flags + createFlags := NewCreateFlags() - // Create the cobra command with its configuration cmd := &cobra.Command{ - Use: "create", // Command name - Short: "Create an ephemeral environment", // Short description - Long: "Create an ephemeral environment in Octopus Deploy", // Long description - Example: heredoc.Docf("$ %s ephemeralenvironment create", constants.ExecutableName), // Usage example - Aliases: []string{"new"}, // Alternative command names - RunE: func(c *cobra.Command, _ []string) error { // Function to run when command is executed - opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) // Create options with flags and dependencies - - return createRun(opts) // Execute the main create logic + Use: "create", + Short: "Create an ephemeral environment", + Long: "Create an ephemeral environment in Octopus Deploy", + Example: heredoc.Docf("$ %s ephemeralenvironment create", constants.ExecutableName), + Aliases: []string{"new"}, + RunE: func(c *cobra.Command, _ []string) error { + opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) + + return createRun(opts) }, } - // Set up command-line flags flags := cmd.Flags() - flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "Name of the environment") // -n, --name flag - flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name of the project") // -p, --project flag + flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "Name of the environment") + flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name of the project") - return cmd // Return the configured command + return cmd } -// createRun contains the main logic for creating an environment func createRun(opts *CreateOptions) error { - // If prompting is enabled, ask user for any missing values + if !opts.NoPrompt { err := PromptMissing(opts) if err != nil { @@ -92,48 +80,40 @@ func createRun(opts *CreateOptions) error { } } - // Get the project by name to retrieve its ID projectResource, err := projects.GetByName(opts.Client, opts.Space.ID, opts.Project.Value) if err != nil { return fmt.Errorf("failed to find project '%s': %w", opts.Project.Value, err) } - // Update the project value to use the ID instead of name for the API call projectId := projectResource.GetID() - // Create a new environment command and send to Octopus deploy createEnv, err := ephemeralenvironments.Add(opts.Client, opts.Space.ID, projectId, opts.Name.Value) if err != nil { return err } - // Print success message with environment name and ID _, err = fmt.Fprintf(opts.Out, "\nSuccessfully created ephemeral environment '%s` with id '%s'.\n", opts.Name.Value, createEnv.Id) if err != nil { return err } - // Generate and display a clickable link to view the environment in Octopus Deploy web UI - link := output.Bluef("%s/app#/%s/projects/%s/ephemeral-environments", opts.Host, opts.Space.GetID(), projectId) // cc check link works after you fix project id/name issue - fmt.Fprintf(opts.Out, "View this ephemeral environments for project `%s` on Octopus Deploy: %s\n", opts.Project.Value, link) // cc fix text to show project name + link := output.Bluef("%s/app#/%s/projects/%s/ephemeral-environments", opts.Host, opts.Space.GetID(), projectId) + fmt.Fprintf(opts.Out, "View this ephemeral environments for project `%s` on Octopus Deploy: %s\n", opts.Project.Value, link) - // If prompting is enabled, show the equivalent automation command for future use if !opts.NoPrompt { autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.Project) fmt.Fprintf(opts.Out, "%s\n", autoCmd) } - return nil // Success - no error + return nil } -// PromptMissing prompts the user for any missing required values func PromptMissing(opts *CreateOptions) error { - // Ask for environment name if not provided + err := question.AskName(opts.Ask, "", "ephemeral environment", &opts.Name.Value) if err != nil { return err } - // Ask for project name if not provided if opts.Project.Value == "" { project, err := selectors.Select(opts.Ask, "Select the project to associate the ephemeral environment with:", opts.GetAllProjectsCallback, func(project *projects.Project) string { return project.GetName() }) if err != nil { diff --git a/pkg/cmd/ephemeralenvironment/create/create_test.go b/pkg/cmd/ephemeralenvironment/create/create_test.go index dcf1a1b0..e3b1dad1 100644 --- a/pkg/cmd/ephemeralenvironment/create/create_test.go +++ b/pkg/cmd/ephemeralenvironment/create/create_test.go @@ -32,7 +32,7 @@ func TestPromptMissing_AllOptionsSupplied(t *testing.T) { return []*projects.Project{project1, project2}, nil } - // Verify that no unexpected prompts were triggered + // Check that no unexpected prompts were triggered create.PromptMissing(opts) checkRemainingPrompts() } @@ -60,7 +60,7 @@ func TestPromptMissing_NoOptionsSupplied(t *testing.T) { create.PromptMissing(opts) - // Verify that all expected prompts were called + // Check that all expected prompts were called checkRemainingPrompts() assert.Equal(t, "Hello Ephemeral Environment", flags.Name.Value) assert.Equal(t, project1.Name, flags.Project.Value) From 7c9ed9997bb4000378486252862bcf941673a256 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 12:09:41 +1000 Subject: [PATCH 08/15] Change command name --- pkg/cmd/ephemeralenvironment/create/create.go | 6 +++--- pkg/cmd/ephemeralenvironment/ephemeralenvironment.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 643374c8..0db050ff 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -55,7 +55,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { Use: "create", Short: "Create an ephemeral environment", Long: "Create an ephemeral environment in Octopus Deploy", - Example: heredoc.Docf("$ %s ephemeralenvironment create", constants.ExecutableName), + Example: heredoc.Docf("$ %s ephemeral-environment create", constants.ExecutableName), Aliases: []string{"new"}, RunE: func(c *cobra.Command, _ []string) error { opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) @@ -91,13 +91,13 @@ func createRun(opts *CreateOptions) error { return err } - _, err = fmt.Fprintf(opts.Out, "\nSuccessfully created ephemeral environment '%s` with id '%s'.\n", opts.Name.Value, createEnv.Id) + _, err = fmt.Fprintf(opts.Out, "\nSuccessfully created ephemeral environment '%s' with id '%s'.\n", opts.Name.Value, createEnv.Id) if err != nil { return err } link := output.Bluef("%s/app#/%s/projects/%s/ephemeral-environments", opts.Host, opts.Space.GetID(), projectId) - fmt.Fprintf(opts.Out, "View this ephemeral environments for project `%s` on Octopus Deploy: %s\n", opts.Project.Value, link) + fmt.Fprintf(opts.Out, "View this ephemeral environment for project `%s` on Octopus Deploy: %s\n", opts.Project.Value, link) if !opts.NoPrompt { autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Name, opts.Project) diff --git a/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go index 248fa7f2..9e7214fa 100644 --- a/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go +++ b/pkg/cmd/ephemeralenvironment/ephemeralenvironment.go @@ -11,11 +11,11 @@ import ( func NewCmdEphemeralEnvironment(f factory.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "ephemeralenvironment ", + Use: "ephemeral-environment ", Short: "Manage ephemeral environments", Long: "Manage ephemeral environments in Octopus Deploy", Example: heredoc.Docf(` - $ %[1]s ephemeralenvironment create --name "EE1" --project "MyProject" + $ %[1]s ephemeral-environment create --name "MyEphemeralEnvironment" --project "MyProject" `, constants.ExecutableName), Annotations: map[string]string{ annotations.IsInfrastructure: "true", From 9bcbe23597c94914edcf9e37405f07779f95f725 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 15:05:16 +1000 Subject: [PATCH 09/15] Show only EE configured projects --- go.mod | 2 +- go.sum | 2 + pkg/cmd/ephemeralenvironment/create/create.go | 37 ++++++++++++++++--- .../create/create_test.go | 6 +-- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 9e638d9e..cde48034 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251104024444-b241d15475fa github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 7b276a35..bd6ac843 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2 h1:dBTkP2Uxxn/gGOC0i0RPQzyJ github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21 h1:8YVMZEu/U8sLWuI1M76e1esEr9yz4ctK379o4O8oH2M= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251104024444-b241d15475fa h1:uyVnPhN939uHyXXW2WbBwTvcc4xtd0X4peum1TXfUJo= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251104024444-b241d15475fa/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 0db050ff..4a91ce29 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -37,17 +37,44 @@ func NewCreateFlags() *CreateFlags { type CreateOptions struct { *CreateFlags *cmd.Dependencies - GetAllProjectsCallback func() ([]*projects.Project, error) + GetConfiguredProjectsCallback func() ([]*projects.Project, error) } func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { return &CreateOptions{ - CreateFlags: createFlags, - Dependencies: dependencies, - GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, + CreateFlags: createFlags, + Dependencies: dependencies, + GetConfiguredProjectsCallback: func() ([]*projects.Project, error) { + return getProjectsWithEphemeralEnvironmentChannels(dependencies) + }, } } +func getProjectsWithEphemeralEnvironmentChannels(dependencies *cmd.Dependencies) ([]*projects.Project, error) { + allProjects, err := shared.GetAllProjects(dependencies.Client) + if err != nil { + return nil, err + } + + var filteredProjects []*projects.Project + + for _, project := range allProjects { + projectChannels, err := dependencies.Client.Projects.GetChannels(project) + if err != nil { + return nil, fmt.Errorf("failed to get channels for project '%s': %w", project.GetName(), err) + } + + for _, channel := range projectChannels { + if channel.Type == "EphemeralEnvironment" { + filteredProjects = append(filteredProjects, project) + break + } + } + } + + return filteredProjects, nil +} + func NewCmdCreate(f factory.Factory) *cobra.Command { createFlags := NewCreateFlags() @@ -115,7 +142,7 @@ func PromptMissing(opts *CreateOptions) error { } if opts.Project.Value == "" { - project, err := selectors.Select(opts.Ask, "Select the project to associate the ephemeral environment with:", opts.GetAllProjectsCallback, func(project *projects.Project) string { return project.GetName() }) + project, err := selectors.Select(opts.Ask, "Select an ephemeral environments configured project to associate with the environment:", opts.GetConfiguredProjectsCallback, func(project *projects.Project) string { return project.GetName() }) if err != nil { return err } diff --git a/pkg/cmd/ephemeralenvironment/create/create_test.go b/pkg/cmd/ephemeralenvironment/create/create_test.go index e3b1dad1..4e270c1b 100644 --- a/pkg/cmd/ephemeralenvironment/create/create_test.go +++ b/pkg/cmd/ephemeralenvironment/create/create_test.go @@ -28,7 +28,7 @@ func TestPromptMissing_AllOptionsSupplied(t *testing.T) { CreateFlags: flags, Dependencies: &cmd.Dependencies{Ask: asker}, } - opts.GetAllProjectsCallback = func() ([]*projects.Project, error) { + opts.GetConfiguredProjectsCallback = func() ([]*projects.Project, error) { return []*projects.Project{project1, project2}, nil } @@ -43,7 +43,7 @@ func TestPromptMissing_NoOptionsSupplied(t *testing.T) { pa := []*testutil.PA{ testutil.NewInputPrompt("Name", "A short, memorable, unique name for this ephemeral environment.", "Hello Ephemeral Environment"), - testutil.NewSelectPrompt("Select the project to associate the ephemeral environment with:", "", []string{project1.Name, project2.Name}, project1.Name), + testutil.NewSelectPrompt("Select an ephemeral environments configured project to associate with the environment:", "", []string{project1.Name, project2.Name}, project1.Name), } asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) @@ -53,7 +53,7 @@ func TestPromptMissing_NoOptionsSupplied(t *testing.T) { opts := &create.CreateOptions{ CreateFlags: flags, Dependencies: &cmd.Dependencies{Ask: asker}, - GetAllProjectsCallback: func() ([]*projects.Project, error) { + GetConfiguredProjectsCallback: func() ([]*projects.Project, error) { return []*projects.Project{project1, project2}, nil }, } From 9c7d0f5d5c8698cd57fb5489b1b4088eb0c4d2d1 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 15:29:18 +1000 Subject: [PATCH 10/15] Improve code to check for configured projects --- pkg/cmd/ephemeralenvironment/create/create.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 4a91ce29..99dc2a0c 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -2,6 +2,7 @@ package create import ( "fmt" + "slices" "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/cmd" @@ -12,6 +13,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/cli/pkg/question/selectors" "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/spf13/cobra" @@ -45,12 +47,12 @@ func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) CreateFlags: createFlags, Dependencies: dependencies, GetConfiguredProjectsCallback: func() ([]*projects.Project, error) { - return getProjectsWithEphemeralEnvironmentChannels(dependencies) + return getConfiguredProjects(dependencies) }, } } -func getProjectsWithEphemeralEnvironmentChannels(dependencies *cmd.Dependencies) ([]*projects.Project, error) { +func getConfiguredProjects(dependencies *cmd.Dependencies) ([]*projects.Project, error) { allProjects, err := shared.GetAllProjects(dependencies.Client) if err != nil { return nil, err @@ -64,11 +66,10 @@ func getProjectsWithEphemeralEnvironmentChannels(dependencies *cmd.Dependencies) return nil, fmt.Errorf("failed to get channels for project '%s': %w", project.GetName(), err) } - for _, channel := range projectChannels { - if channel.Type == "EphemeralEnvironment" { - filteredProjects = append(filteredProjects, project) - break - } + if slices.ContainsFunc(projectChannels, func(channel *channels.Channel) bool { + return channel.Type == "EphemeralEnvironment" + }) { + filteredProjects = append(filteredProjects, project) } } From 7be1f45d4a27eb66853dcd10f2888fc5334760c7 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Tue, 4 Nov 2025 15:35:04 +1000 Subject: [PATCH 11/15] Improve error messaging when no projects configured --- pkg/cmd/ephemeralenvironment/create/create.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 99dc2a0c..1bcfb56b 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -73,6 +73,10 @@ func getConfiguredProjects(dependencies *cmd.Dependencies) ([]*projects.Project, } } + if len(filteredProjects) == 0 { + return nil, fmt.Errorf("no configured projects - configure a project with an ephemeral environment channel before creating an ephemeral environment") + } + return filteredProjects, nil } From fbce6c502b792e9a117628b7c741f75466e42a23 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Wed, 5 Nov 2025 11:24:59 +1000 Subject: [PATCH 12/15] Update to latest release of octo go api client --- go.mod | 2 +- go.sum | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/go.mod b/go.mod index cde48034..f491d657 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251104024444-b241d15475fa + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index bd6ac843..03532249 100644 --- a/go.sum +++ b/go.sum @@ -48,10 +48,6 @@ github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4 github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2 h1:dBTkP2Uxxn/gGOC0i0RPQzyJOqmL1Sv/ntja2u6Jhwo= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21 h1:8YVMZEu/U8sLWuI1M76e1esEr9yz4ctK379o4O8oH2M= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251104024444-b241d15475fa h1:uyVnPhN939uHyXXW2WbBwTvcc4xtd0X4peum1TXfUJo= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251104024444-b241d15475fa/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= From 2944b313ae1f901df1ed567043612934d6e585d7 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Wed, 5 Nov 2025 11:25:23 +1000 Subject: [PATCH 13/15] Update to latest release of octo go api client --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f491d657..d32a79d2 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 03532249..35eb3a5a 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4VDhOQ0Ven0= github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2 h1:dBTkP2Uxxn/gGOC0i0RPQzyJOqmL1Sv/ntja2u6Jhwo= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0 h1:yGoohDFkruQ13mxoK21J+U+IUkxoU5jSxGEpknW/E5Y= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.86.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= From dcc17fbf5928f7d637bded5775903585890f1685 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Wed, 5 Nov 2025 14:07:50 +1000 Subject: [PATCH 14/15] Improve project prompt text --- pkg/cmd/ephemeralenvironment/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 1bcfb56b..85e3f54b 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -147,7 +147,7 @@ func PromptMissing(opts *CreateOptions) error { } if opts.Project.Value == "" { - project, err := selectors.Select(opts.Ask, "Select an ephemeral environments configured project to associate with the environment:", opts.GetConfiguredProjectsCallback, func(project *projects.Project) string { return project.GetName() }) + project, err := selectors.Select(opts.Ask, "Select a project: \n(Showing only projects configured with an ephemeral environment channel.).", opts.GetConfiguredProjectsCallback, func(project *projects.Project) string { return project.GetName() }) if err != nil { return err } From 73469edbb650b46be2a27dd0fa1ad90609cf8100 Mon Sep 17 00:00:00 2001 From: CaitlynStocker Date: Wed, 5 Nov 2025 14:58:22 +1000 Subject: [PATCH 15/15] Clarify project prompt --- pkg/cmd/ephemeralenvironment/create/create.go | 3 ++- pkg/cmd/ephemeralenvironment/create/create_test.go | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/ephemeralenvironment/create/create.go b/pkg/cmd/ephemeralenvironment/create/create.go index 85e3f54b..7e28fa66 100644 --- a/pkg/cmd/ephemeralenvironment/create/create.go +++ b/pkg/cmd/ephemeralenvironment/create/create.go @@ -147,7 +147,8 @@ func PromptMissing(opts *CreateOptions) error { } if opts.Project.Value == "" { - project, err := selectors.Select(opts.Ask, "Select a project: \n(Showing only projects configured with an ephemeral environment channel.).", opts.GetConfiguredProjectsCallback, func(project *projects.Project) string { return project.GetName() }) + fmt.Fprintf(opts.Out, " Choose from projects configured with an ephemeral environment channel.\n") + project, err := selectors.Select(opts.Ask, "Select a project:", opts.GetConfiguredProjectsCallback, func(project *projects.Project) string { return project.GetName() }) if err != nil { return err } diff --git a/pkg/cmd/ephemeralenvironment/create/create_test.go b/pkg/cmd/ephemeralenvironment/create/create_test.go index 4e270c1b..00a299b5 100644 --- a/pkg/cmd/ephemeralenvironment/create/create_test.go +++ b/pkg/cmd/ephemeralenvironment/create/create_test.go @@ -1,6 +1,7 @@ package create_test import ( + "bytes" "testing" "github.com/OctopusDeploy/cli/pkg/cmd" @@ -43,7 +44,7 @@ func TestPromptMissing_NoOptionsSupplied(t *testing.T) { pa := []*testutil.PA{ testutil.NewInputPrompt("Name", "A short, memorable, unique name for this ephemeral environment.", "Hello Ephemeral Environment"), - testutil.NewSelectPrompt("Select an ephemeral environments configured project to associate with the environment:", "", []string{project1.Name, project2.Name}, project1.Name), + testutil.NewSelectPrompt("Select a project:", "", []string{project1.Name, project2.Name}, project1.Name), } asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) @@ -52,7 +53,7 @@ func TestPromptMissing_NoOptionsSupplied(t *testing.T) { opts := &create.CreateOptions{ CreateFlags: flags, - Dependencies: &cmd.Dependencies{Ask: asker}, + Dependencies: &cmd.Dependencies{Ask: asker, Out: &bytes.Buffer{}}, GetConfiguredProjectsCallback: func() ([]*projects.Project, error) { return []*projects.Project{project1, project2}, nil },