From fe37eb7e24f0fb3acd46605957aa0ff3bccd6b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 13:17:02 +0100 Subject: [PATCH 01/28] feat(app): improve environment-aware autocomplete --- internal/app/api.go | 43 ++++++++ internal/app/command/flag/flag.go | 178 ++++++++++++++++++++++++++++-- internal/app/command/issues.go | 9 +- internal/app/command/restart.go | 24 ++++ internal/naisapi/gql/generated.go | 136 +++++++++++++++++++++++ 5 files changed, 381 insertions(+), 9 deletions(-) diff --git a/internal/app/api.go b/internal/app/api.go index a30c56b9..46457c0e 100644 --- a/internal/app/api.go +++ b/internal/app/api.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "sort" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -109,3 +110,45 @@ func ApplicationEnvironments(ctx context.Context, team, appName string) ([]strin } return ret, nil } + +func TeamApplicationEnvironments(ctx context.Context, team string) ([]string, error) { + _ = `# @genqlient + query TeamApplicationEnvironments($team: Slug!) { + team(slug: $team) { + applications(first: 1000) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } + } + ` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.TeamApplicationEnvironments(ctx, client, team) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, node := range resp.Team.Applications.Nodes { + env := node.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index b25daca2..3a269540 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -3,6 +3,9 @@ package flag import ( "context" "fmt" + "os" + "reflect" + "strings" "time" activityutil "github.com/nais/cli/internal/activity" @@ -20,11 +23,166 @@ type App struct { type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - envs, err := naisapi.GetAllEnvironments(ctx) - if err != nil { - return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) + team := appTeamFromFlags(flags) + if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + team = cliTeam } - return envs, "Available environments" + if team == "" { + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + } + + if team != "" { + if appName := appNameForEnvironmentCompletion(args); appName != "" { + envs, err := app.ApplicationEnvironments(ctx, team, appName) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + } + + envs, err := app.TeamApplicationEnvironments(ctx, team) + if err != nil { + return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) + } + if len(envs) > 0 { + return envs, "Available environments" + } + + return nil, "No environments with applications found for this team." + } + + return nil, "No environments available" +} + +func appTeamFromFlags(flags any) string { + switch f := flags.(type) { + case *Activity: + if f.App != nil && f.App.Team != "" { + return string(f.App.Team) + } + return string(f.Team) + case *Issues: + if f.App != nil && f.App.Team != "" { + return string(f.App.Team) + } + return string(f.Team) + case *List: + if f.App != nil && f.App.Team != "" { + return string(f.App.Team) + } + return string(f.Team) + case *Restart: + return string(f.Team) + case *App: + return string(f.Team) + default: + v := reflect.ValueOf(flags) + if !v.IsValid() { + return "" + } + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return "" + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return "" + } + + teamField := v.FieldByName("Team") + if teamField.IsValid() && teamField.Kind() == reflect.String { + return teamField.String() + } + + return "" + } +} + +func appNameForEnvironmentCompletion(args *naistrix.Arguments) string { + if args.Len() > 0 { + if name := args.Get("name"); name != "" { + return name + } + } + + // Some command/flag combinations (for example app restart with parent sticky flags) + // do not expose positional args through naistrix during completion. + if !isRestartCompletionFromCLIArgs() { + return "" + } + + return appNameFromCLIArgs(os.Args) +} + +func isRestartCompletionFromCLIArgs() bool { + for _, arg := range os.Args { + if arg == "restart" { + return true + } + } + return false +} + +func appNameFromCLIArgs(argv []string) string { + seenRestart := false + + for i := 0; i < len(argv); i++ { + arg := argv[i] + + if arg == "restart" { + seenRestart = true + continue + } + if !seenRestart { + continue + } + + if arg == "--" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + + if strings.HasPrefix(arg, "--team=") || strings.HasPrefix(arg, "--environment=") || strings.HasPrefix(arg, "--config=") { + continue + } + + if arg == "-t" || arg == "--team" || arg == "-e" || arg == "--environment" || arg == "--config" { + i++ + continue + } + + if strings.HasPrefix(arg, "-") { + continue + } + + return arg + } + + return "" +} + +func teamFromCLIArgs(argv []string) string { + for i := 0; i < len(argv); i++ { + arg := argv[i] + + if strings.HasPrefix(arg, "--team=") { + return strings.TrimPrefix(arg, "--team=") + } + if strings.HasPrefix(arg, "-t=") { + return strings.TrimPrefix(arg, "-t=") + } + + if arg == "-t" || arg == "--team" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + } + + return "" } type instances []string @@ -87,15 +245,19 @@ func (a *ActivityTypes) AutoComplete(context.Context, *naistrix.Arguments, strin type Env string func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - if args.Len() == 0 { - return autoCompleteEnvironments(ctx) - } - f := flags.(*Log) if len(f.Team) == 0 { return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." } + if args.Len() == 0 { + envs, err := app.TeamApplicationEnvironments(ctx, f.Team) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + return autoCompleteEnvironments(ctx) + } + envs, err := app.ApplicationEnvironments(ctx, f.Team, args.Get("name")) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) diff --git a/internal/app/command/issues.go b/internal/app/command/issues.go index cd9c44a4..425de0f0 100644 --- a/internal/app/command/issues.go +++ b/internal/app/command/issues.go @@ -2,9 +2,11 @@ package command import ( "context" + "os" "github.com/nais/cli/internal/app" "github.com/nais/cli/internal/app/command/flag" + "github.com/nais/cli/internal/cliflags" "github.com/nais/naistrix" "github.com/nais/naistrix/output" ) @@ -42,7 +44,12 @@ func issues(parentFlags *flag.App) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete application names. 'nais config set team ', or '--team ' flag." } - apps, err := app.GetApplicationNames(ctx, flags.Team, flags.Environment) + environments := []string(flags.Environment) + if len(environments) == 0 { + environments = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + + apps, err := app.GetApplicationNames(ctx, flags.Team, environments) if err != nil { return nil, "Unable to fetch application names." } diff --git a/internal/app/command/restart.go b/internal/app/command/restart.go index 7b09c2ae..e0a202b4 100644 --- a/internal/app/command/restart.go +++ b/internal/app/command/restart.go @@ -2,9 +2,12 @@ package command import ( "context" + "fmt" + "os" "github.com/nais/cli/internal/app" "github.com/nais/cli/internal/app/command/flag" + "github.com/nais/cli/internal/cliflags" "github.com/nais/naistrix" ) @@ -20,6 +23,27 @@ func restart(parentFlags *flag.App) *naistrix.Command { Args: []naistrix.Argument{ {Name: "name"}, }, + AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { + if args.Len() != 0 { + return nil, "" + } + if len(flags.Team) == 0 { + return nil, "Please provide team to auto-complete application names. 'nais config set team ', or '--team ' flag." + } + envs := flags.Environment + if len(envs) != 1 { + envs = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + if len(envs) != 1 { + return nil, "Please provide exactly one environment to auto-complete application names. '--environment ' flag." + } + + apps, err := app.GetApplicationNames(ctx, flags.Team, envs) + if err != nil { + return nil, fmt.Sprintf("Unable to fetch application names: %v", err) + } + return apps, "Select an application." + }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { ret, err := app.RestartApp(ctx, flags.Team, args.Get("name"), flags.Environment) if err != nil { diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 9176af86..8056212e 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -21956,6 +21956,92 @@ type TailLogResponse struct { // GetLog returns TailLogResponse.Log, and is useful for accessing the field via an interface. func (v *TailLogResponse) GetLog() TailLogLogLogLine { return v.Log } +// TeamApplicationEnvironmentsResponse is returned by TeamApplicationEnvironments on success. +type TeamApplicationEnvironmentsResponse struct { + // Get a team by its slug. + Team TeamApplicationEnvironmentsTeam `json:"team"` +} + +// GetTeam returns TeamApplicationEnvironmentsResponse.Team, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsResponse) GetTeam() TeamApplicationEnvironmentsTeam { + return v.Team +} + +// TeamApplicationEnvironmentsTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type TeamApplicationEnvironmentsTeam struct { + // Nais applications owned by the team. + Applications TeamApplicationEnvironmentsTeamApplicationsApplicationConnection `json:"applications"` +} + +// GetApplications returns TeamApplicationEnvironmentsTeam.Applications, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeam) GetApplications() TeamApplicationEnvironmentsTeamApplicationsApplicationConnection { + return v.Applications +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnection includes the requested fields of the GraphQL type ApplicationConnection. +// The GraphQL type's documentation follows. +// +// Application connection. +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnection struct { + // List of nodes. + Nodes []TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication `json:"nodes"` +} + +// GetNodes returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnection.Nodes, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnection) GetNodes() []TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication { + return v.Nodes +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication includes the requested fields of the GraphQL type Application. +// The GraphQL type's documentation follows. +// +// An application lets you run one or more instances of a container image on the [Nais platform](https://nais.io/). +// +// Learn more about how to create and configure your applications in the [Nais documentation](https://docs.nais.io/workloads/application/). +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication struct { + // The team environment for the application. + TeamEnvironment TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment `json:"teamEnvironment"` +} + +// GetTeamEnvironment returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication) GetTeamEnvironment() TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment { + return v.TeamEnvironment +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment struct { + // Get the environment. + Environment TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment `json:"environment"` +} + +// GetEnvironment returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment) GetEnvironment() TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment { + return v.Environment +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// The GraphQL type's documentation follows. +// +// An environment represents a runtime environment for workloads. +// +// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment struct { + // Unique name of the environment. + Name string `json:"name"` +} + +// GetName returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment) GetName() string { + return v.Name +} + // Input for filtering the applications of a team. type TeamApplicationsFilter struct { // Input for filtering the applications of a team. @@ -24412,6 +24498,14 @@ func (v *__TailLogInput) GetLimit() int { return v.Limit } // GetStart returns __TailLogInput.Start, and is useful for accessing the field via an interface. func (v *__TailLogInput) GetStart() time.Time { return v.Start } +// __TeamApplicationEnvironmentsInput is used internally by genqlient +type __TeamApplicationEnvironmentsInput struct { + Team string `json:"team"` +} + +// GetTeam returns __TeamApplicationEnvironmentsInput.Team, and is useful for accessing the field via an interface. +func (v *__TeamApplicationEnvironmentsInput) GetTeam() string { return v.Team } + // __TeamMembersInput is used internally by genqlient type __TeamMembersInput struct { Slug string `json:"slug"` @@ -26889,6 +26983,48 @@ func TailLogForwardData(interfaceChan interface{}, jsonRawMsg json.RawMessage) e return nil } +// The query executed by TeamApplicationEnvironments. +const TeamApplicationEnvironments_Operation = ` +query TeamApplicationEnvironments ($team: Slug!) { + team(slug: $team) { + applications(first: 1000) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } +} +` + +func TeamApplicationEnvironments( + ctx_ context.Context, + client_ graphql.Client, + team string, +) (data_ *TeamApplicationEnvironmentsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TeamApplicationEnvironments", + Query: TeamApplicationEnvironments_Operation, + Variables: &__TeamApplicationEnvironmentsInput{ + Team: team, + }, + } + + data_ = &TeamApplicationEnvironmentsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by TeamMembers. const TeamMembers_Operation = ` query TeamMembers ($slug: Slug!) { From f2a350dff76a0c452729c5d8f11ab58fac0e26e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 13:49:17 +0100 Subject: [PATCH 02/28] feat(job): improve environment-aware autocomplete --- internal/job/command/flag/flag.go | 185 +++++++++++++++++++++++++++++- internal/job/command/issues.go | 9 +- internal/job/command/log.go | 8 +- internal/job/command/trigger.go | 8 +- internal/job/list.go | 89 ++++++++++++++ 5 files changed, 293 insertions(+), 6 deletions(-) diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 388514cb..dd66b976 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -3,9 +3,13 @@ package flag import ( "context" "fmt" + "os" + "reflect" + "strings" "time" "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/naisapi" "github.com/nais/naistrix" ) @@ -18,11 +22,158 @@ type Job struct { type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - envs, err := naisapi.GetAllEnvironments(ctx) + team := jobTeamFromFlags(flags) + if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + team = cliTeam + } + if team == "" { + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + } + + if jobName := jobNameForEnvironmentCompletion(args); jobName != "" { + envs, err := job.JobEnvironments(ctx, team, jobName) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + } + + envs, err := job.TeamJobEnvironments(ctx, team) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) } - return envs, "Available environments" + if len(envs) > 0 { + return envs, "Available environments" + } + + return nil, "No environments with jobs found for this team." +} + +func jobTeamFromFlags(flags any) string { + switch f := flags.(type) { + case *Activity: + if f.Job != nil && f.Job.Team != "" { + return string(f.Job.Team) + } + return string(f.Team) + case *Issues: + if f.Job != nil && f.Job.Team != "" { + return string(f.Job.Team) + } + return string(f.Team) + case *List: + if f.Job != nil && f.Job.Team != "" { + return string(f.Job.Team) + } + return string(f.Team) + case *Job: + return string(f.Team) + default: + v := reflect.ValueOf(flags) + if !v.IsValid() { + return "" + } + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return "" + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return "" + } + + teamField := v.FieldByName("Team") + if teamField.IsValid() && teamField.Kind() == reflect.String { + return teamField.String() + } + + return "" + } +} + +func jobNameForEnvironmentCompletion(args *naistrix.Arguments) string { + if args.Len() > 0 { + if name := args.Get("name"); name != "" { + return name + } + } + + if !isTriggerCompletionFromCLIArgs() { + return "" + } + + return jobNameFromCLIArgs(os.Args) +} + +func isTriggerCompletionFromCLIArgs() bool { + for _, arg := range os.Args { + if arg == "trigger" { + return true + } + } + return false +} + +func jobNameFromCLIArgs(argv []string) string { + seenTrigger := false + + for i := 0; i < len(argv); i++ { + arg := argv[i] + + if arg == "trigger" { + seenTrigger = true + continue + } + if !seenTrigger { + continue + } + + if arg == "--" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + + if strings.HasPrefix(arg, "--team=") || strings.HasPrefix(arg, "--environment=") || strings.HasPrefix(arg, "--config=") { + continue + } + + if arg == "-t" || arg == "--team" || arg == "-e" || arg == "--environment" || arg == "--config" { + i++ + continue + } + + if strings.HasPrefix(arg, "-") { + continue + } + + return arg + } + + return "" +} + +func teamFromCLIArgs(argv []string) string { + for i := 0; i < len(argv); i++ { + arg := argv[i] + + if strings.HasPrefix(arg, "--team=") { + return strings.TrimPrefix(arg, "--team=") + } + if strings.HasPrefix(arg, "-t=") { + return strings.TrimPrefix(arg, "-t=") + } + + if arg == "-t" || arg == "--team" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + } + + return "" } type Output string @@ -58,7 +209,35 @@ type Trigger struct { type Env string func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - envs, err := naisapi.GetAllEnvironments(ctx) + var team string + switch t := flags.(type) { + case *Log: + team = string(t.Team) + case *Trigger: + team = string(t.Team) + } + if team == "" { + if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + team = cliTeam + } + } + if team == "" { + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + } + + if args.Len() == 0 { + envs, err := job.TeamJobEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + envs, err = naisapi.GetAllEnvironments(ctx) + if err != nil { + return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) + } + return envs, "Available environments" + } + + envs, err := job.JobEnvironments(ctx, team, args.Get("name")) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) } diff --git a/internal/job/command/issues.go b/internal/job/command/issues.go index 3d12bb80..52e0939f 100644 --- a/internal/job/command/issues.go +++ b/internal/job/command/issues.go @@ -2,7 +2,9 @@ package command import ( "context" + "os" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/job/command/flag" "github.com/nais/naistrix" @@ -40,7 +42,12 @@ func issues(parentFlags *flag.Job) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete job names. 'nais config set team ', or '--team ' flag." } - jobs, err := job.GetJobNames(ctx, flags.Team, flags.Environment) + environments := []string(flags.Environment) + if len(environments) == 0 { + environments = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + + jobs, err := job.GetJobNames(ctx, flags.Team, environments) if err != nil { return nil, "Unable to fetch job names." } diff --git a/internal/job/command/log.go b/internal/job/command/log.go index 77c66ef9..afcd29ab 100644 --- a/internal/job/command/log.go +++ b/internal/job/command/log.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "os" "strings" "sync/atomic" "time" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/job/command/flag" logs "github.com/nais/cli/internal/log/command" @@ -99,10 +101,14 @@ func log(parentFlags *flag.Job) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete job names. 'nais config set team ', or '--team ' flag." } + envs := []string{string(flags.Environment)} if flags.Environment == "" { + envs = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + if len(envs) != 1 { return nil, "Please provide environment to auto-complete job names. '--environment ' flag." } - jobs, err := job.GetJobNames(ctx, flags.Team, []string{string(flags.Environment)}) + jobs, err := job.GetJobNames(ctx, flags.Team, envs) if err != nil { return nil, "Unable to fetch job names." } diff --git a/internal/job/command/trigger.go b/internal/job/command/trigger.go index 9460afcd..6ea3cbb5 100644 --- a/internal/job/command/trigger.go +++ b/internal/job/command/trigger.go @@ -3,7 +3,9 @@ package command import ( "context" "fmt" + "os" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/job/command/flag" "github.com/nais/naistrix" @@ -39,10 +41,14 @@ func trigger(parentFlags *flag.Job) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete job names. 'nais config set team ', or '--team ' flag." } + envs := []string{string(flags.Environment)} if flags.Environment == "" { + envs = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + if len(envs) != 1 { return nil, "Please provide environment to auto-complete job names. '--environment ' flag." } - jobs, err := job.GetJobNames(ctx, flags.Team, []string{string(flags.Environment)}) + jobs, err := job.GetJobNames(ctx, flags.Team, envs) if err != nil { return nil, "Unable to fetch job names." } diff --git a/internal/job/list.go b/internal/job/list.go index ec0c9176..a5e5ffdd 100644 --- a/internal/job/list.go +++ b/internal/job/list.go @@ -105,6 +105,95 @@ func GetJobNames(ctx context.Context, team string, environments []string) ([]str return ret, nil } +func TeamJobEnvironments(ctx context.Context, team string) ([]string, error) { + _ = `# @genqlient + query GetJobNames($team: Slug!) { + team(slug: $team) { + jobs(first: 1000) { + nodes { + name + teamEnvironment { + environment { + name + } + } + } + } + } + } + ` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.GetJobNames(ctx, client, team) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, j := range resp.Team.Jobs.Nodes { + env := j.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} + +func JobEnvironments(ctx context.Context, team, jobName string) ([]string, error) { + _ = `# @genqlient + query GetJobNames($team: Slug!) { + team(slug: $team) { + jobs(first: 1000) { + nodes { + name + teamEnvironment { + environment { + name + } + } + } + } + } + } + ` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.GetJobNames(ctx, client, team) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, j := range resp.Team.Jobs.Nodes { + if j.Name != jobName { + continue + } + env := j.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} + func GetTeamJobs(ctx context.Context, team string, environments []string) ([]Job, error) { _ = `# @genqlient query GetTeamJobs($team: Slug!, $orderBy: JobOrder) { From 1ffc108f0012ee7efd21510c53c44f6c5f165320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 14:11:50 +0100 Subject: [PATCH 03/28] fix(issues): add -e shorthand for environment filter --- internal/issues/command/flag/flag.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/issues/command/flag/flag.go b/internal/issues/command/flag/flag.go index 1f34017b..6fb0f3bf 100644 --- a/internal/issues/command/flag/flag.go +++ b/internal/issues/command/flag/flag.go @@ -33,7 +33,7 @@ func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) type List struct { *Issues - Environment Environment `name:"environment" usage:"Filter issues by environment"` + Environment Environment `name:"environment" short:"e" usage:"Filter issues by environment"` IssueType IssueType `name:"issuetype" usage:"Filter issues by issue type"` ResourceName ResourceName `name:"resourcename" usage:"Filter issues by resource name"` ResourceType ResourceType `name:"resourcetype" usage:"Filter issues by resource type"` From 2c189e2b32f806ffee1224df49d02b662f322abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 14:23:29 +0100 Subject: [PATCH 04/28] feat(kafka): filter environment autocomplete by topics --- internal/kafka/command/flag/flag.go | 49 +++++++++++++++++++++++++++++ internal/kafka/kafka.go | 43 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/internal/kafka/command/flag/flag.go b/internal/kafka/command/flag/flag.go index a0b9f668..6c077118 100644 --- a/internal/kafka/command/flag/flag.go +++ b/internal/kafka/command/flag/flag.go @@ -3,8 +3,11 @@ package flag import ( "context" "fmt" + "os" + "strings" "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/kafka" "github.com/nais/cli/internal/naisapi" "github.com/nais/naistrix" ) @@ -17,6 +20,17 @@ type Kafka struct { type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { + team := kafkaTeamFromFlags(flags) + if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + team = cliTeam + } + if team != "" { + envs, err := kafka.TeamTopicEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + } + envs, err := naisapi.GetAllEnvironments(ctx) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) @@ -24,6 +38,41 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument return envs, "Available environments" } +func kafkaTeamFromFlags(flags any) string { + switch f := flags.(type) { + case *List: + return string(f.Team) + case *Credentials: + return string(f.Team) + case *Kafka: + return string(f.Team) + default: + return "" + } +} + +func teamFromCLIArgs(argv []string) string { + for i := 0; i < len(argv); i++ { + arg := argv[i] + + if strings.HasPrefix(arg, "--team=") { + return strings.TrimPrefix(arg, "--team=") + } + if strings.HasPrefix(arg, "-t=") { + return strings.TrimPrefix(arg, "-t=") + } + + if arg == "-t" || arg == "--team" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + } + + return "" +} + type Output string var _ naistrix.FlagAutoCompleter = (*Output)(nil) diff --git a/internal/kafka/kafka.go b/internal/kafka/kafka.go index 23570732..cfbcd734 100644 --- a/internal/kafka/kafka.go +++ b/internal/kafka/kafka.go @@ -64,3 +64,46 @@ func GetTeamTopics(ctx context.Context, team string, environments []string) ([]T return ret, nil } + +func TeamTopicEnvironments(ctx context.Context, team string) ([]string, error) { + _ = `# @genqlient + query GetTeamKafkaTopics($team: Slug!) { + team(slug: $team) { + kafkaTopics(first: 1000) { + nodes { + name + teamEnvironment { + environment { + name + } + } + } + } + } + } + ` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.GetTeamKafkaTopics(ctx, client, team) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, topic := range resp.Team.KafkaTopics.Nodes { + env := topic.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} From 25a21c123d41a20f743542a9435bd3947df5b2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 14:46:35 +0100 Subject: [PATCH 05/28] feat(opensearch): filter environment autocomplete by instances --- internal/opensearch/command/flag/flag.go | 32 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/opensearch/command/flag/flag.go b/internal/opensearch/command/flag/flag.go index f1b90e20..2ad5afff 100644 --- a/internal/opensearch/command/flag/flag.go +++ b/internal/opensearch/command/flag/flag.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "slices" "sort" "github.com/nais/cli/internal/flags" @@ -30,7 +29,7 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st team = f.Team } - if team != "" && isCredentialsCompletionFromCLIArgs() { + if team != "" && isInstanceEnvironmentCompletionFromCLIArgs() { envs, err := opensearchCredentialEnvironments(ctx, team) if err == nil { return envs, "Available environments with OpenSearch instances" @@ -39,8 +38,13 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st return autoCompleteEnvironments(ctx) } -func isCredentialsCompletionFromCLIArgs() bool { - return slices.Contains(os.Args, "credentials") +func isInstanceEnvironmentCompletionFromCLIArgs() bool { + for _, arg := range os.Args { + if arg == "credentials" || arg == "delete" || arg == "get" || arg == "list" || arg == "update" { + return true + } + } + return false } func opensearchCredentialEnvironments(ctx context.Context, team string) ([]string, error) { @@ -92,15 +96,19 @@ type Delete struct { type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - if args.Len() == 0 { - return autoCompleteEnvironments(ctx) - } - f := flags.(*Get) if len(f.Team) == 0 { return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." } + if args.Len() == 0 { + envs, err := opensearchCredentialEnvironments(ctx, f.Team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with OpenSearch instances" + } + return autoCompleteEnvironments(ctx) + } + envs, err := opensearch.OpenSearchEnvironments(ctx, f.Team, args.Get("name")) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) @@ -118,6 +126,14 @@ type Output string type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { + f, ok := flags.(*List) + if ok && f.Team != "" { + envs, err := opensearchCredentialEnvironments(ctx, f.Team) + if err == nil { + return envs, "Available environments with OpenSearch instances" + } + } + return autoCompleteEnvironments(ctx) } From 14c6dbd76c1b11ae41f615a431ed46c369f051c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 14:54:52 +0100 Subject: [PATCH 06/28] feat(valkey): filter environment autocomplete by instances --- internal/valkey/command/flag/flag.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 36f7f831..088c153a 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "slices" "sort" "github.com/nais/cli/internal/flags" @@ -22,6 +21,14 @@ type Valkey struct { } func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { + f, ok := flags.(*List) + if ok && f.Team != "" { + envs, err := valkeyCredentialEnvironments(ctx, f.Team) + if err == nil { + return envs, "Available environments with Valkey instances" + } + } + return autoCompleteEnvironments(ctx) } @@ -87,7 +94,7 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st team = f.Team } - if team != "" && isCredentialsCompletionFromCLIArgs() { + if team != "" && isInstanceEnvironmentCompletionFromCLIArgs() { envs, err := valkeyCredentialEnvironments(ctx, team) if err == nil { return envs, "Available environments with Valkey instances" @@ -96,8 +103,13 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st return autoCompleteEnvironments(ctx) } -func isCredentialsCompletionFromCLIArgs() bool { - return slices.Contains(os.Args, "credentials") +func isInstanceEnvironmentCompletionFromCLIArgs() bool { + for _, arg := range os.Args { + if arg == "credentials" || arg == "delete" || arg == "get" || arg == "list" || arg == "update" { + return true + } + } + return false } func valkeyCredentialEnvironments(ctx context.Context, team string) ([]string, error) { From 95d6a84300eb1bcbc916a1e673660e98d13f574b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 15:23:57 +0100 Subject: [PATCH 07/28] feat(secret): improve environment-aware UX and activity ordering --- internal/secret/activity.go | 5 ++ internal/secret/activity_test.go | 25 ++++++ internal/secret/command/environment.go | 2 +- internal/secret/command/environment_test.go | 2 +- internal/secret/command/flag/flag.go | 85 +++++++++++++++++++-- internal/secret/command/get.go | 2 +- internal/secret/secret.go | 23 ++++++ 7 files changed, 135 insertions(+), 9 deletions(-) diff --git a/internal/secret/activity.go b/internal/secret/activity.go index d279ac60..b626c659 100644 --- a/internal/secret/activity.go +++ b/internal/secret/activity.go @@ -2,6 +2,7 @@ package secret import ( "context" + "sort" "slices" "time" @@ -118,5 +119,9 @@ func buildSecretActivity(resources []secretActivityResource, name string, enviro } } + sort.SliceStable(ret, func(i, j int) bool { + return ret[i].CreatedAt.After(ret[j].CreatedAt) + }) + return ret, found } diff --git a/internal/secret/activity_test.go b/internal/secret/activity_test.go index fcb5518a..e183d873 100644 --- a/internal/secret/activity_test.go +++ b/internal/secret/activity_test.go @@ -64,6 +64,31 @@ func TestBuildSecretActivity(t *testing.T) { wantFound: false, want: []SecretActivity{}, }, + { + name: "sorted by created time descending", + secretName: "db-credentials", + resources: []secretActivityResource{ + { + Name: "db-credentials", + DefaultEnvName: "dev-gcp", + Entries: []secretActivityEntry{ + {CreatedAt: now.Add(-2 * time.Hour), Actor: "alice@example.com", Message: "Older event", EnvironmentName: ""}, + }, + }, + { + Name: "db-credentials", + DefaultEnvName: "prod-gcp", + Entries: []secretActivityEntry{ + {CreatedAt: now, Actor: "bob@example.com", Message: "Newest event", EnvironmentName: ""}, + }, + }, + }, + wantFound: true, + want: []SecretActivity{ + {CreatedAt: now, Actor: "bob@example.com", Environment: "prod-gcp", Message: "Newest event"}, + {CreatedAt: now.Add(-2 * time.Hour), Actor: "alice@example.com", Environment: "dev-gcp", Message: "Older event"}, + }, + }, } for _, tt := range tests { diff --git a/internal/secret/command/environment.go b/internal/secret/command/environment.go index 8f9af0dc..1cd6af78 100644 --- a/internal/secret/command/environment.go +++ b/internal/secret/command/environment.go @@ -42,7 +42,7 @@ func selectSecretEnvironment(team, name, provided string, envs []string) (string return envs[0], nil default: sort.Strings(envs) - return "", fmt.Errorf("secret %q exists in multiple environments (%s); specify --environment/-e", name, strings.Join(envs, ", ")) + return "", fmt.Errorf("secret %q exists in multiple environments (%s); specify --environment/-e on the command, e.g. nais secrets get -t %s %s -e <%s>", name, strings.Join(envs, ", "), team, name, envs[0]) } } diff --git a/internal/secret/command/environment_test.go b/internal/secret/command/environment_test.go index 257b14c3..fa19acdf 100644 --- a/internal/secret/command/environment_test.go +++ b/internal/secret/command/environment_test.go @@ -57,7 +57,7 @@ func TestSelectSecretEnvironment(t *testing.T) { team: "nais", secret: "my-secret", envs: []string{"prod-gcp", "dev-gcp"}, - wantError: "secret \"my-secret\" exists in multiple environments (dev-gcp, prod-gcp); specify --environment/-e", + wantError: "secret \"my-secret\" exists in multiple environments (dev-gcp, prod-gcp); specify --environment/-e on the command, e.g. nais secrets get -t nais my-secret -e ", }, } diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index c167846d..eed210e9 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -3,6 +3,8 @@ package flag import ( "context" "fmt" + "os" + "strings" activityutil "github.com/nais/cli/internal/activity" "github.com/nais/cli/internal/flags" @@ -19,7 +21,19 @@ type Secret struct { type Env string -func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, _ any) ([]string, string) { + +func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { + team := secretTeamFromFlags(flags) + if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + team = cliTeam + } + if team != "" { + envs, err := secret.TeamSecretEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with secrets" + } + } + return autoCompleteEnvironments(ctx) } @@ -29,10 +43,6 @@ func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ string, flags any) ([]string, string) { - if args.Len() == 0 { - return autoCompleteEnvironments(ctx) - } - type teamProvider interface { GetTeam() string } @@ -42,6 +52,14 @@ func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ s return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." } + if args.Len() == 0 { + envs, err := secret.TeamSecretEnvironments(ctx, tp.GetTeam()) + if err == nil && len(envs) > 0 { + return envs, "Available environments with secrets" + } + return autoCompleteEnvironments(ctx) + } + envs, err := secret.SecretEnvironments(ctx, tp.GetTeam(), args.Get("name")) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) @@ -59,10 +77,65 @@ func autoCompleteEnvironments(ctx context.Context) ([]string, string) { type Environments []string -func (e *Environments) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, _ any) ([]string, string) { + +func (e *Environments) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { + team := secretTeamFromFlags(flags) + if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + team = cliTeam + } + if team != "" { + envs, err := secret.TeamSecretEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with secrets" + } + } + return autoCompleteEnvironments(ctx) } +func secretTeamFromFlags(flags any) string { + switch f := flags.(type) { + case *Get: + return string(f.Team) + case *Delete: + return string(f.Team) + case *Set: + return string(f.Team) + case *Unset: + return string(f.Team) + case *List: + return string(f.Team) + case *Activity: + return string(f.Team) + case *Secret: + return string(f.Team) + default: + return "" + } +} + +func teamFromCLIArgs(argv []string) string { + for i := 0; i < len(argv); i++ { + arg := argv[i] + + if strings.HasPrefix(arg, "--team=") { + return strings.TrimPrefix(arg, "--team=") + } + if strings.HasPrefix(arg, "-t=") { + return strings.TrimPrefix(arg, "-t=") + } + + if arg == "-t" || arg == "--team" { + if i+1 < len(argv) { + return argv[i+1] + } + return "" + } + } + + return "" +} + type Output string func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) ([]string, string) { diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index 48990784..34fd8958 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -59,7 +59,7 @@ func get(parentFlags *flag.Secret) *naistrix.Command { }, AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { if args.Len() == 0 { - return autoCompleteSecretNames(ctx, f.Team, string(f.Environment), false) + return autoCompleteSecretNames(ctx, f.Team, string(f.Environment), true) } return nil, "" }, diff --git a/internal/secret/secret.go b/internal/secret/secret.go index 59e29b82..2df888ab 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "sort" "time" "github.com/nais/cli/internal/naisapi" @@ -70,6 +71,28 @@ func SecretEnvironments(ctx context.Context, teamSlug, name string) ([]string, e return envs, nil } +// TeamSecretEnvironments returns unique environments where the team has one or more secrets. +func TeamSecretEnvironments(ctx context.Context, teamSlug string) ([]string, error) { + all, err := GetAll(ctx, teamSlug) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, s := range all { + env := s.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} + // GetAll retrieves all secrets for a team. func GetAll(ctx context.Context, teamSlug string) ([]gql.GetAllSecretsTeamSecretsSecretConnectionNodesSecret, error) { _ = `# @genqlient From 9b195a81264301ffc1dd252e499741ee2a1ba221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 15:28:34 +0100 Subject: [PATCH 08/28] refactor: apply fix/fmt cleanups for autocomplete helpers --- internal/app/command/flag/flag.go | 20 ++++++++------------ internal/job/command/flag/flag.go | 20 ++++++++------------ internal/kafka/command/flag/flag.go | 10 +++++----- internal/secret/activity.go | 2 +- internal/secret/command/flag/flag.go | 12 +++++------- 5 files changed, 27 insertions(+), 37 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 3a269540..7ffd75dc 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "reflect" + "slices" "strings" "time" @@ -79,7 +80,7 @@ func appTeamFromFlags(flags any) string { if !v.IsValid() { return "" } - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { if v.IsNil() { return "" } @@ -115,12 +116,7 @@ func appNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isRestartCompletionFromCLIArgs() bool { - for _, arg := range os.Args { - if arg == "restart" { - return true - } - } - return false + return slices.Contains(os.Args, "restart") } func appNameFromCLIArgs(argv []string) string { @@ -164,14 +160,14 @@ func appNameFromCLIArgs(argv []string) string { } func teamFromCLIArgs(argv []string) string { - for i := 0; i < len(argv); i++ { + for i := range argv { arg := argv[i] - if strings.HasPrefix(arg, "--team=") { - return strings.TrimPrefix(arg, "--team=") + if after, ok := strings.CutPrefix(arg, "--team="); ok { + return after } - if strings.HasPrefix(arg, "-t=") { - return strings.TrimPrefix(arg, "-t=") + if after, ok := strings.CutPrefix(arg, "-t="); ok { + return after } if arg == "-t" || arg == "--team" { diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index dd66b976..6d4c48d6 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "reflect" + "slices" "strings" "time" @@ -72,7 +73,7 @@ func jobTeamFromFlags(flags any) string { if !v.IsValid() { return "" } - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { if v.IsNil() { return "" } @@ -106,12 +107,7 @@ func jobNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isTriggerCompletionFromCLIArgs() bool { - for _, arg := range os.Args { - if arg == "trigger" { - return true - } - } - return false + return slices.Contains(os.Args, "trigger") } func jobNameFromCLIArgs(argv []string) string { @@ -155,14 +151,14 @@ func jobNameFromCLIArgs(argv []string) string { } func teamFromCLIArgs(argv []string) string { - for i := 0; i < len(argv); i++ { + for i := range argv { arg := argv[i] - if strings.HasPrefix(arg, "--team=") { - return strings.TrimPrefix(arg, "--team=") + if after, ok := strings.CutPrefix(arg, "--team="); ok { + return after } - if strings.HasPrefix(arg, "-t=") { - return strings.TrimPrefix(arg, "-t=") + if after, ok := strings.CutPrefix(arg, "-t="); ok { + return after } if arg == "-t" || arg == "--team" { diff --git a/internal/kafka/command/flag/flag.go b/internal/kafka/command/flag/flag.go index 6c077118..88d38255 100644 --- a/internal/kafka/command/flag/flag.go +++ b/internal/kafka/command/flag/flag.go @@ -52,14 +52,14 @@ func kafkaTeamFromFlags(flags any) string { } func teamFromCLIArgs(argv []string) string { - for i := 0; i < len(argv); i++ { + for i := range argv { arg := argv[i] - if strings.HasPrefix(arg, "--team=") { - return strings.TrimPrefix(arg, "--team=") + if after, ok := strings.CutPrefix(arg, "--team="); ok { + return after } - if strings.HasPrefix(arg, "-t=") { - return strings.TrimPrefix(arg, "-t=") + if after, ok := strings.CutPrefix(arg, "-t="); ok { + return after } if arg == "-t" || arg == "--team" { diff --git a/internal/secret/activity.go b/internal/secret/activity.go index b626c659..49c34268 100644 --- a/internal/secret/activity.go +++ b/internal/secret/activity.go @@ -2,8 +2,8 @@ package secret import ( "context" - "sort" "slices" + "sort" "time" "github.com/nais/cli/internal/naisapi" diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index eed210e9..f99b2b25 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -21,7 +21,6 @@ type Secret struct { type Env string - func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { team := secretTeamFromFlags(flags) if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { @@ -77,7 +76,6 @@ func autoCompleteEnvironments(ctx context.Context) ([]string, string) { type Environments []string - func (e *Environments) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { team := secretTeamFromFlags(flags) if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { @@ -115,14 +113,14 @@ func secretTeamFromFlags(flags any) string { } func teamFromCLIArgs(argv []string) string { - for i := 0; i < len(argv); i++ { + for i := range argv { arg := argv[i] - if strings.HasPrefix(arg, "--team=") { - return strings.TrimPrefix(arg, "--team=") + if after, ok := strings.CutPrefix(arg, "--team="); ok { + return after } - if strings.HasPrefix(arg, "-t=") { - return strings.TrimPrefix(arg, "-t=") + if after, ok := strings.CutPrefix(arg, "-t="); ok { + return after } if arg == "-t" || arg == "--team" { From 195554c2e826c7fb03248d5f70f37b849f999b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 15:45:10 +0100 Subject: [PATCH 09/28] fix: address copilot review feedback on autocomplete parsing --- internal/app/command/flag/flag.go | 8 +++++++- internal/app/command/restart.go | 2 +- internal/job/command/flag/flag.go | 8 +++++++- internal/kafka/command/flag/flag.go | 8 +++++++- internal/secret/command/flag/flag.go | 8 +++++++- 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 7ffd75dc..41e16073 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -162,6 +162,9 @@ func appNameFromCLIArgs(argv []string) string { func teamFromCLIArgs(argv []string) string { for i := range argv { arg := argv[i] + if arg == "--" { + break + } if after, ok := strings.CutPrefix(arg, "--team="); ok { return after @@ -172,7 +175,10 @@ func teamFromCLIArgs(argv []string) string { if arg == "-t" || arg == "--team" { if i+1 < len(argv) { - return argv[i+1] + next := argv[i+1] + if next != "" && !strings.HasPrefix(next, "-") { + return next + } } return "" } diff --git a/internal/app/command/restart.go b/internal/app/command/restart.go index e0a202b4..4d5557be 100644 --- a/internal/app/command/restart.go +++ b/internal/app/command/restart.go @@ -30,7 +30,7 @@ func restart(parentFlags *flag.App) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete application names. 'nais config set team ', or '--team ' flag." } - envs := flags.Environment + envs := []string(flags.Environment) if len(envs) != 1 { envs = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") } diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 6d4c48d6..bae0e386 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -153,6 +153,9 @@ func jobNameFromCLIArgs(argv []string) string { func teamFromCLIArgs(argv []string) string { for i := range argv { arg := argv[i] + if arg == "--" { + break + } if after, ok := strings.CutPrefix(arg, "--team="); ok { return after @@ -163,7 +166,10 @@ func teamFromCLIArgs(argv []string) string { if arg == "-t" || arg == "--team" { if i+1 < len(argv) { - return argv[i+1] + next := argv[i+1] + if next != "" && !strings.HasPrefix(next, "-") { + return next + } } return "" } diff --git a/internal/kafka/command/flag/flag.go b/internal/kafka/command/flag/flag.go index 88d38255..f166b145 100644 --- a/internal/kafka/command/flag/flag.go +++ b/internal/kafka/command/flag/flag.go @@ -54,6 +54,9 @@ func kafkaTeamFromFlags(flags any) string { func teamFromCLIArgs(argv []string) string { for i := range argv { arg := argv[i] + if arg == "--" { + break + } if after, ok := strings.CutPrefix(arg, "--team="); ok { return after @@ -64,7 +67,10 @@ func teamFromCLIArgs(argv []string) string { if arg == "-t" || arg == "--team" { if i+1 < len(argv) { - return argv[i+1] + next := argv[i+1] + if next != "" && !strings.HasPrefix(next, "-") { + return next + } } return "" } diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index f99b2b25..64830369 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -115,6 +115,9 @@ func secretTeamFromFlags(flags any) string { func teamFromCLIArgs(argv []string) string { for i := range argv { arg := argv[i] + if arg == "--" { + break + } if after, ok := strings.CutPrefix(arg, "--team="); ok { return after @@ -125,7 +128,10 @@ func teamFromCLIArgs(argv []string) string { if arg == "-t" || arg == "--team" { if i+1 < len(argv) { - return argv[i+1] + next := argv[i+1] + if next != "" && !strings.HasPrefix(next, "-") { + return next + } } return "" } From 76286bb90a22b8dd9ffbb95d071dfe206bf93372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 16:00:05 +0100 Subject: [PATCH 10/28] fix: address remaining Copilot feedback --- internal/app/command/flag/flag.go | 28 ++++++++++++---------------- internal/job/command/flag/flag.go | 2 +- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 41e16073..0dad8567 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -32,26 +32,22 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." } - if team != "" { - if appName := appNameForEnvironmentCompletion(args); appName != "" { - envs, err := app.ApplicationEnvironments(ctx, team, appName) - if err == nil && len(envs) > 0 { - return envs, "Available environments" - } - } - - envs, err := app.TeamApplicationEnvironments(ctx, team) - if err != nil { - return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) - } - if len(envs) > 0 { + if appName := appNameForEnvironmentCompletion(args); appName != "" { + envs, err := app.ApplicationEnvironments(ctx, team, appName) + if err == nil && len(envs) > 0 { return envs, "Available environments" } + } - return nil, "No environments with applications found for this team." + envs, err := app.TeamApplicationEnvironments(ctx, team) + if err != nil { + return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) + } + if len(envs) > 0 { + return envs, "Available environments" } - return nil, "No environments available" + return nil, "No environments with applications found for this team." } func appTeamFromFlags(flags any) string { @@ -80,7 +76,7 @@ func appTeamFromFlags(flags any) string { if !v.IsValid() { return "" } - if v.Kind() == reflect.Pointer { + if v.Kind() == reflect.Ptr { if v.IsNil() { return "" } diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index bae0e386..67e4ab0c 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -73,7 +73,7 @@ func jobTeamFromFlags(flags any) string { if !v.IsValid() { return "" } - if v.Kind() == reflect.Pointer { + if v.Kind() == reflect.Ptr { if v.IsNil() { return "" } From 117dcae9865caec99943cc4281270498b71a8548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 16:03:59 +0100 Subject: [PATCH 11/28] fix: apply go fix for reflect.Pointer on go1.26 --- internal/app/command/flag/flag.go | 2 +- internal/job/command/flag/flag.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 0dad8567..6b430569 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -76,7 +76,7 @@ func appTeamFromFlags(flags any) string { if !v.IsValid() { return "" } - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { if v.IsNil() { return "" } diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 67e4ab0c..bae0e386 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -73,7 +73,7 @@ func jobTeamFromFlags(flags any) string { if !v.IsValid() { return "" } - if v.Kind() == reflect.Ptr { + if v.Kind() == reflect.Pointer { if v.IsNil() { return "" } From 6851d97b95ceb2a6e3f732437c52c210bb06cac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 16:18:11 +0100 Subject: [PATCH 12/28] fix: remove duplicate genqlient operation blocks --- internal/job/list.go | 34 ---------------------------------- internal/kafka/kafka.go | 17 ----------------- 2 files changed, 51 deletions(-) diff --git a/internal/job/list.go b/internal/job/list.go index a5e5ffdd..d8f990de 100644 --- a/internal/job/list.go +++ b/internal/job/list.go @@ -106,23 +106,6 @@ func GetJobNames(ctx context.Context, team string, environments []string) ([]str } func TeamJobEnvironments(ctx context.Context, team string) ([]string, error) { - _ = `# @genqlient - query GetJobNames($team: Slug!) { - team(slug: $team) { - jobs(first: 1000) { - nodes { - name - teamEnvironment { - environment { - name - } - } - } - } - } - } - ` - client, err := naisapi.GraphqlClient(ctx) if err != nil { return nil, err @@ -149,23 +132,6 @@ func TeamJobEnvironments(ctx context.Context, team string) ([]string, error) { } func JobEnvironments(ctx context.Context, team, jobName string) ([]string, error) { - _ = `# @genqlient - query GetJobNames($team: Slug!) { - team(slug: $team) { - jobs(first: 1000) { - nodes { - name - teamEnvironment { - environment { - name - } - } - } - } - } - } - ` - client, err := naisapi.GraphqlClient(ctx) if err != nil { return nil, err diff --git a/internal/kafka/kafka.go b/internal/kafka/kafka.go index cfbcd734..4765ef25 100644 --- a/internal/kafka/kafka.go +++ b/internal/kafka/kafka.go @@ -66,23 +66,6 @@ func GetTeamTopics(ctx context.Context, team string, environments []string) ([]T } func TeamTopicEnvironments(ctx context.Context, team string) ([]string, error) { - _ = `# @genqlient - query GetTeamKafkaTopics($team: Slug!) { - team(slug: $team) { - kafkaTopics(first: 1000) { - nodes { - name - teamEnvironment { - environment { - name - } - } - } - } - } - } - ` - client, err := naisapi.GraphqlClient(ctx) if err != nil { return nil, err From ca36cfd08b5eca57e08a79e4967de67e98bd9033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 16:33:08 +0100 Subject: [PATCH 13/28] refactor: deduplicate team flag parsing in autocomplete --- internal/app/command/flag/flag.go | 31 +-------- internal/cliflags/flags.go | 36 +++++++++++ internal/cliflags/flags_test.go | 94 ++++++++++++++++++++++++++++ internal/job/command/flag/flag.go | 33 +--------- internal/kafka/command/flag/flag.go | 32 +--------- internal/secret/command/flag/flag.go | 34 +--------- 6 files changed, 140 insertions(+), 120 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 6b430569..aad5acab 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -10,6 +10,7 @@ import ( "time" activityutil "github.com/nais/cli/internal/activity" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/app" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" @@ -25,7 +26,7 @@ type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { team := appTeamFromFlags(flags) - if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { team = cliTeam } if team == "" { @@ -155,34 +156,6 @@ func appNameFromCLIArgs(argv []string) string { return "" } -func teamFromCLIArgs(argv []string) string { - for i := range argv { - arg := argv[i] - if arg == "--" { - break - } - - if after, ok := strings.CutPrefix(arg, "--team="); ok { - return after - } - if after, ok := strings.CutPrefix(arg, "-t="); ok { - return after - } - - if arg == "-t" || arg == "--team" { - if i+1 < len(argv) { - next := argv[i+1] - if next != "" && !strings.HasPrefix(next, "-") { - return next - } - } - return "" - } - } - - return "" -} - type instances []string func (i *instances) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 1e894c7b..ad3daa7e 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -82,3 +82,39 @@ func CountFlagOccurrences(args []string, shortFlag, longFlag string) int { return count } + +// FirstFlagValue returns the first valid value for a short/long CLI flag from args. +// It supports forms: -t value, --team value, -t=value, --team=value. +func FirstFlagValue(args []string, shortFlag, longFlag string) string { + for i := range args { + arg := args[i] + if arg == "--" { + break + } + + if after, ok := strings.CutPrefix(arg, longFlag+"="); ok { + if after != "" { + return after + } + continue + } + if after, ok := strings.CutPrefix(arg, shortFlag+"="); ok { + if after != "" { + return after + } + continue + } + + if arg == shortFlag || arg == longFlag { + if i+1 < len(args) { + next := args[i+1] + if next != "" && !strings.HasPrefix(next, "-") { + return next + } + } + return "" + } + } + + return "" +} diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index ad527774..abbed94b 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -164,3 +164,97 @@ func TestCountFlagOccurrences(t *testing.T) { }) } } + +func TestFirstFlagValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + short string + long string + want string + }{ + { + name: "no matching flag", + args: []string{"cmd", "sub"}, + short: "-t", + long: "--team", + want: "", + }, + { + name: "short flag with separate value", + args: []string{"cmd", "-t", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "long flag with separate value", + args: []string{"cmd", "--team", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "short equals form", + args: []string{"cmd", "-t=nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "long equals form", + args: []string{"cmd", "--team=nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "first occurrence wins", + args: []string{"cmd", "--team=nais", "-t", "other"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "missing value returns empty", + args: []string{"cmd", "--team"}, + short: "-t", + long: "--team", + want: "", + }, + { + name: "flag-like next arg returns empty", + args: []string{"cmd", "--team", "--environment", "dev"}, + short: "-t", + long: "--team", + want: "", + }, + { + name: "empty equals value ignored and later value used", + args: []string{"cmd", "--team=", "-t", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "flags after end-of-flags marker ignored", + args: []string{"cmd", "--", "--team", "nais"}, + short: "-t", + long: "--team", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := FirstFlagValue(tt.args, tt.short, tt.long) + if got != tt.want { + t.Fatalf("FirstFlagValue() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index bae0e386..0a852c1b 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/naisapi" @@ -24,7 +25,7 @@ type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { team := jobTeamFromFlags(flags) - if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { team = cliTeam } if team == "" { @@ -150,34 +151,6 @@ func jobNameFromCLIArgs(argv []string) string { return "" } -func teamFromCLIArgs(argv []string) string { - for i := range argv { - arg := argv[i] - if arg == "--" { - break - } - - if after, ok := strings.CutPrefix(arg, "--team="); ok { - return after - } - if after, ok := strings.CutPrefix(arg, "-t="); ok { - return after - } - - if arg == "-t" || arg == "--team" { - if i+1 < len(argv) { - next := argv[i+1] - if next != "" && !strings.HasPrefix(next, "-") { - return next - } - } - return "" - } - } - - return "" -} - type Output string var _ naistrix.FlagAutoCompleter = (*Output)(nil) @@ -219,7 +192,7 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st team = string(t.Team) } if team == "" { - if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { team = cliTeam } } diff --git a/internal/kafka/command/flag/flag.go b/internal/kafka/command/flag/flag.go index f166b145..cdb3cf5a 100644 --- a/internal/kafka/command/flag/flag.go +++ b/internal/kafka/command/flag/flag.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "os" - "strings" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/kafka" "github.com/nais/cli/internal/naisapi" @@ -21,7 +21,7 @@ type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { team := kafkaTeamFromFlags(flags) - if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { team = cliTeam } if team != "" { @@ -51,34 +51,6 @@ func kafkaTeamFromFlags(flags any) string { } } -func teamFromCLIArgs(argv []string) string { - for i := range argv { - arg := argv[i] - if arg == "--" { - break - } - - if after, ok := strings.CutPrefix(arg, "--team="); ok { - return after - } - if after, ok := strings.CutPrefix(arg, "-t="); ok { - return after - } - - if arg == "-t" || arg == "--team" { - if i+1 < len(argv) { - next := argv[i+1] - if next != "" && !strings.HasPrefix(next, "-") { - return next - } - } - return "" - } - } - - return "" -} - type Output string var _ naistrix.FlagAutoCompleter = (*Output)(nil) diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index 64830369..a0b4777e 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -4,9 +4,9 @@ import ( "context" "fmt" "os" - "strings" activityutil "github.com/nais/cli/internal/activity" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -23,7 +23,7 @@ type Env string func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { team := secretTeamFromFlags(flags) - if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { team = cliTeam } if team != "" { @@ -78,7 +78,7 @@ type Environments []string func (e *Environments) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { team := secretTeamFromFlags(flags) - if cliTeam := teamFromCLIArgs(os.Args); cliTeam != "" { + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { team = cliTeam } if team != "" { @@ -112,34 +112,6 @@ func secretTeamFromFlags(flags any) string { } } -func teamFromCLIArgs(argv []string) string { - for i := range argv { - arg := argv[i] - if arg == "--" { - break - } - - if after, ok := strings.CutPrefix(arg, "--team="); ok { - return after - } - if after, ok := strings.CutPrefix(arg, "-t="); ok { - return after - } - - if arg == "-t" || arg == "--team" { - if i+1 < len(argv) { - next := argv[i+1] - if next != "" && !strings.HasPrefix(next, "-") { - return next - } - } - return "" - } - } - - return "" -} - type Output string func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) ([]string, string) { From b975daa40b276c23e73ab093221a7449bdfe5306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 17:09:35 +0100 Subject: [PATCH 14/28] fix: reorder import statements for consistency --- internal/app/command/flag/flag.go | 2 +- internal/cliflags/flags_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index aad5acab..1d863916 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -10,8 +10,8 @@ import ( "time" activityutil "github.com/nais/cli/internal/activity" - "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/app" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index abbed94b..800156ec 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -169,11 +169,11 @@ func TestFirstFlagValue(t *testing.T) { t.Parallel() tests := []struct { - name string - args []string - short string - long string - want string + name string + args []string + short string + long string + want string }{ { name: "no matching flag", From 6cde4273299b425331cb25811e1e710536834f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 17:18:29 +0100 Subject: [PATCH 15/28] fix: address latest Copilot feedback --- internal/app/command/flag/flag.go | 24 +++++- internal/cliflags/flags.go | 2 +- internal/cliflags/flags_test.go | 18 ++++- internal/job/command/flag/flag.go | 24 +++++- internal/naisapi/gql/generated.go | 129 ++++++++++++++++++++++++++++++ internal/secret/command/get.go | 21 ++--- internal/secret/secret.go | 32 +++++++- 7 files changed, 228 insertions(+), 22 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 1d863916..29a47c65 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "reflect" - "slices" "strings" "time" @@ -113,7 +112,28 @@ func appNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isRestartCompletionFromCLIArgs() bool { - return slices.Contains(os.Args, "restart") + return hasSubCommandPath(os.Args, "app", "restart") +} + +func hasSubCommandPath(argv []string, parent, sub string) bool { + for i := range argv { + if argv[i] != parent { + continue + } + + for j := i + 1; j < len(argv); j++ { + next := argv[j] + if next == "--" { + break + } + if strings.HasPrefix(next, "-") { + continue + } + return next == sub + } + } + + return false } func appNameFromCLIArgs(argv []string) string { diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index ad3daa7e..413ae2ac 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -112,7 +112,7 @@ func FirstFlagValue(args []string, shortFlag, longFlag string) string { return next } } - return "" + continue } } diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index 800156ec..e88119f4 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -218,19 +218,33 @@ func TestFirstFlagValue(t *testing.T) { want: "nais", }, { - name: "missing value returns empty", + name: "missing value with no later value returns empty", args: []string{"cmd", "--team"}, short: "-t", long: "--team", want: "", }, { - name: "flag-like next arg returns empty", + name: "flag-like next arg with no later value returns empty", args: []string{"cmd", "--team", "--environment", "dev"}, short: "-t", long: "--team", want: "", }, + { + name: "missing value continues scanning to later valid value", + args: []string{"cmd", "--team", "-x", "--team=nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "flag-like next arg continues scanning to later valid value", + args: []string{"cmd", "--team", "--environment", "dev", "-t", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, { name: "empty equals value ignored and later value used", args: []string{"cmd", "--team=", "-t", "nais"}, diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 0a852c1b..0eafbc81 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "reflect" - "slices" "strings" "time" @@ -108,7 +107,28 @@ func jobNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isTriggerCompletionFromCLIArgs() bool { - return slices.Contains(os.Args, "trigger") + return hasSubCommandPath(os.Args, "job", "trigger") +} + +func hasSubCommandPath(argv []string, parent, sub string) bool { + for i := range argv { + if argv[i] != parent { + continue + } + + for j := i + 1; j < len(argv); j++ { + next := argv[j] + if next == "--" { + break + } + if strings.HasPrefix(next, "-") { + continue + } + return next == sub + } + } + + return false } func jobNameFromCLIArgs(argv []string) string { diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 8056212e..835e00dd 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -3882,6 +3882,85 @@ func (v *GetAllOpenSearchesTeamOpenSearchesOpenSearchConnectionNodesOpenSearchVe return v.Actual } +// GetAllSecretEnvironmentsResponse is returned by GetAllSecretEnvironments on success. +type GetAllSecretEnvironmentsResponse struct { + // Get a team by its slug. + Team GetAllSecretEnvironmentsTeam `json:"team"` +} + +// GetTeam returns GetAllSecretEnvironmentsResponse.Team, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsResponse) GetTeam() GetAllSecretEnvironmentsTeam { return v.Team } + +// GetAllSecretEnvironmentsTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetAllSecretEnvironmentsTeam struct { + // Secrets owned by the team. + Secrets GetAllSecretEnvironmentsTeamSecretsSecretConnection `json:"secrets"` +} + +// GetSecrets returns GetAllSecretEnvironmentsTeam.Secrets, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeam) GetSecrets() GetAllSecretEnvironmentsTeamSecretsSecretConnection { + return v.Secrets +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnection includes the requested fields of the GraphQL type SecretConnection. +type GetAllSecretEnvironmentsTeamSecretsSecretConnection struct { + // List of nodes. + Nodes []GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret `json:"nodes"` +} + +// GetNodes returns GetAllSecretEnvironmentsTeamSecretsSecretConnection.Nodes, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnection) GetNodes() []GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret { + return v.Nodes +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret includes the requested fields of the GraphQL type Secret. +// The GraphQL type's documentation follows. +// +// A secret is a collection of secret values. +type GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret struct { + // The environment the secret exists in. + TeamEnvironment GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment `json:"teamEnvironment"` +} + +// GetTeamEnvironment returns GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret) GetTeamEnvironment() GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment { + return v.TeamEnvironment +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment struct { + // Get the environment. + Environment GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment `json:"environment"` +} + +// GetEnvironment returns GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment) GetEnvironment() GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment { + return v.Environment +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// The GraphQL type's documentation follows. +// +// An environment represents a runtime environment for workloads. +// +// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). +type GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment struct { + // Unique name of the environment. + Name string `json:"name"` +} + +// GetName returns GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment) GetName() string { + return v.Name +} + // GetAllSecretsResponse is returned by GetAllSecrets on success. type GetAllSecretsResponse struct { // Get a team by its slug. @@ -24086,6 +24165,14 @@ type __GetAllOpenSearchesInput struct { // GetTeamSlug returns __GetAllOpenSearchesInput.TeamSlug, and is useful for accessing the field via an interface. func (v *__GetAllOpenSearchesInput) GetTeamSlug() string { return v.TeamSlug } +// __GetAllSecretEnvironmentsInput is used internally by genqlient +type __GetAllSecretEnvironmentsInput struct { + TeamSlug string `json:"teamSlug"` +} + +// GetTeamSlug returns __GetAllSecretEnvironmentsInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *__GetAllSecretEnvironmentsInput) GetTeamSlug() string { return v.TeamSlug } + // __GetAllSecretsInput is used internally by genqlient type __GetAllSecretsInput struct { TeamSlug string `json:"teamSlug"` @@ -25425,6 +25512,48 @@ func GetAllOpenSearches( return data_, err_ } +// The query executed by GetAllSecretEnvironments. +const GetAllSecretEnvironments_Operation = ` +query GetAllSecretEnvironments ($teamSlug: Slug!) { + team(slug: $teamSlug) { + secrets(first: 1000, orderBy: {field:NAME,direction:ASC}) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } +} +` + +func GetAllSecretEnvironments( + ctx_ context.Context, + client_ graphql.Client, + teamSlug string, +) (data_ *GetAllSecretEnvironmentsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAllSecretEnvironments", + Query: GetAllSecretEnvironments_Operation, + Variables: &__GetAllSecretEnvironmentsInput{ + TeamSlug: teamSlug, + }, + } + + data_ = &GetAllSecretEnvironmentsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetAllSecrets. const GetAllSecrets_Operation = ` query GetAllSecrets ($teamSlug: Slug!) { diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index 34fd8958..37d5cb42 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -41,10 +41,12 @@ func get(parentFlags *flag.Secret) *naistrix.Command { if err := validateSingleEnvironmentFlagUsage(); err != nil { return err } - if providedEnvironment := string(f.Environment); providedEnvironment != "" { - if err := validation.CheckEnvironment(providedEnvironment); err != nil { - return err - } + providedEnvironment := string(f.Environment) + if providedEnvironment == "" { + return fmt.Errorf("exactly one environment must be specified") + } + if err := validation.CheckEnvironment(providedEnvironment); err != nil { + return err } if err := validateArgs(args); err != nil { return err @@ -78,16 +80,7 @@ func get(parentFlags *flag.Secret) *naistrix.Command { }, }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - if providedEnvironment := string(f.Environment); providedEnvironment != "" { - return runGetCommand(ctx, args, out, f.Team, providedEnvironment, f.Output, f.WithValues, f.Reason) - } - - environment, err := resolveSecretEnvironment(ctx, f.Team, args.Get("name"), string(f.Environment)) - if err != nil { - return err - } - - return runGetCommand(ctx, args, out, f.Team, environment, f.Output, f.WithValues, f.Reason) + return runGetCommand(ctx, args, out, f.Team, string(f.Environment), f.Output, f.WithValues, f.Reason) }, } } diff --git a/internal/secret/secret.go b/internal/secret/secret.go index 2df888ab..16c868cc 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -73,7 +73,7 @@ func SecretEnvironments(ctx context.Context, teamSlug, name string) ([]string, e // TeamSecretEnvironments returns unique environments where the team has one or more secrets. func TeamSecretEnvironments(ctx context.Context, teamSlug string) ([]string, error) { - all, err := GetAll(ctx, teamSlug) + all, err := getAllSecretEnvironments(ctx, teamSlug) if err != nil { return nil, err } @@ -93,6 +93,36 @@ func TeamSecretEnvironments(ctx context.Context, teamSlug string) ([]string, err return ret, nil } +func getAllSecretEnvironments(ctx context.Context, teamSlug string) ([]gql.GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret, error) { + _ = `# @genqlient + query GetAllSecretEnvironments($teamSlug: Slug!) { + team(slug: $teamSlug) { + secrets(first: 1000, orderBy: {field: NAME, direction: ASC}) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } + } + ` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.GetAllSecretEnvironments(ctx, client, teamSlug) + if err != nil { + return nil, err + } + + return resp.Team.Secrets.Nodes, nil +} + // GetAll retrieves all secrets for a team. func GetAll(ctx context.Context, teamSlug string) ([]gql.GetAllSecretsTeamSecretsSecretConnectionNodesSecret, error) { _ = `# @genqlient From 38cab357c9fc21a24403901c58bd7f0b3fe75753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 17:26:23 +0100 Subject: [PATCH 16/28] fix: remove unused secret environment resolver --- internal/secret/command/environment.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/internal/secret/command/environment.go b/internal/secret/command/environment.go index 1cd6af78..85819e92 100644 --- a/internal/secret/command/environment.go +++ b/internal/secret/command/environment.go @@ -1,7 +1,6 @@ package command import ( - "context" "fmt" "os" "slices" @@ -9,18 +8,8 @@ import ( "strings" "github.com/nais/cli/internal/cliflags" - "github.com/nais/cli/internal/secret" ) -func resolveSecretEnvironment(ctx context.Context, team, name, provided string) (string, error) { - envs, err := secret.SecretEnvironments(ctx, team, name) - if err != nil { - return "", fmt.Errorf("fetching environments for secret %q: %w", name, err) - } - - return selectSecretEnvironment(team, name, provided, envs) -} - func selectSecretEnvironment(team, name, provided string, envs []string) (string, error) { if provided != "" { if slices.Contains(envs, provided) { From cf3a47f5c509285240c78e0c1cadae62ed5de565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 17:32:34 +0100 Subject: [PATCH 17/28] fix: tighten autocomplete subcommand path detection --- internal/app/command/flag/flag.go | 23 +------- internal/cliflags/flags.go | 42 ++++++++++++++ internal/cliflags/flags_test.go | 73 ++++++++++++++++++++++++ internal/job/command/flag/flag.go | 23 +------- internal/opensearch/command/flag/flag.go | 8 +-- internal/valkey/command/flag/flag.go | 8 +-- 6 files changed, 121 insertions(+), 56 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 29a47c65..63d6535e 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -112,28 +112,7 @@ func appNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isRestartCompletionFromCLIArgs() bool { - return hasSubCommandPath(os.Args, "app", "restart") -} - -func hasSubCommandPath(argv []string, parent, sub string) bool { - for i := range argv { - if argv[i] != parent { - continue - } - - for j := i + 1; j < len(argv); j++ { - next := argv[j] - if next == "--" { - break - } - if strings.HasPrefix(next, "-") { - continue - } - return next == sub - } - } - - return false + return cliflags.HasSubCommandPath(os.Args, "app", "restart") } func appNameFromCLIArgs(argv []string) string { diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 413ae2ac..6e735912 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -118,3 +118,45 @@ func FirstFlagValue(args []string, shortFlag, longFlag string) string { return "" } + +// HasSubCommandPath reports whether args contain a command path where `parent` +// is followed by one of the provided subcommands as the next non-flag token. +func HasSubCommandPath(args []string, parent string, subcommands ...string) bool { + if len(subcommands) == 0 { + return false + } + + allowed := make(map[string]struct{}, len(subcommands)) + for _, sub := range subcommands { + allowed[sub] = struct{}{} + } + + for i := range args { + if args[i] == "--" { + break + } + if args[i] != parent { + continue + } + + for j := i + 1; j < len(args); j++ { + next := args[j] + if next == "--" { + break + } + if strings.HasPrefix(next, "-") { + if !strings.Contains(next, "=") && j+1 < len(args) { + value := args[j+1] + if value != "" && !strings.HasPrefix(value, "-") { + j++ + } + } + continue + } + _, ok := allowed[next] + return ok + } + } + + return false +} diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index e88119f4..c13d57ac 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -272,3 +272,76 @@ func TestFirstFlagValue(t *testing.T) { }) } } + +func TestHasSubCommandPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + parent string + subs []string + wantHas bool + }{ + { + name: "matches parent and subcommand", + args: []string{"nais", "app", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, + { + name: "matches with flag between parent and subcommand", + args: []string{"nais", "job", "--team", "my-team", "trigger"}, + parent: "job", + subs: []string{"trigger"}, + wantHas: true, + }, + { + name: "matches one of many subcommands", + args: []string{"nais", "valkey", "list"}, + parent: "valkey", + subs: []string{"credentials", "delete", "get", "list", "update"}, + wantHas: true, + }, + { + name: "returns false for different subcommand", + args: []string{"nais", "app", "list"}, + parent: "app", + subs: []string{"restart"}, + wantHas: false, + }, + { + name: "returns false when token appears as positional argument", + args: []string{"nais", "opensearch", "create", "delete"}, + parent: "opensearch", + subs: []string{"credentials", "delete", "get", "list", "update"}, + wantHas: false, + }, + { + name: "stops at end of flags marker", + args: []string{"nais", "app", "--", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: false, + }, + { + name: "returns false with no subcommands provided", + args: []string{"nais", "app", "restart"}, + parent: "app", + subs: nil, + wantHas: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := HasSubCommandPath(tt.args, tt.parent, tt.subs...) + if got != tt.wantHas { + t.Fatalf("HasSubCommandPath() = %v, want %v", got, tt.wantHas) + } + }) + } +} diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 0eafbc81..86e0ee44 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -107,28 +107,7 @@ func jobNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isTriggerCompletionFromCLIArgs() bool { - return hasSubCommandPath(os.Args, "job", "trigger") -} - -func hasSubCommandPath(argv []string, parent, sub string) bool { - for i := range argv { - if argv[i] != parent { - continue - } - - for j := i + 1; j < len(argv); j++ { - next := argv[j] - if next == "--" { - break - } - if strings.HasPrefix(next, "-") { - continue - } - return next == sub - } - } - - return false + return cliflags.HasSubCommandPath(os.Args, "job", "trigger") } func jobNameFromCLIArgs(argv []string) string { diff --git a/internal/opensearch/command/flag/flag.go b/internal/opensearch/command/flag/flag.go index 2ad5afff..24769292 100644 --- a/internal/opensearch/command/flag/flag.go +++ b/internal/opensearch/command/flag/flag.go @@ -6,6 +6,7 @@ import ( "os" "sort" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -39,12 +40,7 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st } func isInstanceEnvironmentCompletionFromCLIArgs() bool { - for _, arg := range os.Args { - if arg == "credentials" || arg == "delete" || arg == "get" || arg == "list" || arg == "update" { - return true - } - } - return false + return cliflags.HasSubCommandPath(os.Args, "opensearch", "credentials", "delete", "get", "list", "update") } func opensearchCredentialEnvironments(ctx context.Context, team string) ([]string, error) { diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 088c153a..653affc7 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -6,6 +6,7 @@ import ( "os" "sort" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -104,12 +105,7 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st } func isInstanceEnvironmentCompletionFromCLIArgs() bool { - for _, arg := range os.Args { - if arg == "credentials" || arg == "delete" || arg == "get" || arg == "list" || arg == "update" { - return true - } - } - return false + return cliflags.HasSubCommandPath(os.Args, "valkey", "credentials", "delete", "get", "list", "update") } func valkeyCredentialEnvironments(ctx context.Context, team string) ([]string, error) { From 7bb7e2d265e952cf065978a2327a404174ce4541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 22:50:08 +0100 Subject: [PATCH 18/28] fix: handle flags in autocomplete argv parsing --- internal/cliflags/flags.go | 12 +++++++++++- internal/cliflags/flags_test.go | 14 ++++++++++++++ internal/job/command/flag/flag.go | 4 ++-- internal/secret/command/get.go | 3 --- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 6e735912..1d4b22e1 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -2,6 +2,15 @@ package cliflags import "strings" +var valueTakingFlags = map[string]struct{}{ + "-t": {}, + "--team": {}, + "-e": {}, + "--environment": {}, + "--config": {}, + "--run-name": {}, +} + // UniqueFlagValues returns unique values for a short/long CLI flag from args. // It supports forms: -e value, --environment value, -e=value, --environment=value. func UniqueFlagValues(args []string, shortFlag, longFlag string) []string { @@ -145,7 +154,8 @@ func HasSubCommandPath(args []string, parent string, subcommands ...string) bool break } if strings.HasPrefix(next, "-") { - if !strings.Contains(next, "=") && j+1 < len(args) { + _, takesValue := valueTakingFlags[next] + if takesValue && !strings.Contains(next, "=") && j+1 < len(args) { value := args[j+1] if value != "" && !strings.HasPrefix(value, "-") { j++ diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index c13d57ac..06222691 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -297,6 +297,13 @@ func TestHasSubCommandPath(t *testing.T) { subs: []string{"trigger"}, wantHas: true, }, + { + name: "matches with boolean flag between parent and subcommand", + args: []string{"nais", "app", "--verbose", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, { name: "matches one of many subcommands", args: []string{"nais", "valkey", "list"}, @@ -332,6 +339,13 @@ func TestHasSubCommandPath(t *testing.T) { subs: nil, wantHas: false, }, + { + name: "unknown flag does not consume following token", + args: []string{"nais", "app", "--unknown", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, } for _, tt := range tests { diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 86e0ee44..c28bc6a3 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -131,11 +131,11 @@ func jobNameFromCLIArgs(argv []string) string { return "" } - if strings.HasPrefix(arg, "--team=") || strings.HasPrefix(arg, "--environment=") || strings.HasPrefix(arg, "--config=") { + if strings.HasPrefix(arg, "--team=") || strings.HasPrefix(arg, "--environment=") || strings.HasPrefix(arg, "--config=") || strings.HasPrefix(arg, "--run-name=") { continue } - if arg == "-t" || arg == "--team" || arg == "-e" || arg == "--environment" || arg == "--config" { + if arg == "-t" || arg == "--team" || arg == "-e" || arg == "--environment" || arg == "--config" || arg == "--run-name" { i++ continue } diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index 37d5cb42..1066b30d 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -42,9 +42,6 @@ func get(parentFlags *flag.Secret) *naistrix.Command { return err } providedEnvironment := string(f.Environment) - if providedEnvironment == "" { - return fmt.Errorf("exactly one environment must be specified") - } if err := validation.CheckEnvironment(providedEnvironment); err != nil { return err } From c637b6a70295aec960926ed3fd0766d11abdf27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 23:14:05 +0100 Subject: [PATCH 19/28] fix: finalize strict env requirement for secrets get --- internal/secret/command/environment.go | 28 ------- internal/secret/command/environment_test.go | 87 --------------------- internal/secret/command/get.go | 2 +- 3 files changed, 1 insertion(+), 116 deletions(-) delete mode 100644 internal/secret/command/environment_test.go diff --git a/internal/secret/command/environment.go b/internal/secret/command/environment.go index 85819e92..a80afbef 100644 --- a/internal/secret/command/environment.go +++ b/internal/secret/command/environment.go @@ -3,38 +3,10 @@ package command import ( "fmt" "os" - "slices" - "sort" - "strings" "github.com/nais/cli/internal/cliflags" ) -func selectSecretEnvironment(team, name, provided string, envs []string) (string, error) { - if provided != "" { - if slices.Contains(envs, provided) { - return provided, nil - } - - if len(envs) == 0 { - return "", fmt.Errorf("secret %q not found in team %q", name, team) - } - - sort.Strings(envs) - return "", fmt.Errorf("secret %q does not exist in environment %q; available environments: %s", name, provided, strings.Join(envs, ", ")) - } - - switch len(envs) { - case 0: - return "", fmt.Errorf("secret %q not found in team %q", name, team) - case 1: - return envs[0], nil - default: - sort.Strings(envs) - return "", fmt.Errorf("secret %q exists in multiple environments (%s); specify --environment/-e on the command, e.g. nais secrets get -t %s %s -e <%s>", name, strings.Join(envs, ", "), team, name, envs[0]) - } -} - func validateSingleEnvironmentFlagUsage() error { if countEnvironmentFlagsInCLIArgs() > 1 { return fmt.Errorf("only one --environment/-e flag may be provided") diff --git a/internal/secret/command/environment_test.go b/internal/secret/command/environment_test.go deleted file mode 100644 index fa19acdf..00000000 --- a/internal/secret/command/environment_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package command - -import "testing" - -func TestSelectSecretEnvironment(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - team string - secret string - provided string - envs []string - wantEnv string - wantError string - }{ - { - name: "provided environment exists", - team: "nais", - secret: "my-secret", - provided: "dev-gcp", - envs: []string{"dev-gcp", "prod-gcp"}, - wantEnv: "dev-gcp", - }, - { - name: "provided environment missing with alternatives", - team: "nais", - secret: "my-secret", - provided: "staging-gcp", - envs: []string{"prod-gcp", "dev-gcp"}, - wantError: "secret \"my-secret\" does not exist in environment \"staging-gcp\"; available environments: dev-gcp, prod-gcp", - }, - { - name: "provided environment missing and secret absent", - team: "nais", - secret: "my-secret", - provided: "dev-gcp", - envs: nil, - wantError: "secret \"my-secret\" not found in team \"nais\"", - }, - { - name: "no provided and no environments", - team: "nais", - secret: "my-secret", - envs: nil, - wantError: "secret \"my-secret\" not found in team \"nais\"", - }, - { - name: "no provided and one environment", - team: "nais", - secret: "my-secret", - envs: []string{"dev-gcp"}, - wantEnv: "dev-gcp", - }, - { - name: "no provided and multiple environments", - team: "nais", - secret: "my-secret", - envs: []string{"prod-gcp", "dev-gcp"}, - wantError: "secret \"my-secret\" exists in multiple environments (dev-gcp, prod-gcp); specify --environment/-e on the command, e.g. nais secrets get -t nais my-secret -e ", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - gotEnv, err := selectSecretEnvironment(tt.team, tt.secret, tt.provided, tt.envs) - if tt.wantError != "" { - if err == nil { - t.Fatalf("expected error %q, got nil", tt.wantError) - } - if err.Error() != tt.wantError { - t.Fatalf("error = %q, want %q", err.Error(), tt.wantError) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotEnv != tt.wantEnv { - t.Fatalf("env = %q, want %q", gotEnv, tt.wantEnv) - } - }) - } -} diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index 1066b30d..1aafb9f4 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -34,7 +34,7 @@ func get(parentFlags *flag.Secret) *naistrix.Command { return &naistrix.Command{ Name: "get", Title: "Get details about a secret.", - Description: "This command shows details about a secret, including its keys, workloads using it, and last modification info. Use --with-values to also fetch and display the actual secret values (access is logged for auditing).", + Description: "This command shows details about a secret, including its keys, workloads using it, and last modification info. The --environment/-e flag is required. Use --with-values to also fetch and display the actual secret values (access is logged for auditing).", Flags: f, Args: defaultArgs, ValidateFunc: func(_ context.Context, args *naistrix.Arguments) error { From cdac14f8e066864783ae3889c253be0d99480402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 23:31:32 +0100 Subject: [PATCH 20/28] refactor: harden autocomplete team and command parsing --- internal/app/command/flag/flag.go | 27 ++++++------------------ internal/cliflags/flags.go | 27 +++++++++++++++++------- internal/cliflags/flags_test.go | 22 +++++++++++++++++++ internal/job/command/flag/flag.go | 27 ++++++------------------ internal/opensearch/command/flag/flag.go | 7 +++++- internal/valkey/command/flag/flag.go | 7 +++++- 6 files changed, 65 insertions(+), 52 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 63d6535e..90382d0f 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "reflect" "strings" "time" @@ -72,25 +71,6 @@ func appTeamFromFlags(flags any) string { case *App: return string(f.Team) default: - v := reflect.ValueOf(flags) - if !v.IsValid() { - return "" - } - if v.Kind() == reflect.Pointer { - if v.IsNil() { - return "" - } - v = v.Elem() - } - if v.Kind() != reflect.Struct { - return "" - } - - teamField := v.FieldByName("Team") - if teamField.IsValid() && teamField.Kind() == reflect.String { - return teamField.String() - } - return "" } } @@ -112,7 +92,12 @@ func appNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isRestartCompletionFromCLIArgs() bool { - return cliflags.HasSubCommandPath(os.Args, "app", "restart") + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "app", + []string{"-t", "--team", "-e", "--environment", "--config"}, + "restart", + ) } func appNameFromCLIArgs(argv []string) string { diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 1d4b22e1..863314a8 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -2,13 +2,13 @@ package cliflags import "strings" -var valueTakingFlags = map[string]struct{}{ - "-t": {}, - "--team": {}, - "-e": {}, - "--environment": {}, - "--config": {}, - "--run-name": {}, +var defaultValueTakingFlags = []string{ + "-t", + "--team", + "-e", + "--environment", + "--config", + "--run-name", } // UniqueFlagValues returns unique values for a short/long CLI flag from args. @@ -131,6 +131,12 @@ func FirstFlagValue(args []string, shortFlag, longFlag string) string { // HasSubCommandPath reports whether args contain a command path where `parent` // is followed by one of the provided subcommands as the next non-flag token. func HasSubCommandPath(args []string, parent string, subcommands ...string) bool { + return HasSubCommandPathWithValueFlags(args, parent, defaultValueTakingFlags, subcommands...) +} + +// HasSubCommandPathWithValueFlags is like HasSubCommandPath, but lets callers +// define which flags consume the next token as a value. +func HasSubCommandPathWithValueFlags(args []string, parent string, valueTakingFlags []string, subcommands ...string) bool { if len(subcommands) == 0 { return false } @@ -140,6 +146,11 @@ func HasSubCommandPath(args []string, parent string, subcommands ...string) bool allowed[sub] = struct{}{} } + consumesValue := make(map[string]struct{}, len(valueTakingFlags)) + for _, f := range valueTakingFlags { + consumesValue[f] = struct{}{} + } + for i := range args { if args[i] == "--" { break @@ -154,7 +165,7 @@ func HasSubCommandPath(args []string, parent string, subcommands ...string) bool break } if strings.HasPrefix(next, "-") { - _, takesValue := valueTakingFlags[next] + _, takesValue := consumesValue[next] if takesValue && !strings.Contains(next, "=") && j+1 < len(args) { value := args[j+1] if value != "" && !strings.HasPrefix(value, "-") { diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index 06222691..ee35843b 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -359,3 +359,25 @@ func TestHasSubCommandPath(t *testing.T) { }) } } + +func TestHasSubCommandPathWithValueFlags(t *testing.T) { + t.Parallel() + + t.Run("consumes only configured value-taking flags", func(t *testing.T) { + t.Parallel() + + args := []string{"nais", "job", "--run-name", "manual-run", "trigger"} + if !HasSubCommandPathWithValueFlags(args, "job", []string{"--run-name"}, "trigger") { + t.Fatalf("expected true when --run-name is configured as value-taking flag") + } + }) + + t.Run("does not consume unknown boolean flag", func(t *testing.T) { + t.Parallel() + + args := []string{"nais", "app", "--verbose", "restart"} + if !HasSubCommandPathWithValueFlags(args, "app", []string{"--team"}, "restart") { + t.Fatalf("expected true when unknown flag does not consume next token") + } + }) +} diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index c28bc6a3..427976c2 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "reflect" "strings" "time" @@ -69,25 +68,6 @@ func jobTeamFromFlags(flags any) string { case *Job: return string(f.Team) default: - v := reflect.ValueOf(flags) - if !v.IsValid() { - return "" - } - if v.Kind() == reflect.Pointer { - if v.IsNil() { - return "" - } - v = v.Elem() - } - if v.Kind() != reflect.Struct { - return "" - } - - teamField := v.FieldByName("Team") - if teamField.IsValid() && teamField.Kind() == reflect.String { - return teamField.String() - } - return "" } } @@ -107,7 +87,12 @@ func jobNameForEnvironmentCompletion(args *naistrix.Arguments) string { } func isTriggerCompletionFromCLIArgs() bool { - return cliflags.HasSubCommandPath(os.Args, "job", "trigger") + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "job", + []string{"-t", "--team", "-e", "--environment", "--config", "--run-name"}, + "trigger", + ) } func jobNameFromCLIArgs(argv []string) string { diff --git a/internal/opensearch/command/flag/flag.go b/internal/opensearch/command/flag/flag.go index 24769292..bc21aa9e 100644 --- a/internal/opensearch/command/flag/flag.go +++ b/internal/opensearch/command/flag/flag.go @@ -40,7 +40,12 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st } func isInstanceEnvironmentCompletionFromCLIArgs() bool { - return cliflags.HasSubCommandPath(os.Args, "opensearch", "credentials", "delete", "get", "list", "update") + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "opensearch", + []string{"-t", "--team", "-e", "--environment", "--config"}, + "credentials", "delete", "get", "list", "update", + ) } func opensearchCredentialEnvironments(ctx context.Context, team string) ([]string, error) { diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 653affc7..56756437 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -105,7 +105,12 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st } func isInstanceEnvironmentCompletionFromCLIArgs() bool { - return cliflags.HasSubCommandPath(os.Args, "valkey", "credentials", "delete", "get", "list", "update") + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "valkey", + []string{"-t", "--team", "-e", "--environment", "--config"}, + "credentials", "delete", "get", "list", "update", + ) } func valkeyCredentialEnvironments(ctx context.Context, team string) ([]string, error) { From 5bd761db44d52e928e19258a0a7382cf2e04415b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 23:56:25 +0100 Subject: [PATCH 21/28] refactor: improve autocomplete fallback and arg parsing --- internal/app/command/flag/flag.go | 45 +++------------------ internal/cliflags/flags.go | 50 +++++++++++++++++++++++ internal/cliflags/flags_test.go | 59 ++++++++++++++++++++++++++++ internal/job/command/flag/flag.go | 49 +++++------------------ internal/secret/command/flag/flag.go | 29 +++++--------- 5 files changed, 134 insertions(+), 98 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 90382d0f..e45f5927 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strings" "time" activityutil "github.com/nais/cli/internal/activity" @@ -28,7 +27,7 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument team = cliTeam } if team == "" { - return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + return autoCompleteEnvironments(ctx) } if appName := appNameForEnvironmentCompletion(args); appName != "" { @@ -101,43 +100,11 @@ func isRestartCompletionFromCLIArgs() bool { } func appNameFromCLIArgs(argv []string) string { - seenRestart := false - - for i := 0; i < len(argv); i++ { - arg := argv[i] - - if arg == "restart" { - seenRestart = true - continue - } - if !seenRestart { - continue - } - - if arg == "--" { - if i+1 < len(argv) { - return argv[i+1] - } - return "" - } - - if strings.HasPrefix(arg, "--team=") || strings.HasPrefix(arg, "--environment=") || strings.HasPrefix(arg, "--config=") { - continue - } - - if arg == "-t" || arg == "--team" || arg == "-e" || arg == "--environment" || arg == "--config" { - i++ - continue - } - - if strings.HasPrefix(arg, "-") { - continue - } - - return arg - } - - return "" + return cliflags.PositionalArgAfterSubcommand( + argv, + "restart", + []string{"-t", "--team", "-e", "--environment", "--config"}, + ) } type instances []string diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 863314a8..60670788 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -181,3 +181,53 @@ func HasSubCommandPathWithValueFlags(args []string, parent string, valueTakingFl return false } + +// PositionalArgAfterSubcommand returns the first positional argument after a +// specific subcommand, skipping known value-taking flags and their values. +func PositionalArgAfterSubcommand(args []string, subcommand string, valueTakingFlags []string) string { + consumesValue := make(map[string]struct{}, len(valueTakingFlags)) + for _, f := range valueTakingFlags { + consumesValue[f] = struct{}{} + } + + seenSubcommand := false + for i := 0; i < len(args); i++ { + arg := args[i] + + if arg == subcommand { + seenSubcommand = true + continue + } + if !seenSubcommand { + continue + } + + if arg == "--" { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + + for f := range consumesValue { + if strings.HasPrefix(arg, f+"=") { + goto nextArg + } + } + + if _, ok := consumesValue[arg]; ok { + i++ + continue + } + + if strings.HasPrefix(arg, "-") { + continue + } + + return arg + + nextArg: + } + + return "" +} diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index ee35843b..f4d11f83 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -381,3 +381,62 @@ func TestHasSubCommandPathWithValueFlags(t *testing.T) { } }) } + +func TestPositionalArgAfterSubcommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + subcommand string + valueFlags []string + wantPositional string + }{ + { + name: "returns positional after subcommand", + args: []string{"nais", "app", "restart", "my-app"}, + subcommand: "restart", + valueFlags: []string{"-t", "--team", "-e", "--environment", "--config"}, + wantPositional: "my-app", + }, + { + name: "skips value-taking flags and values", + args: []string{"nais", "job", "trigger", "--run-name", "manual", "my-job"}, + subcommand: "trigger", + valueFlags: []string{"-t", "--team", "-e", "--environment", "--config", "--run-name"}, + wantPositional: "my-job", + }, + { + name: "skips equals form for value-taking flags", + args: []string{"nais", "job", "trigger", "--run-name=manual", "my-job"}, + subcommand: "trigger", + valueFlags: []string{"--run-name"}, + wantPositional: "my-job", + }, + { + name: "supports end-of-flags marker", + args: []string{"nais", "app", "restart", "--", "my-app"}, + subcommand: "restart", + valueFlags: []string{"-t", "--team", "-e", "--environment", "--config"}, + wantPositional: "my-app", + }, + { + name: "returns empty when none found", + args: []string{"nais", "app", "restart", "--team", "nais"}, + subcommand: "restart", + valueFlags: []string{"--team"}, + wantPositional: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := PositionalArgAfterSubcommand(tt.args, tt.subcommand, tt.valueFlags) + if got != tt.wantPositional { + t.Fatalf("PositionalArgAfterSubcommand() = %q, want %q", got, tt.wantPositional) + } + }) + } +} diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 427976c2..c711fbed 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strings" "time" "github.com/nais/cli/internal/cliflags" @@ -27,7 +26,11 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument team = cliTeam } if team == "" { - return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + envs, err := naisapi.GetAllEnvironments(ctx) + if err != nil { + return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) + } + return envs, "Available environments" } if jobName := jobNameForEnvironmentCompletion(args); jobName != "" { @@ -96,43 +99,11 @@ func isTriggerCompletionFromCLIArgs() bool { } func jobNameFromCLIArgs(argv []string) string { - seenTrigger := false - - for i := 0; i < len(argv); i++ { - arg := argv[i] - - if arg == "trigger" { - seenTrigger = true - continue - } - if !seenTrigger { - continue - } - - if arg == "--" { - if i+1 < len(argv) { - return argv[i+1] - } - return "" - } - - if strings.HasPrefix(arg, "--team=") || strings.HasPrefix(arg, "--environment=") || strings.HasPrefix(arg, "--config=") || strings.HasPrefix(arg, "--run-name=") { - continue - } - - if arg == "-t" || arg == "--team" || arg == "-e" || arg == "--environment" || arg == "--config" || arg == "--run-name" { - i++ - continue - } - - if strings.HasPrefix(arg, "-") { - continue - } - - return arg - } - - return "" + return cliflags.PositionalArgAfterSubcommand( + argv, + "trigger", + []string{"-t", "--team", "-e", "--environment", "--config", "--run-name"}, + ) } type Output string diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index a0b4777e..3bd245c2 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -19,6 +19,12 @@ type Secret struct { Environment Env `name:"environment" short:"e" usage:"Filter by environment."` } +type teamProvider interface { + GetTeam() string +} + +func (s *Secret) GetTeam() string { return string(s.Team) } + type Env string func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { @@ -42,10 +48,6 @@ func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ string, flags any) ([]string, string) { - type teamProvider interface { - GetTeam() string - } - tp, ok := flags.(teamProvider) if !ok || tp.GetTeam() == "" { return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." @@ -92,24 +94,11 @@ func (e *Environments) AutoComplete(ctx context.Context, _ *naistrix.Arguments, } func secretTeamFromFlags(flags any) string { - switch f := flags.(type) { - case *Get: - return string(f.Team) - case *Delete: - return string(f.Team) - case *Set: - return string(f.Team) - case *Unset: - return string(f.Team) - case *List: - return string(f.Team) - case *Activity: - return string(f.Team) - case *Secret: - return string(f.Team) - default: + tp, ok := flags.(teamProvider) + if !ok { return "" } + return tp.GetTeam() } type Output string From cb147e7abfaa7f3a36c2e54c33cc1d3c73de4143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Tue, 17 Mar 2026 23:59:42 +0100 Subject: [PATCH 22/28] refactor: standardize formatting in TestPositionalArgAfterSubcommand --- internal/cliflags/flags_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index f4d11f83..663f0005 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -386,11 +386,11 @@ func TestPositionalArgAfterSubcommand(t *testing.T) { t.Parallel() tests := []struct { - name string - args []string - subcommand string - valueFlags []string - wantPositional string + name string + args []string + subcommand string + valueFlags []string + wantPositional string }{ { name: "returns positional after subcommand", From d7989d98fdd3a49ad152ab3a824b695c41813166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Wed, 18 Mar 2026 00:31:42 +0100 Subject: [PATCH 23/28] refactor: remove duplicate env and team helper logic --- internal/job/list.go | 30 ++++++---------------------- internal/secret/command/flag/flag.go | 8 -------- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/internal/job/list.go b/internal/job/list.go index d8f990de..d4f030ed 100644 --- a/internal/job/list.go +++ b/internal/job/list.go @@ -106,32 +106,14 @@ func GetJobNames(ctx context.Context, team string, environments []string) ([]str } func TeamJobEnvironments(ctx context.Context, team string) ([]string, error) { - client, err := naisapi.GraphqlClient(ctx) - if err != nil { - return nil, err - } - - resp, err := gql.GetJobNames(ctx, client, team) - if err != nil { - return nil, err - } - - seen := make(map[string]struct{}) - ret := make([]string, 0) - for _, j := range resp.Team.Jobs.Nodes { - env := j.TeamEnvironment.Environment.Name - if _, ok := seen[env]; ok { - continue - } - seen[env] = struct{}{} - ret = append(ret, env) - } - - sort.Strings(ret) - return ret, nil + return jobEnvironments(ctx, team, "") } func JobEnvironments(ctx context.Context, team, jobName string) ([]string, error) { + return jobEnvironments(ctx, team, jobName) +} + +func jobEnvironments(ctx context.Context, team, jobName string) ([]string, error) { client, err := naisapi.GraphqlClient(ctx) if err != nil { return nil, err @@ -145,7 +127,7 @@ func JobEnvironments(ctx context.Context, team, jobName string) ([]string, error seen := make(map[string]struct{}) ret := make([]string, 0) for _, j := range resp.Team.Jobs.Nodes { - if j.Name != jobName { + if jobName != "" && j.Name != jobName { continue } env := j.TeamEnvironment.Environment.Name diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index 3bd245c2..46f8362a 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -135,8 +135,6 @@ type Get struct { Reason string `name:"reason" usage:"Reason for accessing secret values (min 10 chars). Used with --with-values."` } -func (g *Get) GetTeam() string { return string(g.Team) } - type Create struct { *Secret } @@ -147,8 +145,6 @@ type Delete struct { Yes bool `name:"yes" short:"y" usage:"Automatic yes to prompts; assume 'yes' as answer to all prompts and run non-interactively."` } -func (d *Delete) GetTeam() string { return string(d.Team) } - type Set struct { *Secret Environment GetEnv `name:"environment" short:"e" usage:"Filter by environment."` @@ -157,13 +153,9 @@ type Set struct { ValueFromStdin bool `name:"value-from-stdin" usage:"Read value from stdin."` } -func (s *Set) GetTeam() string { return string(s.Team) } - type Unset struct { *Secret Environment GetEnv `name:"environment" short:"e" usage:"Filter by environment."` Key string `name:"key" usage:"Name of the key to unset."` Yes bool `name:"yes" short:"y" usage:"Automatic yes to prompts; assume 'yes' as answer to all prompts and run non-interactively."` } - -func (u *Unset) GetTeam() string { return string(u.Team) } From dd0da136c697c9f0293f15486e525db8591c8733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Wed, 18 Mar 2026 00:43:54 +0100 Subject: [PATCH 24/28] fix: use CLI team fallback in secret get env completion --- internal/secret/command/flag/flag.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index 46f8362a..1afb3a90 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -49,19 +49,28 @@ type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ string, flags any) ([]string, string) { tp, ok := flags.(teamProvider) - if !ok || tp.GetTeam() == "" { + if !ok { + return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + } + + team := tp.GetTeam() + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam + } + + if team == "" { return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." } if args.Len() == 0 { - envs, err := secret.TeamSecretEnvironments(ctx, tp.GetTeam()) + envs, err := secret.TeamSecretEnvironments(ctx, team) if err == nil && len(envs) > 0 { return envs, "Available environments with secrets" } return autoCompleteEnvironments(ctx) } - envs, err := secret.SecretEnvironments(ctx, tp.GetTeam(), args.Get("name")) + envs, err := secret.SecretEnvironments(ctx, team, args.Get("name")) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) } From 0b1fa90cf10bd099ad8e3695b515a8b2a022e8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Wed, 18 Mar 2026 01:07:42 +0100 Subject: [PATCH 25/28] fix: improve env completion fallbacks and guidance text --- internal/app/command/flag/flag.go | 4 ++-- internal/opensearch/command/flag/flag.go | 4 ++-- internal/secret/command/flag/flag.go | 4 ++-- internal/valkey/command/flag/flag.go | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index e45f5927..720d354a 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -120,7 +120,7 @@ func (i *instances) AutoComplete(ctx context.Context, args *naistrix.Arguments, } if len(f.Team) == 0 { - return nil, "Please provide team to auto-complete instances. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete instances. 'nais config set team ', or '--team ' flag." } instances, err := app.GetApplicationInstances(ctx, string(f.Team), args.Get("name"), string(f.Environment)) @@ -169,7 +169,7 @@ type Env string func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { f := flags.(*Log) if len(f.Team) == 0 { - return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." } if args.Len() == 0 { diff --git a/internal/opensearch/command/flag/flag.go b/internal/opensearch/command/flag/flag.go index bc21aa9e..2f9af3a5 100644 --- a/internal/opensearch/command/flag/flag.go +++ b/internal/opensearch/command/flag/flag.go @@ -32,7 +32,7 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st if team != "" && isInstanceEnvironmentCompletionFromCLIArgs() { envs, err := opensearchCredentialEnvironments(ctx, team) - if err == nil { + if err == nil && len(envs) > 0 { return envs, "Available environments with OpenSearch instances" } } @@ -99,7 +99,7 @@ type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { f := flags.(*Get) if len(f.Team) == 0 { - return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." } if args.Len() == 0 { diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index 1afb3a90..e21c6802 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -50,7 +50,7 @@ type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ string, flags any) ([]string, string) { tp, ok := flags.(teamProvider) if !ok { - return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." } team := tp.GetTeam() @@ -59,7 +59,7 @@ func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ s } if team == "" { - return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." } if args.Len() == 0 { diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 56756437..9a3c67c4 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -97,7 +97,7 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st if team != "" && isInstanceEnvironmentCompletionFromCLIArgs() { envs, err := valkeyCredentialEnvironments(ctx, team) - if err == nil { + if err == nil && len(envs) > 0 { return envs, "Available environments with Valkey instances" } } From 135ff0275532eb03ab4ed6522645faf86da70046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Wed, 18 Mar 2026 09:49:26 +0100 Subject: [PATCH 26/28] feat: implement GetTeam method and refactor team retrieval logic across commands --- internal/app/command/flag/flag.go | 36 ++++++++++-------------- internal/cliflags/flags.go | 6 ++-- internal/job/command/flag/flag.go | 33 +++++++++------------- internal/opensearch/command/flag/flag.go | 2 +- internal/secret/command/get.go | 19 +++---------- internal/secret/secret.go | 4 +-- internal/valkey/command/flag/flag.go | 2 +- 7 files changed, 39 insertions(+), 63 deletions(-) diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index 720d354a..eb37eba7 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -19,6 +19,14 @@ type App struct { *flags.GlobalFlags Environment Environments `name:"environment" short:"e" usage:"Filter by environment."` } + +func (a *App) GetTeam() string { + if a == nil { + return "" + } + return string(a.Team) +} + type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { @@ -48,30 +56,16 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument return nil, "No environments with applications found for this team." } +type teamProvider interface { + GetTeam() string +} + func appTeamFromFlags(flags any) string { - switch f := flags.(type) { - case *Activity: - if f.App != nil && f.App.Team != "" { - return string(f.App.Team) - } - return string(f.Team) - case *Issues: - if f.App != nil && f.App.Team != "" { - return string(f.App.Team) - } - return string(f.Team) - case *List: - if f.App != nil && f.App.Team != "" { - return string(f.App.Team) - } - return string(f.Team) - case *Restart: - return string(f.Team) - case *App: - return string(f.Team) - default: + tp, ok := flags.(teamProvider) + if !ok { return "" } + return tp.GetTeam() } func appNameForEnvironmentCompletion(args *naistrix.Arguments) string { diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 60670788..5b3beaa7 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -8,7 +8,6 @@ var defaultValueTakingFlags = []string{ "-e", "--environment", "--config", - "--run-name", } // UniqueFlagValues returns unique values for a short/long CLI flag from args. @@ -191,6 +190,7 @@ func PositionalArgAfterSubcommand(args []string, subcommand string, valueTakingF } seenSubcommand := false +outer: for i := 0; i < len(args); i++ { arg := args[i] @@ -211,7 +211,7 @@ func PositionalArgAfterSubcommand(args []string, subcommand string, valueTakingF for f := range consumesValue { if strings.HasPrefix(arg, f+"=") { - goto nextArg + continue outer } } @@ -225,8 +225,6 @@ func PositionalArgAfterSubcommand(args []string, subcommand string, valueTakingF } return arg - - nextArg: } return "" diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index c711fbed..091fe1b8 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -18,6 +18,13 @@ type Job struct { Environment Environments `name:"environment" short:"e" usage:"Filter by environment."` } +func (j *Job) GetTeam() string { + if j == nil { + return "" + } + return string(j.Team) +} + type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { @@ -51,28 +58,16 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument return nil, "No environments with jobs found for this team." } +type teamProvider interface { + GetTeam() string +} + func jobTeamFromFlags(flags any) string { - switch f := flags.(type) { - case *Activity: - if f.Job != nil && f.Job.Team != "" { - return string(f.Job.Team) - } - return string(f.Team) - case *Issues: - if f.Job != nil && f.Job.Team != "" { - return string(f.Job.Team) - } - return string(f.Team) - case *List: - if f.Job != nil && f.Job.Team != "" { - return string(f.Job.Team) - } - return string(f.Team) - case *Job: - return string(f.Team) - default: + tp, ok := flags.(teamProvider) + if !ok { return "" } + return tp.GetTeam() } func jobNameForEnvironmentCompletion(args *naistrix.Arguments) string { diff --git a/internal/opensearch/command/flag/flag.go b/internal/opensearch/command/flag/flag.go index 2f9af3a5..f251b58f 100644 --- a/internal/opensearch/command/flag/flag.go +++ b/internal/opensearch/command/flag/flag.go @@ -130,7 +130,7 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument f, ok := flags.(*List) if ok && f.Team != "" { envs, err := opensearchCredentialEnvironments(ctx, f.Team) - if err == nil { + if err == nil && len(envs) > 0 { return envs, "Available environments with OpenSearch instances" } } diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index 1aafb9f4..b6a3c958 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -13,17 +13,10 @@ import ( "github.com/pterm/pterm" ) -// Entry represents a key-value pair in a secret. When values are not fetched, -// the Value field is empty and omitted from JSON output. -type Entry struct { - Key string `json:"key"` - Value string `json:"value,omitempty"` -} - type SecretDetail struct { Name string `json:"name"` Environment string `json:"environment"` - Data []Entry `json:"data"` + Data []secret.Entry `json:"data"` LastModified secret.LastModified `json:"lastModified"` ModifiedBy string `json:"modifiedBy,omitempty"` Workloads []string `json:"workloads,omitempty"` @@ -90,9 +83,9 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. return fmt.Errorf("fetching secret: %w", err) } - entries := make([]Entry, len(existing.Keys)) + entries := make([]secret.Entry, len(existing.Keys)) for i, k := range existing.Keys { - entries[i] = Entry{Key: k} + entries[i] = secret.Entry{Key: k} } if withValues { @@ -155,11 +148,7 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. if len(entries) > 0 { var data [][]string if withValues { - secretEntries := make([]secret.Entry, len(entries)) - for i, e := range entries { - secretEntries[i] = secret.Entry{Key: e.Key, Value: e.Value} - } - data = secret.FormatDataWithValues(secretEntries) + data = secret.FormatDataWithValues(entries) } else { data = secret.FormatData(existing.Keys) } diff --git a/internal/secret/secret.go b/internal/secret/secret.go index 16c868cc..b104a19d 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -365,8 +365,8 @@ func FormatDetails(metadata Metadata, s *gql.GetSecretTeamEnvironmentSecret) [][ // Entry represents a key-value pair in a secret. When values have not been // fetched, Value is empty. type Entry struct { - Key string - Value string + Key string `json:"key"` + Value string `json:"value,omitempty"` } // FormatData formats secret keys as a key-only table for pterm rendering. diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 9a3c67c4..04a58fb2 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -25,7 +25,7 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument f, ok := flags.(*List) if ok && f.Team != "" { envs, err := valkeyCredentialEnvironments(ctx, f.Team) - if err == nil { + if err == nil && len(envs) > 0 { return envs, "Available environments with Valkey instances" } } From 3109c199c1e9aa3d38a2eb181f6a4f0d6b108c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Wed, 18 Mar 2026 10:07:00 +0100 Subject: [PATCH 27/28] fix: enhance subcommand path detection to avoid misinterpreting flag values --- internal/cliflags/flags.go | 14 +++++++++++++- internal/cliflags/flags_test.go | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 5b3beaa7..d7664c29 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -150,10 +150,22 @@ func HasSubCommandPathWithValueFlags(args []string, parent string, valueTakingFl consumesValue[f] = struct{}{} } - for i := range args { + for i := 0; i < len(args); i++ { if args[i] == "--" { break } + // Skip value-taking flags and their values so that a flag value equal + // to parent is not mistaken for a command token (e.g. --team app app restart). + if strings.HasPrefix(args[i], "-") { + if _, takesValue := consumesValue[args[i]]; takesValue && + !strings.Contains(args[i], "=") && + i+1 < len(args) && + args[i+1] != "" && + !strings.HasPrefix(args[i+1], "-") { + i++ // skip the value token + } + continue + } if args[i] != parent { continue } diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index 663f0005..e8a76148 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -346,6 +346,20 @@ func TestHasSubCommandPath(t *testing.T) { subs: []string{"restart"}, wantHas: true, }, + { + name: "parent token as flag value is not mistaken for command", + args: []string{"nais", "--team", "app", "app", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, + { + name: "returns false when parent only appears as flag value", + args: []string{"nais", "--team", "app", "secret", "list"}, + parent: "app", + subs: []string{"restart"}, + wantHas: false, + }, } for _, tt := range tests { From a08fbd9c9a1d967f0fca792124502e074d8af645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Bj=C3=B8rnstad?= Date: Wed, 18 Mar 2026 10:47:34 +0100 Subject: [PATCH 28/28] feat: enhance environment handling with unique sorting and add tests for uniqueness --- internal/job/list.go | 3 ++ internal/secret/command/flag/flag.go | 8 +++-- internal/secret/secret.go | 19 ++++++++---- internal/secret/secret_test.go | 46 ++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/internal/job/list.go b/internal/job/list.go index d4f030ed..1e5722b2 100644 --- a/internal/job/list.go +++ b/internal/job/list.go @@ -113,6 +113,9 @@ func JobEnvironments(ctx context.Context, team, jobName string) ([]string, error return jobEnvironments(ctx, team, jobName) } +// jobEnvironments fetches all jobs for the team and returns the unique sorted +// environments, optionally filtered to a specific job name client-side. +// Server-side name filtering is not currently supported by the API. func jobEnvironments(ctx context.Context, team, jobName string) ([]string, error) { client, err := naisapi.GraphqlClient(ctx) if err != nil { diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index e21c6802..04958ba1 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -42,9 +42,11 @@ func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, return autoCompleteEnvironments(ctx) } -// GetEnv is like Env but provides context-aware autocomplete: when a secret -// name argument has been provided, only environments where that secret exists -// are suggested. +// GetEnv is like Env but provides context-aware autocomplete: +// - when no secret name argument is provided, environments where the team has +// at least one secret are suggested (falling back to all platform environments) +// - when a secret name argument has been provided, only environments where +// that specific secret exists are suggested. type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ string, flags any) ([]string, string) { diff --git a/internal/secret/secret.go b/internal/secret/secret.go index b104a19d..71eeaedc 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -71,26 +71,33 @@ func SecretEnvironments(ctx context.Context, teamSlug, name string) ([]string, e return envs, nil } -// TeamSecretEnvironments returns unique environments where the team has one or more secrets. +// TeamSecretEnvironments returns unique sorted environments where the team has one or more secrets. func TeamSecretEnvironments(ctx context.Context, teamSlug string) ([]string, error) { all, err := getAllSecretEnvironments(ctx, teamSlug) if err != nil { return nil, err } - seen := make(map[string]struct{}) - ret := make([]string, 0) + names := make([]string, 0, len(all)) for _, s := range all { - env := s.TeamEnvironment.Environment.Name + names = append(names, s.TeamEnvironment.Environment.Name) + } + return uniqueSortedEnvironments(names), nil +} + +// uniqueSortedEnvironments returns a deduplicated, sorted slice of environment names. +func uniqueSortedEnvironments(envs []string) []string { + seen := make(map[string]struct{}) + ret := make([]string, 0, len(envs)) + for _, env := range envs { if _, ok := seen[env]; ok { continue } seen[env] = struct{}{} ret = append(ret, env) } - sort.Strings(ret) - return ret, nil + return ret } func getAllSecretEnvironments(ctx context.Context, teamSlug string) ([]gql.GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret, error) { diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go index 5a85bee3..d43e99fe 100644 --- a/internal/secret/secret_test.go +++ b/internal/secret/secret_test.go @@ -298,3 +298,49 @@ func TestLastModified_MarshalJSON(t *testing.T) { t.Errorf("MarshalJSON() zero = %s, want %q", data, "") } } + +func TestUniqueSortedEnvironments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + envs []string + want []string + }{ + { + name: "deduplicates and sorts", + envs: []string{"prod-gcp", "dev-gcp", "prod-gcp", "staging", "dev-gcp"}, + want: []string{"dev-gcp", "prod-gcp", "staging"}, + }, + { + name: "empty input returns empty slice", + envs: []string{}, + want: []string{}, + }, + { + name: "single entry", + envs: []string{"dev-gcp"}, + want: []string{"dev-gcp"}, + }, + { + name: "all duplicates", + envs: []string{"dev-gcp", "dev-gcp", "dev-gcp"}, + want: []string{"dev-gcp"}, + }, + { + name: "already sorted unique", + envs: []string{"dev-gcp", "prod-gcp", "staging"}, + want: []string{"dev-gcp", "prod-gcp", "staging"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := uniqueSortedEnvironments(tt.envs) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("uniqueSortedEnvironments() = %v, want %v", got, tt.want) + } + }) + } +}