Skip to content

Commit 0b2ba96

Browse files
feat(cli): add shell completions (coder#14341)
1 parent 6f9b3c1 commit 0b2ba96

File tree

80 files changed

+511
-419
lines changed

Some content is hidden

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

80 files changed

+511
-419
lines changed

Diff for: cli/cliui/output.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ func (f *OutputFormatter) AttachOptions(opts *serpent.OptionSet) {
6565
Flag: "output",
6666
FlagShorthand: "o",
6767
Default: f.formats[0].ID(),
68-
Value: serpent.StringOf(&f.formatID),
69-
Description: "Output format. Available formats: " + strings.Join(formatNames, ", ") + ".",
68+
Value: serpent.EnumOf(&f.formatID, formatNames...),
69+
Description: "Output format.",
7070
},
7171
)
7272
}
@@ -136,8 +136,8 @@ func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) {
136136
Flag: "column",
137137
FlagShorthand: "c",
138138
Default: strings.Join(f.defaultColumns, ","),
139-
Value: serpent.StringArrayOf(&f.columns),
140-
Description: "Columns to display in table output. Available columns: " + strings.Join(f.allColumns, ", ") + ".",
139+
Value: serpent.EnumArrayOf(&f.columns, f.allColumns...),
140+
Description: "Columns to display in table output.",
141141
},
142142
)
143143
}

Diff for: cli/cliui/output_test.go

+8-9
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ func Test_OutputFormatter(t *testing.T) {
106106

107107
fs := cmd.Options.FlagSet()
108108

109-
selected, err := fs.GetString("output")
110-
require.NoError(t, err)
111-
require.Equal(t, "json", selected)
109+
selected := cmd.Options.ByFlag("output")
110+
require.NotNil(t, selected)
111+
require.Equal(t, "json", selected.Value.String())
112112
usage := fs.FlagUsages()
113-
require.Contains(t, usage, "Available formats: json, foo")
113+
require.Contains(t, usage, "Output format.")
114114
require.Contains(t, usage, "foo flag 1234")
115115

116116
ctx := context.Background()
@@ -129,11 +129,10 @@ func Test_OutputFormatter(t *testing.T) {
129129
require.Equal(t, "foo", out)
130130
require.EqualValues(t, 1, atomic.LoadInt64(&called))
131131

132-
require.NoError(t, fs.Set("output", "bar"))
132+
require.Error(t, fs.Set("output", "bar"))
133133
out, err = f.Format(ctx, data)
134-
require.Error(t, err)
135-
require.ErrorContains(t, err, "bar")
136-
require.Equal(t, "", out)
137-
require.EqualValues(t, 1, atomic.LoadInt64(&called))
134+
require.NoError(t, err)
135+
require.Equal(t, "foo", out)
136+
require.EqualValues(t, 2, atomic.LoadInt64(&called))
138137
})
139138
}

Diff for: cli/completion.go

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/coder/coder/v2/cli/cliui"
7+
"github.com/coder/serpent"
8+
"github.com/coder/serpent/completion"
9+
)
10+
11+
func (*RootCmd) completion() *serpent.Command {
12+
var shellName string
13+
var printOutput bool
14+
shellOptions := completion.ShellOptions(&shellName)
15+
return &serpent.Command{
16+
Use: "completion",
17+
Short: "Install or update shell completion scripts for the detected or chosen shell.",
18+
Options: []serpent.Option{
19+
{
20+
Flag: "shell",
21+
FlagShorthand: "s",
22+
Description: "The shell to install completion for.",
23+
Value: shellOptions,
24+
},
25+
{
26+
Flag: "print",
27+
Description: "Print the completion script instead of installing it.",
28+
FlagShorthand: "p",
29+
30+
Value: serpent.BoolOf(&printOutput),
31+
},
32+
},
33+
Handler: func(inv *serpent.Invocation) error {
34+
if shellName != "" {
35+
shell, err := completion.ShellByName(shellName, inv.Command.Parent.Name())
36+
if err != nil {
37+
return err
38+
}
39+
if printOutput {
40+
return shell.WriteCompletion(inv.Stdout)
41+
}
42+
return installCompletion(inv, shell)
43+
}
44+
shell, err := completion.DetectUserShell(inv.Command.Parent.Name())
45+
if err == nil {
46+
return installCompletion(inv, shell)
47+
}
48+
// Silently continue to the shell selection if detecting failed.
49+
choice, err := cliui.Select(inv, cliui.SelectOptions{
50+
Message: "Select a shell to install completion for:",
51+
Options: shellOptions.Choices,
52+
})
53+
if err != nil {
54+
return err
55+
}
56+
shellChoice, err := completion.ShellByName(choice, inv.Command.Parent.Name())
57+
if err != nil {
58+
return err
59+
}
60+
if printOutput {
61+
return shellChoice.WriteCompletion(inv.Stdout)
62+
}
63+
return installCompletion(inv, shellChoice)
64+
},
65+
}
66+
}
67+
68+
func installCompletion(inv *serpent.Invocation, shell completion.Shell) error {
69+
path, err := shell.InstallPath()
70+
if err != nil {
71+
cliui.Error(inv.Stderr, fmt.Sprintf("Failed to determine completion path %v", err))
72+
return shell.WriteCompletion(inv.Stdout)
73+
}
74+
choice, err := cliui.Select(inv, cliui.SelectOptions{
75+
Options: []string{
76+
"Confirm",
77+
"Print to terminal",
78+
},
79+
Message: fmt.Sprintf("Install completion for %s at %s?", shell.Name(), path),
80+
HideSearch: true,
81+
})
82+
if err != nil {
83+
return err
84+
}
85+
if choice == "Print to terminal" {
86+
return shell.WriteCompletion(inv.Stdout)
87+
}
88+
return completion.InstallShellCompletion(shell)
89+
}

Diff for: cli/configssh.go

+2-45
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"strings"
1818

1919
"github.com/cli/safeexec"
20+
"github.com/natefinch/atomic"
2021
"github.com/pkg/diff"
2122
"github.com/pkg/diff/write"
2223
"golang.org/x/exp/constraints"
@@ -524,7 +525,7 @@ func (r *RootCmd) configSSH() *serpent.Command {
524525
}
525526

526527
if !bytes.Equal(configRaw, configModified) {
527-
err = writeWithTempFileAndMove(sshConfigFile, bytes.NewReader(configModified))
528+
err = atomic.WriteFile(sshConfigFile, bytes.NewReader(configModified))
528529
if err != nil {
529530
return xerrors.Errorf("write ssh config failed: %w", err)
530531
}
@@ -758,50 +759,6 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []
758759
return data, nil, nil, nil
759760
}
760761

761-
// writeWithTempFileAndMove writes to a temporary file in the same
762-
// directory as path and renames the temp file to the file provided in
763-
// path. This ensure we avoid trashing the file we are writing due to
764-
// unforeseen circumstance like filesystem full, command killed, etc.
765-
func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
766-
dir := filepath.Dir(path)
767-
name := filepath.Base(path)
768-
769-
// Ensure that e.g. the ~/.ssh directory exists.
770-
if err = os.MkdirAll(dir, 0o700); err != nil {
771-
return xerrors.Errorf("create directory: %w", err)
772-
}
773-
774-
// Create a tempfile in the same directory for ensuring write
775-
// operation does not fail.
776-
f, err := os.CreateTemp(dir, fmt.Sprintf(".%s.", name))
777-
if err != nil {
778-
return xerrors.Errorf("create temp file failed: %w", err)
779-
}
780-
defer func() {
781-
if err != nil {
782-
_ = os.Remove(f.Name()) // Cleanup in case a step failed.
783-
}
784-
}()
785-
786-
_, err = io.Copy(f, r)
787-
if err != nil {
788-
_ = f.Close()
789-
return xerrors.Errorf("write temp file failed: %w", err)
790-
}
791-
792-
err = f.Close()
793-
if err != nil {
794-
return xerrors.Errorf("close temp file failed: %w", err)
795-
}
796-
797-
err = os.Rename(f.Name(), path)
798-
if err != nil {
799-
return xerrors.Errorf("rename temp file failed: %w", err)
800-
}
801-
802-
return nil
803-
}
804-
805762
// sshConfigExecEscape quotes the string if it contains spaces, as per
806763
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
807764
// run the command, and as such the formatting/escape requirements

Diff for: cli/help.go

+2
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ var usageTemplate = func() *template.Template {
8181
switch v := opt.Value.(type) {
8282
case *serpent.Enum:
8383
return strings.Join(v.Choices, "|")
84+
case *serpent.EnumArray:
85+
return fmt.Sprintf("[%s]", strings.Join(v.Choices, "|"))
8486
default:
8587
return v.Type()
8688
}

Diff for: cli/organizationmembers.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ func (r *RootCmd) assignOrganizationRoles(orgContext *OrganizationContext) *serp
137137

138138
func (r *RootCmd) listOrganizationMembers(orgContext *OrganizationContext) *serpent.Command {
139139
formatter := cliui.NewOutputFormatter(
140-
cliui.TableFormat([]codersdk.OrganizationMemberWithUserData{}, []string{"username", "organization_roles"}),
140+
cliui.TableFormat([]codersdk.OrganizationMemberWithUserData{}, []string{"username", "organization roles"}),
141141
cliui.JSONFormat(),
142142
)
143143

Diff for: cli/organizationmembers_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func TestListOrganizationMembers(t *testing.T) {
2323
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
2424

2525
ctx := testutil.Context(t, testutil.WaitMedium)
26-
inv, root := clitest.New(t, "organization", "members", "list", "-c", "user_id,username,roles")
26+
inv, root := clitest.New(t, "organization", "members", "list", "-c", "user id,username,organization roles")
2727
clitest.SetupConfig(t, client, root)
2828

2929
buf := new(bytes.Buffer)

Diff for: cli/organizationroles.go

+7-7
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Co
3636
func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpent.Command {
3737
formatter := cliui.NewOutputFormatter(
3838
cliui.ChangeFormatterData(
39-
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}),
39+
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}),
4040
func(data any) (any, error) {
4141
inputs, ok := data.([]codersdk.AssignableRoles)
4242
if !ok {
@@ -103,7 +103,7 @@ func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpen
103103
func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
104104
formatter := cliui.NewOutputFormatter(
105105
cliui.ChangeFormatterData(
106-
cliui.TableFormat([]roleTableRow{}, []string{"name", "display_name", "site_permissions", "organization_permissions", "user_permissions"}),
106+
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}),
107107
func(data any) (any, error) {
108108
typed, _ := data.(codersdk.Role)
109109
return []roleTableRow{roleToTableView(typed)}, nil
@@ -408,10 +408,10 @@ func roleToTableView(role codersdk.Role) roleTableRow {
408408

409409
type roleTableRow struct {
410410
Name string `table:"name,default_sort"`
411-
DisplayName string `table:"display_name"`
412-
OrganizationID string `table:"organization_id"`
413-
SitePermissions string ` table:"site_permissions"`
411+
DisplayName string `table:"display name"`
412+
OrganizationID string `table:"organization id"`
413+
SitePermissions string ` table:"site permissions"`
414414
// map[<org_id>] -> Permissions
415-
OrganizationPermissions string `table:"organization_permissions"`
416-
UserPermissions string `table:"user_permissions"`
415+
OrganizationPermissions string `table:"organization permissions"`
416+
UserPermissions string `table:"user permissions"`
417417
}

Diff for: cli/root.go

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const (
8282
func (r *RootCmd) CoreSubcommands() []*serpent.Command {
8383
// Please re-sort this list alphabetically if you change it!
8484
return []*serpent.Command{
85+
r.completion(),
8586
r.dotfiles(),
8687
r.externalAuth(),
8788
r.login(),

Diff for: cli/stat.go

+10-10
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ func (r *RootCmd) stat() *serpent.Command {
3232
fs = afero.NewReadOnlyFs(afero.NewOsFs())
3333
formatter = cliui.NewOutputFormatter(
3434
cliui.TableFormat([]statsRow{}, []string{
35-
"host_cpu",
36-
"host_memory",
37-
"home_disk",
38-
"container_cpu",
39-
"container_memory",
35+
"host cpu",
36+
"host memory",
37+
"home disk",
38+
"container cpu",
39+
"container memory",
4040
}),
4141
cliui.JSONFormat(),
4242
)
@@ -284,9 +284,9 @@ func (*RootCmd) statDisk(fs afero.Fs) *serpent.Command {
284284
}
285285

286286
type statsRow struct {
287-
HostCPU *clistat.Result `json:"host_cpu" table:"host_cpu,default_sort"`
288-
HostMemory *clistat.Result `json:"host_memory" table:"host_memory"`
289-
Disk *clistat.Result `json:"home_disk" table:"home_disk"`
290-
ContainerCPU *clistat.Result `json:"container_cpu" table:"container_cpu"`
291-
ContainerMemory *clistat.Result `json:"container_memory" table:"container_memory"`
287+
HostCPU *clistat.Result `json:"host_cpu" table:"host cpu,default_sort"`
288+
HostMemory *clistat.Result `json:"host_memory" table:"host memory"`
289+
Disk *clistat.Result `json:"home_disk" table:"home disk"`
290+
ContainerCPU *clistat.Result `json:"container_cpu" table:"container cpu"`
291+
ContainerMemory *clistat.Result `json:"container_memory" table:"container memory"`
292292
}

Diff for: cli/templateedit.go

+3-25
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cli
33
import (
44
"fmt"
55
"net/http"
6-
"strings"
76
"time"
87

98
"golang.org/x/xerrors"
@@ -239,35 +238,14 @@ func (r *RootCmd) templateEdit() *serpent.Command {
239238
Value: serpent.DurationOf(&activityBump),
240239
},
241240
{
242-
Flag: "autostart-requirement-weekdays",
243-
// workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the autostop requirement for the template), pass 'none'.
241+
Flag: "autostart-requirement-weekdays",
244242
Description: "Edit the template autostart requirement weekdays - workspaces created from this template can only autostart on the given weekdays. To unset this value for the template (and allow autostart on all days), pass 'all'.",
245-
Value: serpent.Validate(serpent.StringArrayOf(&autostartRequirementDaysOfWeek), func(value *serpent.StringArray) error {
246-
v := value.GetSlice()
247-
if len(v) == 1 && v[0] == "all" {
248-
return nil
249-
}
250-
_, err := codersdk.WeekdaysToBitmap(v)
251-
if err != nil {
252-
return xerrors.Errorf("invalid autostart requirement days of week %q: %w", strings.Join(v, ","), err)
253-
}
254-
return nil
255-
}),
243+
Value: serpent.EnumArrayOf(&autostartRequirementDaysOfWeek, append(codersdk.AllDaysOfWeek, "all")...),
256244
},
257245
{
258246
Flag: "autostop-requirement-weekdays",
259247
Description: "Edit the template autostop requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the autostop requirement for the template), pass 'none'.",
260-
Value: serpent.Validate(serpent.StringArrayOf(&autostopRequirementDaysOfWeek), func(value *serpent.StringArray) error {
261-
v := value.GetSlice()
262-
if len(v) == 1 && v[0] == "none" {
263-
return nil
264-
}
265-
_, err := codersdk.WeekdaysToBitmap(v)
266-
if err != nil {
267-
return xerrors.Errorf("invalid autostop requirement days of week %q: %w", strings.Join(v, ","), err)
268-
}
269-
return nil
270-
}),
248+
Value: serpent.EnumArrayOf(&autostopRequirementDaysOfWeek, append(codersdk.AllDaysOfWeek, "none")...),
271249
},
272250
{
273251
Flag: "autostop-requirement-weeks",

Diff for: cli/templateversions.go

+7-7
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ func (r *RootCmd) templateVersions() *serpent.Command {
4040

4141
func (r *RootCmd) templateVersionsList() *serpent.Command {
4242
defaultColumns := []string{
43-
"Name",
44-
"Created At",
45-
"Created By",
46-
"Status",
47-
"Active",
43+
"name",
44+
"created at",
45+
"created by",
46+
"status",
47+
"active",
4848
}
4949
formatter := cliui.NewOutputFormatter(
5050
cliui.TableFormat([]templateVersionRow{}, defaultColumns),
@@ -70,10 +70,10 @@ func (r *RootCmd) templateVersionsList() *serpent.Command {
7070
for _, opt := range i.Command.Options {
7171
if opt.Flag == "column" {
7272
if opt.ValueSource == serpent.ValueSourceDefault {
73-
v, ok := opt.Value.(*serpent.StringArray)
73+
v, ok := opt.Value.(*serpent.EnumArray)
7474
if ok {
7575
// Add the extra new default column.
76-
*v = append(*v, "Archived")
76+
_ = v.Append("Archived")
7777
}
7878
}
7979
break

Diff for: cli/testdata/coder_--help.golden

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ USAGE:
1515

1616
SUBCOMMANDS:
1717
autoupdate Toggle auto-update policy for a workspace
18+
completion Install or update shell completion scripts for the
19+
detected or chosen shell.
1820
config-ssh Add an SSH Host entry for your workspaces "ssh
1921
coder.workspace"
2022
create Create a workspace

0 commit comments

Comments
 (0)