Skip to content

Commit 7e2b111

Browse files
committed
fix(cli): make -y flag properly support non-interactive mode in create
The -y flag was documented as 'Bypass prompts' but only bypassed the final 'Confirm create?' prompt. All other interactive prompts (preset selection, parameter input, template selection, workspace name) would still appear, breaking CI/CD and non-interactive usage. Changes: - Add isNonInteractive(inv) helper in create.go - Workspace name: error if not provided with -y - Template selection: auto-select if exactly 1 template exists, error if multiple - Preset selection: skip presets entirely (default to none) with -y - Parameters: use default values automatically, error if required param has no default - External auth: error immediately if required auth not authenticated - Update SkipPromptOption() description to accurately describe behavior - Regenerate golden files for updated flag description The -y flag now enables true non-interactive mode for `coder create`.
1 parent 67024b8 commit 7e2b111

File tree

64 files changed

+233
-119
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+233
-119
lines changed

cli/cliui/externalauth.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ import (
77
"time"
88

99
"github.com/briandowns/spinner"
10+
"golang.org/x/xerrors"
1011

1112
"github.com/coder/coder/v2/codersdk"
1213
)
1314

1415
type ExternalAuthOptions struct {
1516
Fetch func(context.Context) ([]codersdk.TemplateVersionExternalAuth, error)
1617
FetchInterval time.Duration
18+
// NonInteractive, when true, will cause the function to fail immediately
19+
// if required external authentication is not already complete, rather than
20+
// waiting for the user to authenticate via browser.
21+
NonInteractive bool
1722
}
1823

1924
func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOptions) error {
@@ -41,6 +46,14 @@ func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOption
4146
continue
4247
}
4348

49+
// In non-interactive mode, fail immediately if required auth is missing.
50+
if opts.NonInteractive {
51+
return xerrors.Errorf(
52+
"external authentication with %q is required but not authenticated; "+
53+
"authenticate via browser first or ensure the template marks it as optional",
54+
auth.DisplayName)
55+
}
56+
4457
_, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.DisplayName, auth.AuthenticateURL)
4558

4659
ticker.Reset(opts.FetchInterval)

cli/cliui/prompt.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,14 @@ type PromptOptions struct {
3131

3232
const skipPromptFlag = "yes"
3333

34-
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
35-
// prompts.
34+
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to run
35+
// in non-interactive mode. When enabled, prompts are skipped, default values
36+
// are used where available, and the command fails if required inputs are missing.
3637
func SkipPromptOption() serpent.Option {
3738
return serpent.Option{
3839
Flag: skipPromptFlag,
3940
FlagShorthand: "y",
40-
Description: "Bypass prompts.",
41+
Description: "Run in non-interactive mode. Accepts default values and fails on required inputs.",
4142
// Discard
4243
Value: serpent.BoolOf(new(bool)),
4344
}

cli/create.go

Lines changed: 96 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ const PresetNone = "none"
2929

3030
var ErrNoPresetFound = xerrors.New("no preset found")
3131

32+
// isNonInteractive checks if the command is running in non-interactive mode
33+
// (i.e., the --yes/-y flag was provided).
34+
func isNonInteractive(inv *serpent.Invocation) bool {
35+
if inv.ParsedFlags().Lookup("yes") != nil {
36+
if skip, _ := inv.ParsedFlags().GetBool("yes"); skip {
37+
return true
38+
}
39+
}
40+
return false
41+
}
42+
3243
type CreateOptions struct {
3344
BeforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error
3445
AfterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error
@@ -75,6 +86,9 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
7586
}
7687

7788
if workspaceName == "" {
89+
if isNonInteractive(inv) {
90+
return xerrors.New("workspace name is required in non-interactive mode; provide it as an argument")
91+
}
7892
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
7993
Text: "Specify a name for your workspace:",
8094
Validate: func(workspaceName string) error {
@@ -122,62 +136,76 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
122136
var templateVersionID uuid.UUID
123137
switch {
124138
case templateName == "":
125-
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
126-
127139
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
128140
if err != nil {
129141
return err
130142
}
131143

132-
slices.SortFunc(templates, func(a, b codersdk.Template) int {
133-
return slice.Descending(a.ActiveUserCount, b.ActiveUserCount)
134-
})
135-
136-
templateNames := make([]string, 0, len(templates))
137-
templateByName := make(map[string]codersdk.Template, len(templates))
144+
// In non-interactive mode, auto-select if only one template exists,
145+
// otherwise require explicit --template flag.
146+
if isNonInteractive(inv) {
147+
if len(templates) == 0 {
148+
return xerrors.New("no templates available")
149+
}
150+
if len(templates) > 1 {
151+
return xerrors.New("multiple templates available; use the --template flag to specify which one")
152+
}
153+
// Only one template available - auto-select it
154+
template = templates[0]
155+
templateVersionID = template.ActiveVersionID
156+
} else {
157+
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
158+
159+
slices.SortFunc(templates, func(a, b codersdk.Template) int {
160+
return slice.Descending(a.ActiveUserCount, b.ActiveUserCount)
161+
})
138162

139-
// If more than 1 organization exists in the list of templates,
140-
// then include the organization name in the select options.
141-
uniqueOrganizations := make(map[uuid.UUID]bool)
142-
for _, template := range templates {
143-
uniqueOrganizations[template.OrganizationID] = true
144-
}
163+
templateNames := make([]string, 0, len(templates))
164+
templateByName := make(map[string]codersdk.Template, len(templates))
145165

146-
for _, template := range templates {
147-
templateName := template.Name
148-
if len(uniqueOrganizations) > 1 {
149-
templateName += cliui.Placeholder(
150-
fmt.Sprintf(
151-
" (%s)",
152-
template.OrganizationName,
153-
),
154-
)
166+
// If more than 1 organization exists in the list of templates,
167+
// then include the organization name in the select options.
168+
uniqueOrganizations := make(map[uuid.UUID]bool)
169+
for _, tpl := range templates {
170+
uniqueOrganizations[tpl.OrganizationID] = true
155171
}
156172

157-
if template.ActiveUserCount > 0 {
158-
templateName += cliui.Placeholder(
159-
fmt.Sprintf(
160-
" used by %s",
161-
formatActiveDevelopers(template.ActiveUserCount),
162-
),
163-
)
173+
for _, tpl := range templates {
174+
tplName := tpl.Name
175+
if len(uniqueOrganizations) > 1 {
176+
tplName += cliui.Placeholder(
177+
fmt.Sprintf(
178+
" (%s)",
179+
tpl.OrganizationName,
180+
),
181+
)
182+
}
183+
184+
if tpl.ActiveUserCount > 0 {
185+
tplName += cliui.Placeholder(
186+
fmt.Sprintf(
187+
" used by %s",
188+
formatActiveDevelopers(tpl.ActiveUserCount),
189+
),
190+
)
191+
}
192+
193+
templateNames = append(templateNames, tplName)
194+
templateByName[tplName] = tpl
164195
}
165196

166-
templateNames = append(templateNames, templateName)
167-
templateByName[templateName] = template
168-
}
197+
// Move the cursor up a single line for nicer display!
198+
option, err := cliui.Select(inv, cliui.SelectOptions{
199+
Options: templateNames,
200+
HideSearch: true,
201+
})
202+
if err != nil {
203+
return err
204+
}
169205

170-
// Move the cursor up a single line for nicer display!
171-
option, err := cliui.Select(inv, cliui.SelectOptions{
172-
Options: templateNames,
173-
HideSearch: true,
174-
})
175-
if err != nil {
176-
return err
206+
template = templateByName[option]
207+
templateVersionID = template.ActiveVersionID
177208
}
178-
179-
template = templateByName[option]
180-
templateVersionID = template.ActiveVersionID
181209
case sourceWorkspace.LatestBuild.TemplateVersionID != uuid.Nil:
182210
template, err = client.Template(inv.Context(), sourceWorkspace.TemplateID)
183211
if err != nil {
@@ -297,19 +325,28 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
297325
if !errors.Is(err, ErrNoPresetFound) {
298326
return xerrors.Errorf("unable to resolve preset: %w", err)
299327
}
300-
// If no preset found, prompt the user to choose a preset
301-
if preset, err = promptPresetSelection(inv, tvPresets); err != nil {
302-
return xerrors.Errorf("unable to prompt user for preset: %w", err)
328+
// No preset found - in non-interactive mode, skip presets instead of prompting
329+
if isNonInteractive(inv) {
330+
// Leave preset as nil, effectively skipping presets
331+
preset = nil
332+
} else {
333+
// Interactive mode - prompt the user to choose a preset
334+
if preset, err = promptPresetSelection(inv, tvPresets); err != nil {
335+
return xerrors.Errorf("unable to prompt user for preset: %w", err)
336+
}
303337
}
304338
}
305339

306-
// Convert preset parameters into workspace build parameters
307-
presetParameters = presetParameterAsWorkspaceBuildParameters(preset.Parameters)
308-
// Inform the user which preset was applied and its parameters
309-
displayAppliedPreset(inv, preset, presetParameters)
310-
} else {
340+
// Convert preset parameters into workspace build parameters (if a preset was selected)
341+
if preset != nil {
342+
presetParameters = presetParameterAsWorkspaceBuildParameters(preset.Parameters)
343+
// Inform the user which preset was applied and its parameters
344+
displayAppliedPreset(inv, preset, presetParameters)
345+
}
346+
}
347+
if preset == nil {
311348
// Inform the user that no preset was applied
312-
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
349+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.Bold("No preset applied."))
313350
}
314351

315352
if opts.BeforeCreate != nil {
@@ -330,6 +367,8 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
330367
RichParameterDefaults: cliBuildParameterDefaults,
331368

332369
SourceWorkspaceParameters: sourceWorkspaceParameters,
370+
371+
NonInteractive: isNonInteractive(inv),
333372
})
334373
if err != nil {
335374
return xerrors.Errorf("prepare build: %w", err)
@@ -460,6 +499,8 @@ type prepWorkspaceBuildArgs struct {
460499
RichParameters []codersdk.WorkspaceBuildParameter
461500
RichParameterFile string
462501
RichParameterDefaults []codersdk.WorkspaceBuildParameter
502+
503+
NonInteractive bool
463504
}
464505

465506
// resolvePreset returns the preset matching the given presetName (if specified),
@@ -562,7 +603,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
562603
WithPromptRichParameters(args.PromptRichParameters).
563604
WithRichParameters(args.RichParameters).
564605
WithRichParametersFile(parameterFile).
565-
WithRichParametersDefaults(args.RichParameterDefaults)
606+
WithRichParametersDefaults(args.RichParameterDefaults).
607+
WithNonInteractive(args.NonInteractive)
566608
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
567609
if err != nil {
568610
return nil, err
@@ -572,6 +614,7 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
572614
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
573615
return client.TemplateVersionExternalAuth(ctx, templateVersion.ID)
574616
},
617+
NonInteractive: args.NonInteractive,
575618
})
576619
if err != nil {
577620
return nil, xerrors.Errorf("template version git auth: %w", err)

cli/parameterresolver.go

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type ParameterResolver struct {
3434

3535
promptRichParameters bool
3636
promptEphemeralParameters bool
37+
nonInteractive bool
3738
}
3839

3940
func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
@@ -86,6 +87,11 @@ func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParame
8687
return pr
8788
}
8889

90+
func (pr *ParameterResolver) WithNonInteractive(nonInteractive bool) *ParameterResolver {
91+
pr.nonInteractive = nonInteractive
92+
return pr
93+
}
94+
8995
// Resolve gathers workspace build parameters in a layered fashion, applying values from various sources
9096
// in order of precedence: parameter file < CLI/ENV < source build < last build < preset < user input.
9197
func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
@@ -250,18 +256,39 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
250256
if p != nil {
251257
continue
252258
}
253-
// PreviewParameter has not been resolved yet, so CLI needs to determine if user should input it.
259+
// Parameter has not been resolved yet, so CLI needs to determine if user should input it.
254260

255261
firstTimeUse := pr.isFirstTimeUse(tvp.Name)
256262
promptParameterOption := pr.isLastBuildParameterInvalidOption(tvp)
257263

258-
if (tvp.Ephemeral && pr.promptEphemeralParameters) ||
264+
needsInput := (tvp.Ephemeral && pr.promptEphemeralParameters) ||
259265
(action == WorkspaceCreate && tvp.Required) ||
260266
(action == WorkspaceCreate && !tvp.Ephemeral) ||
261267
(action == WorkspaceUpdate && promptParameterOption) ||
262268
(action == WorkspaceUpdate && tvp.Mutable && tvp.Required) ||
263269
(action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) ||
264-
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
270+
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters)
271+
272+
if needsInput {
273+
// In non-interactive mode, use default values or fail if required without default.
274+
if pr.nonInteractive {
275+
if tvp.DefaultValue != "" {
276+
// Use default value
277+
resolved = append(resolved, codersdk.WorkspaceBuildParameter{
278+
Name: tvp.Name,
279+
Value: tvp.DefaultValue,
280+
})
281+
} else if tvp.Required {
282+
// Required parameter with no default - fail
283+
return nil, xerrors.Errorf(
284+
"parameter %q is required but has no default value; provide it with --parameter %s=<value>",
285+
tvp.Name, tvp.Name)
286+
}
287+
// Optional parameter with no default - skip (will use empty/server default)
288+
continue
289+
}
290+
291+
// Interactive mode - prompt user for input
265292
parameterValue, err := cliui.RichParameter(inv, tvp, pr.richParametersDefaults)
266293
if err != nil {
267294
return nil, err

cli/testdata/coder_autoupdate_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ USAGE:
77

88
OPTIONS:
99
-y, --yes bool
10-
Bypass prompts.
10+
Run in non-interactive mode. Accepts default values and fails on
11+
required inputs.
1112

1213
———
1314
Run `coder --help` for a list of global options.

cli/testdata/coder_config-ssh_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ OPTIONS:
5555
configured in the workspace template is used.
5656

5757
-y, --yes bool
58-
Bypass prompts.
58+
Run in non-interactive mode. Accepts default values and fails on
59+
required inputs.
5960

6061
———
6162
Run `coder --help` for a list of global options.

cli/testdata/coder_create_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ OPTIONS:
5050
Specify a template version name.
5151

5252
-y, --yes bool
53-
Bypass prompts.
53+
Run in non-interactive mode. Accepts default values and fails on
54+
required inputs.
5455

5556
———
5657
Run `coder --help` for a list of global options.

cli/testdata/coder_delete_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ OPTIONS:
1818
resources.
1919

2020
-y, --yes bool
21-
Bypass prompts.
21+
Run in non-interactive mode. Accepts default values and fails on
22+
required inputs.
2223

2324
———
2425
Run `coder --help` for a list of global options.

cli/testdata/coder_dotfiles_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ OPTIONS:
2424
empty, will use $HOME.
2525

2626
-y, --yes bool
27-
Bypass prompts.
27+
Run in non-interactive mode. Accepts default values and fails on
28+
required inputs.
2829

2930
———
3031
Run `coder --help` for a list of global options.

cli/testdata/coder_logout_--help.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ USAGE:
77

88
OPTIONS:
99
-y, --yes bool
10-
Bypass prompts.
10+
Run in non-interactive mode. Accepts default values and fails on
11+
required inputs.
1112

1213
———
1314
Run `coder --help` for a list of global options.

0 commit comments

Comments
 (0)