Skip to content

Commit 6c29207

Browse files
committed
Initial scaffold
1 parent acf000a commit 6c29207

File tree

6 files changed

+265
-0
lines changed

6 files changed

+265
-0
lines changed

cli/bigcli/bigcli.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Package bigcli offers an all-in-one solution for a highly configurable CLI
2+
// application. Within Coder, we use it for our `server` subcommand, which
3+
// demands more than cobra/viper can offer.
4+
//
5+
// We may extend its usage to the rest of our application, completely replacing
6+
// cobra/viper, in the future. It's also a candidate to be broken out into its
7+
// own open-source library.
8+
package bigcli

cli/bigcli/option.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package bigcli
2+
3+
import (
4+
"strings"
5+
6+
"github.com/hashicorp/go-multierror"
7+
"github.com/iancoleman/strcase"
8+
"github.com/spf13/pflag"
9+
"golang.org/x/xerrors"
10+
)
11+
12+
const Disable = "-"
13+
14+
// Option is a configuration option for a CLI application.
15+
type Option struct {
16+
Name string
17+
Usage string
18+
19+
// If unset, Flag defaults to the kebab-case version of Name.
20+
// Use special value "Disable" to disable flag support.
21+
Flag string
22+
23+
FlagShorthand string
24+
25+
// If unset, Env defaults to the upper-case, snake-case version of Name.
26+
// Use special value "Disable" to disable environment variable support.
27+
Env string
28+
29+
// Default is parsed into Value if set.
30+
Default string
31+
Value pflag.Value
32+
33+
// Annotations can be anything and everything you want. It's useful for
34+
// help formatting and documentation generation.
35+
Annotations map[string]string
36+
Hidden bool
37+
}
38+
39+
func (o *Option) FlagName() (string, bool) {
40+
if o.Flag == Disable {
41+
return "", false
42+
}
43+
if o.Flag == "" {
44+
return strcase.ToKebab(o.Name), true
45+
}
46+
return o.Flag, true
47+
}
48+
49+
// EnvName returns the environment variable name for the option.
50+
func (o *Option) EnvName() (string, bool) {
51+
if o.Env != "" {
52+
if o.Env == Disable {
53+
return "", false
54+
}
55+
return o.Env, true
56+
}
57+
return strings.ToUpper(strcase.ToSnake(o.Name)), true
58+
}
59+
60+
// OptionSet is a group of options that can be applied to a command.
61+
type OptionSet []Option
62+
63+
// Add adds the given Options to the OptionSet.
64+
func (os *OptionSet) Add(opts ...Option) {
65+
*os = append(*os, opts...)
66+
}
67+
68+
// ParseFlags parses the given os.Args style arguments into the OptionSet.
69+
func (os *OptionSet) ParseFlags(args ...string) error {
70+
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
71+
for _, opt := range *os {
72+
flagName, ok := opt.FlagName()
73+
if !ok {
74+
continue
75+
}
76+
fs.AddFlag(&pflag.Flag{
77+
Name: flagName,
78+
Shorthand: opt.FlagShorthand,
79+
Usage: opt.Usage,
80+
Value: opt.Value,
81+
DefValue: "",
82+
Changed: false,
83+
NoOptDefVal: "",
84+
Deprecated: "",
85+
Hidden: opt.Hidden,
86+
})
87+
}
88+
return fs.Parse(args)
89+
}
90+
91+
// ParseEnv parses the given environment variables into the OptionSet.
92+
func (os *OptionSet) ParseEnv(globalPrefix string, environ []string) error {
93+
var merr *multierror.Error
94+
95+
// We parse environment variables first instead of using a nested loop to
96+
// avoid N*M complexity when there are a lot of options and environment
97+
// variables.
98+
envs := make(map[string]string)
99+
for _, env := range environ {
100+
env = strings.TrimPrefix(env, globalPrefix)
101+
if len(env) == 0 {
102+
continue
103+
}
104+
105+
tokens := strings.Split(env, "=")
106+
if len(tokens) != 2 {
107+
return xerrors.Errorf("invalid env %q", env)
108+
}
109+
envs[tokens[0]] = tokens[1]
110+
}
111+
112+
for _, opt := range *os {
113+
envName, ok := opt.EnvName()
114+
if !ok {
115+
continue
116+
}
117+
118+
envVal, ok := envs[envName]
119+
if !ok {
120+
continue
121+
}
122+
123+
if err := opt.Value.Set(envVal); err != nil {
124+
merr = multierror.Append(
125+
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
126+
)
127+
}
128+
}
129+
130+
return merr.ErrorOrNil()
131+
}
132+
133+
// SetDefaults sets the default values for each Option.
134+
// It should be called after all parsing (e.g. ParseFlags, ParseEnv, ParseConfig).
135+
func (os *OptionSet) SetDefaults() error {
136+
var merr *multierror.Error
137+
for _, opt := range *os {
138+
if opt.Default == "" {
139+
continue
140+
}
141+
if err := opt.Value.Set(opt.Default); err != nil {
142+
merr = multierror.Append(
143+
merr, xerrors.Errorf("parse %q: %w", opt.Name, err),
144+
)
145+
}
146+
}
147+
return merr.ErrorOrNil()
148+
}

cli/bigcli/option_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package bigcli_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/coder/coder/cli/bigcli"
9+
)
10+
11+
func TestOptionSet_ParseFlags(t *testing.T) {
12+
t.Parallel()
13+
14+
t.Run("SimpleString", func(t *testing.T) {
15+
t.Parallel()
16+
17+
var workspaceName bigcli.String
18+
19+
os := bigcli.OptionSet{
20+
bigcli.Option{
21+
Name: "Workspace Name",
22+
Value: &workspaceName,
23+
FlagShorthand: "n",
24+
},
25+
}
26+
27+
var err error
28+
err = os.ParseFlags("--workspace-name", "foo")
29+
require.NoError(t, err)
30+
require.EqualValues(t, "foo", workspaceName)
31+
32+
err = os.ParseFlags("-n", "f")
33+
require.NoError(t, err)
34+
require.EqualValues(t, "f", workspaceName)
35+
})
36+
37+
t.Run("ExtraFlags", func(t *testing.T) {
38+
t.Parallel()
39+
40+
var workspaceName bigcli.String
41+
42+
os := bigcli.OptionSet{
43+
bigcli.Option{
44+
Name: "Workspace Name",
45+
Value: &workspaceName,
46+
},
47+
}
48+
49+
err := os.ParseFlags("--some-unknown", "foo")
50+
require.Error(t, err)
51+
})
52+
}
53+
54+
func TestOptionSet_ParseEnv(t *testing.T) {
55+
t.Parallel()
56+
57+
t.Run("SimpleString", func(t *testing.T) {
58+
t.Parallel()
59+
60+
var workspaceName bigcli.String
61+
62+
os := bigcli.OptionSet{
63+
bigcli.Option{
64+
Name: "Workspace Name",
65+
Value: &workspaceName,
66+
},
67+
}
68+
69+
err := os.ParseEnv("CODER_", []string{"CODER_WORKSPACE_NAME=foo"})
70+
require.NoError(t, err)
71+
require.EqualValues(t, "foo", workspaceName)
72+
})
73+
}

cli/bigcli/value.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package bigcli
2+
3+
import "strconv"
4+
5+
type Int int
6+
7+
func (i *Int) Set(s string) error {
8+
ii, err := strconv.ParseInt(s, 10, 64)
9+
*i = Int(ii)
10+
return err
11+
}
12+
13+
func (i Int) String() string {
14+
return strconv.Itoa(int(i))
15+
}
16+
17+
func (Int) Type() string {
18+
return "int"
19+
}
20+
21+
type String string
22+
23+
func (s *String) Set(v string) error {
24+
*s = String(v)
25+
return nil
26+
}
27+
28+
func (s String) String() string {
29+
return string(s)
30+
}
31+
32+
func (String) Type() string {
33+
return "string"
34+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ require (
110110
github.com/hashicorp/terraform-config-inspect v0.0.0-20211115214459-90acf1ca460f
111111
github.com/hashicorp/terraform-json v0.14.0
112112
github.com/hashicorp/yamux v0.0.0-20220718163420-dd80a7ee44ce
113+
github.com/iancoleman/strcase v0.2.0
113114
github.com/imulab/go-scim/pkg/v2 v2.2.0
114115
github.com/jedib0t/go-pretty/v6 v6.4.0
115116
github.com/jmoiron/sqlx v1.3.5

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,7 @@ github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq
10881088
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
10891089
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
10901090
github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA=
1091+
github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0=
10911092
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
10921093
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
10931094
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=

0 commit comments

Comments
 (0)