From f26a350e6a8539fb612ec84582da8dd6c41c28e1 Mon Sep 17 00:00:00 2001 From: geemus Date: Thu, 11 Apr 2024 15:17:25 -0500 Subject: [PATCH] v0.0.21: nss detection fix and misc cleanup --- auth/auth.go | 11 ++ auth/auth_test.go | 18 +- auth/client.go | 10 +- auth/signin.go | 35 ++-- auth/signin_test.go | 11 ++ auth/signout.go | 13 +- auth/signout_test.go | 42 ++++ auth/testdata/TestCmdAuth/--help.golden | 15 ++ auth/testdata/TestCmdAuth/auth.golden | 15 ++ auth/testdata/TestCmdAuthSignin/--help.golden | 10 + .../testdata/TestCmdAuthSignout/--help.golden | 10 + auth/testdata/TestCmdAuthWhoAmI/--help.golden | 7 + auth/whoami.go | 13 +- auth/whoami_test.go | 48 ++--- cert/provision.go | 10 +- cli.go | 23 ++- cmd.go | 185 ++++++++++++++++++ cmd/anchor/main.go | 32 ++- cmdtest/cmdtest.go | 28 +++ command.go | 14 +- go.mod | 1 + keyring/keyring.go | 3 +- lcl/audit.go | 10 +- lcl/audit_test.go | 32 +-- lcl/clean.go | 12 +- lcl/clean_test.go | 6 +- lcl/config.go | 19 +- lcl/config_test.go | 6 +- lcl/lcl.go | 11 +- lcl/lcl_test.go | 20 +- lcl/mkcert.go | 19 +- lcl/mkcert_test.go | 3 +- lcl/provision.go | 4 - lcl/provision_test.go | 1 - lcl/setup.go | 17 +- lcl/setup_test.go | 10 +- lcl/testdata/TestCmdDefLclAudit/--help.golden | 7 + lcl/testdata/TestCmdDefLclAudit/basics.golden | 27 +++ lcl/testdata/TestCmdLcl/--help.golden | 13 ++ root.go | 36 ++++ root_test.go | 30 +++ testdata/TestCmdRoot/--help.golden | 18 ++ testdata/TestCmdRoot/root.golden | 18 ++ trust/audit.go | 12 +- trust/audit_test.go | 8 +- trust/clean.go | 23 +-- trust/trust.go | 33 ++-- trust/trust_test.go | 6 +- truststore/nss.go | 2 +- ui/uitest/uitest.go | 30 ++- 50 files changed, 769 insertions(+), 218 deletions(-) create mode 100644 auth/auth.go create mode 100644 auth/signout_test.go create mode 100644 auth/testdata/TestCmdAuth/--help.golden create mode 100644 auth/testdata/TestCmdAuth/auth.golden create mode 100644 auth/testdata/TestCmdAuthSignin/--help.golden create mode 100644 auth/testdata/TestCmdAuthSignout/--help.golden create mode 100644 auth/testdata/TestCmdAuthWhoAmI/--help.golden create mode 100644 cmd.go create mode 100644 cmdtest/cmdtest.go create mode 100644 lcl/testdata/TestCmdDefLclAudit/--help.golden create mode 100644 lcl/testdata/TestCmdDefLclAudit/basics.golden create mode 100644 lcl/testdata/TestCmdLcl/--help.golden create mode 100644 root.go create mode 100644 root_test.go create mode 100644 testdata/TestCmdRoot/--help.golden create mode 100644 testdata/TestCmdRoot/root.golden diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..4abe764 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,11 @@ +package auth + +import ( + "github.com/spf13/cobra" + + "github.com/anchordotdev/cli" +) + +var CmdAuth = cli.NewCmd[cli.ShowHelp](cli.CmdRoot, "auth", func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs +}) diff --git a/auth/auth_test.go b/auth/auth_test.go index c936fe6..51ad217 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -6,10 +6,7 @@ import ( "testing" "github.com/anchordotdev/cli/api/apitest" -) - -var ( - _ = flag.Bool("update", false, "ignored") + "github.com/anchordotdev/cli/cmdtest" ) var srv = &apitest.Server{ @@ -27,3 +24,16 @@ func TestMain(m *testing.M) { m.Run() } + +func TestCmdAuth(t *testing.T) { + cmd := CmdAuth + root := cmd.Root() + + t.Run("auth", func(t *testing.T) { + cmdtest.TestOutput(t, root, "auth") + }) + + t.Run("--help", func(t *testing.T) { + cmdtest.TestOutput(t, root, "auth", "--help") + }) +} diff --git a/auth/client.go b/auth/client.go index 3ce1619..565081a 100644 --- a/auth/client.go +++ b/auth/client.go @@ -12,20 +12,20 @@ import ( ) type Client struct { - Config *cli.Config - Anc *api.Session Hint tea.Model Source string } func (c Client) Perform(ctx context.Context, drv *ui.Driver) (*api.Session, error) { + cfg := cli.ConfigFromContext(ctx) + var newClientErr, userInfoErr error drv.Activate(ctx, &models.Client{}) if c.Anc == nil { - c.Anc, newClientErr = api.NewClient(c.Config) + c.Anc, newClientErr = api.NewClient(cfg) if newClientErr != nil && !errors.Is(newClientErr, api.ErrSignedOut) { return nil, newClientErr } @@ -47,16 +47,16 @@ func (c Client) Perform(ctx context.Context, drv *ui.Driver) (*api.Session, erro c.Hint = &models.SignInHint{} } cmd := &SignIn{ - Config: c.Config, Hint: c.Hint, Source: c.Source, } + ctx = cli.ContextWithConfig(ctx, cfg) err := cmd.RunTUI(ctx, drv) if err != nil { return nil, err } - c.Anc, err = api.NewClient(c.Config) + c.Anc, err = api.NewClient(cfg) if err != nil { return nil, err } diff --git a/auth/signin.go b/auth/signin.go index 49567a6..7b26e76 100644 --- a/auth/signin.go +++ b/auth/signin.go @@ -13,6 +13,7 @@ import ( "github.com/cli/browser" "github.com/mattn/go-isatty" "github.com/muesli/termenv" + "github.com/spf13/cobra" "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api" @@ -22,12 +23,14 @@ import ( ) var ( + CmdAuthSignin = cli.NewCmd[SignIn](CmdAuth, "signin", func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs + }) + ErrSigninFailed = errors.New("sign in failed") ) type SignIn struct { - Config *cli.Config - Source string Hint tea.Model @@ -41,6 +44,8 @@ func (s SignIn) UI() cli.UI { } func (s *SignIn) runTTY(ctx context.Context, tty termenv.File) error { + cfg := cli.ConfigFromContext(ctx) + output := termenv.DefaultOutput() cp := output.ColorProfile() @@ -48,7 +53,7 @@ func (s *SignIn) runTTY(ctx context.Context, tty termenv.File) error { output.String("# Run `anchor auth signin`").Bold(), ) - anc, err := api.NewClient(s.Config) + anc, err := api.NewClient(cfg) if err != nil && err != api.ErrSignedOut { return err } @@ -114,15 +119,15 @@ func (s *SignIn) runTTY(ctx context.Context, tty termenv.File) error { time.Sleep(time.Duration(codes.Interval) * time.Second) } } - s.Config.API.Token = patToken + cfg.API.Token = patToken - userInfo, err := fetchUserInfo(s.Config) + userInfo, err := fetchUserInfo(cfg) if err != nil { return err } - kr := keyring.Keyring{Config: s.Config} - if err := kr.Set(keyring.APIToken, s.Config.API.Token); err != nil { + kr := keyring.Keyring{Config: cfg} + if err := kr.Set(keyring.APIToken, cfg.API.Token); err != nil { return err } @@ -136,7 +141,11 @@ func (s *SignIn) runTTY(ctx context.Context, tty termenv.File) error { return nil } +// FIXME: dedup mostly identical RunTUI/RunTTY + func (s *SignIn) RunTUI(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + drv.Activate(ctx, &models.SignInHeader{}) if s.Hint == nil { @@ -144,7 +153,7 @@ func (s *SignIn) RunTUI(ctx context.Context, drv *ui.Driver) error { } drv.Activate(ctx, s.Hint) - anc, err := api.NewClient(s.Config) + anc, err := api.NewClient(cfg) if err != nil && err != api.ErrSignedOut { return err } @@ -165,7 +174,7 @@ func (s *SignIn) RunTUI(ctx context.Context, drv *ui.Driver) error { VerificationURL: codes.VerificationUri, }) - if !s.Config.NonInteractive { + if !cfg.NonInteractive { select { case <-confirmc: case <-ctx.Done(): @@ -189,15 +198,15 @@ func (s *SignIn) RunTUI(ctx context.Context, drv *ui.Driver) error { time.Sleep(time.Duration(codes.Interval) * time.Second) } } - s.Config.API.Token = patToken + cfg.API.Token = patToken - userInfo, err := fetchUserInfo(s.Config) + userInfo, err := fetchUserInfo(cfg) if err != nil { return err } - kr := keyring.Keyring{Config: s.Config} - if err := kr.Set(keyring.APIToken, s.Config.API.Token); err != nil { + kr := keyring.Keyring{Config: cfg} + if err := kr.Set(keyring.APIToken, cfg.API.Token); err != nil { return err } diff --git a/auth/signin_test.go b/auth/signin_test.go index 3ea86a2..f77160d 100644 --- a/auth/signin_test.go +++ b/auth/signin_test.go @@ -2,8 +2,19 @@ package auth import ( "testing" + + "github.com/anchordotdev/cli/cmdtest" ) +func TestCmdAuthSignin(t *testing.T) { + cmd := CmdAuthSignin + root := cmd.Root() + + t.Run("--help", func(t *testing.T) { + cmdtest.TestOutput(t, root, "auth", "signin", "--help") + }) +} + func TestSignIn(t *testing.T) { t.Run("cli-auth-success", func(t *testing.T) { t.Skip("cli auth test not yet implemented") diff --git a/auth/signout.go b/auth/signout.go index 23b223f..b101f1b 100644 --- a/auth/signout.go +++ b/auth/signout.go @@ -7,11 +7,14 @@ import ( "github.com/anchordotdev/cli/auth/models" "github.com/anchordotdev/cli/keyring" "github.com/anchordotdev/cli/ui" + "github.com/spf13/cobra" ) -type SignOut struct { - Config *cli.Config -} +var CmdAuthSignout = cli.NewCmd[SignOut](CmdAuth, "signout", func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs +}) + +type SignOut struct{} func (s SignOut) UI() cli.UI { return cli.UI{ @@ -20,9 +23,11 @@ func (s SignOut) UI() cli.UI { } func (s *SignOut) runTUI(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + drv.Activate(ctx, &models.SignOutPreamble{}) - kr := keyring.Keyring{Config: s.Config} + kr := keyring.Keyring{Config: cfg} err := kr.Delete(keyring.APIToken) if err == nil { diff --git a/auth/signout_test.go b/auth/signout_test.go new file mode 100644 index 0000000..4df2041 --- /dev/null +++ b/auth/signout_test.go @@ -0,0 +1,42 @@ +package auth + +import ( + "context" + "testing" + + "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/cmdtest" + "github.com/anchordotdev/cli/ui/uitest" +) + +func TestCmdAuthSignout(t *testing.T) { + cmd := CmdAuthSignin + cfg := cli.ConfigFromCmd(cmd) + cfg.Test.SkipRunE = true + root := cmd.Root() + + t.Run("--help", func(t *testing.T) { + cmdtest.TestOutput(t, root, "auth", "signout", "--help") + }) +} + +func TestSignout(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := new(cli.Config) + cfg.Keyring.MockMode = true + ctx = cli.ContextWithConfig(ctx, cfg) + + t.Run("signed-out", func(t *testing.T) { + uitest.TestTUIError(ctx, t, new(SignOut).UI(), "secret not found in keyring") + }) + + t.Run("signed-in", func(t *testing.T) { + t.Skip("pending singleton keyring") + // kr := keyring.Keyring{} + // if err := kr.Set(keyring.APIToken, "secret"); err != nil { + // t.Fatal(err) + // } + }) +} diff --git a/auth/testdata/TestCmdAuth/--help.golden b/auth/testdata/TestCmdAuth/--help.golden new file mode 100644 index 0000000..b78da1d --- /dev/null +++ b/auth/testdata/TestCmdAuth/--help.golden @@ -0,0 +1,15 @@ +Manage Anchor.dev Authentication + +Usage: + anchor auth [flags] + anchor auth [command] + +Available Commands: + signin Authenticate with your account + signout Invalidate your local Anchor account session + whoami Identify current account + +Flags: + -h, --help help for auth + +Use "anchor auth [command] --help" for more information about a command. diff --git a/auth/testdata/TestCmdAuth/auth.golden b/auth/testdata/TestCmdAuth/auth.golden new file mode 100644 index 0000000..b78da1d --- /dev/null +++ b/auth/testdata/TestCmdAuth/auth.golden @@ -0,0 +1,15 @@ +Manage Anchor.dev Authentication + +Usage: + anchor auth [flags] + anchor auth [command] + +Available Commands: + signin Authenticate with your account + signout Invalidate your local Anchor account session + whoami Identify current account + +Flags: + -h, --help help for auth + +Use "anchor auth [command] --help" for more information about a command. diff --git a/auth/testdata/TestCmdAuthSignin/--help.golden b/auth/testdata/TestCmdAuthSignin/--help.golden new file mode 100644 index 0000000..384f920 --- /dev/null +++ b/auth/testdata/TestCmdAuthSignin/--help.golden @@ -0,0 +1,10 @@ +Sign into your Anchor account for your local system user. + +Generate a new Personal Access Token (PAT) and store it in the system keychain +for the local system user. + +Usage: + anchor auth signin [flags] + +Flags: + -h, --help help for signin diff --git a/auth/testdata/TestCmdAuthSignout/--help.golden b/auth/testdata/TestCmdAuthSignout/--help.golden new file mode 100644 index 0000000..c477ed5 --- /dev/null +++ b/auth/testdata/TestCmdAuthSignout/--help.golden @@ -0,0 +1,10 @@ +Sign out of your Anchor account for your local system user. + +Remove your Personal Access Token (PAT) from the system keychain for your local +system user. + +Usage: + anchor auth signout [flags] + +Flags: + -h, --help help for signout diff --git a/auth/testdata/TestCmdAuthWhoAmI/--help.golden b/auth/testdata/TestCmdAuthWhoAmI/--help.golden new file mode 100644 index 0000000..ddca2b1 --- /dev/null +++ b/auth/testdata/TestCmdAuthWhoAmI/--help.golden @@ -0,0 +1,7 @@ +Print the details of the Anchor account for your local system user. + +Usage: + anchor auth whoami [flags] + +Flags: + -h, --help help for whoami diff --git a/auth/whoami.go b/auth/whoami.go index b0660c2..23111ac 100644 --- a/auth/whoami.go +++ b/auth/whoami.go @@ -8,14 +8,17 @@ import ( "net/http" "github.com/muesli/termenv" + "github.com/spf13/cobra" "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api" ) -type WhoAmI struct { - Config *cli.Config -} +var CmdAuthWhoami = cli.NewCmd[WhoAmI](CmdAuth, "whoami", func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs +}) + +type WhoAmI struct{} func (w WhoAmI) UI() cli.UI { return cli.UI{ @@ -24,7 +27,9 @@ func (w WhoAmI) UI() cli.UI { } func (w *WhoAmI) run(ctx context.Context, tty termenv.File) error { - anc, err := api.NewClient(w.Config) + cfg := cli.ConfigFromContext(ctx) + + anc, err := api.NewClient(cfg) if err != nil { return err } diff --git a/auth/whoami_test.go b/auth/whoami_test.go index 603f135..54f0cea 100644 --- a/auth/whoami_test.go +++ b/auth/whoami_test.go @@ -8,8 +8,22 @@ import ( "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api" "github.com/anchordotdev/cli/api/apitest" + "github.com/anchordotdev/cli/cmdtest" + "github.com/stretchr/testify/require" ) +func TestCmdAuthWhoAmI(t *testing.T) { + cmd := CmdAuthWhoami + cfg := cli.ConfigFromCmd(cmd) + cfg.Test.SkipRunE = true + root := cmd.Root() + + t.Run("--help", func(t *testing.T) { + cmdtest.TestOutput(t, root, "auth", "whoami", "--help") + }) + +} + func TestWhoAmI(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("no pty support on windows") @@ -18,36 +32,24 @@ func TestWhoAmI(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - t.Run("signed-out", func(t *testing.T) { - cfg := new(cli.Config) - cfg.API.URL = srv.URL - cfg.Keyring.MockMode = true - - cmd := &WhoAmI{ - Config: cfg, - } + cfg := new(cli.Config) + cfg.API.URL = srv.URL + cfg.Keyring.MockMode = true + ctx = cli.ContextWithConfig(ctx, cfg) - _, err := apitest.RunTTY(ctx, cmd.UI()) - if want, got := api.ErrSignedOut, err; want != got { - t.Fatalf("want signin failure error %q, got %q", want, got) - } + t.Run("signed-out", func(t *testing.T) { + _, err := apitest.RunTTY(ctx, new(WhoAmI).UI()) + require.Error(t, err, api.ErrSignedOut) }) t.Run("signed-in", func(t *testing.T) { - cfg := new(cli.Config) - cfg.API.URL = srv.URL - cfg.Keyring.MockMode = true - - var err error - if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { + apiToken, err := srv.GeneratePAT("anky@anchor.dev") + if err != nil { t.Fatal(err) } + cfg.API.Token = apiToken - cmd := &WhoAmI{ - Config: cfg, - } - - buf, err := apitest.RunTTY(ctx, cmd.UI()) + buf, err := apitest.RunTTY(ctx, new(WhoAmI).UI()) if err != nil { t.Fatal(err) } diff --git a/cert/provision.go b/cert/provision.go index 645e7a6..a586317 100644 --- a/cert/provision.go +++ b/cert/provision.go @@ -15,12 +15,12 @@ import ( ) type Provision struct { - Config *cli.Config - Cert *tls.Certificate } func (p *Provision) RunTUI(ctx context.Context, drv *ui.Driver, domains ...string) error { + cfg := cli.ConfigFromContext(ctx) + drv.Activate(ctx, &models.Provision{ Domains: domains, }) @@ -45,7 +45,7 @@ func (p *Provision) RunTUI(ctx context.Context, drv *ui.Driver, domains ...strin Bytes: cert.Certificate[0], } - if !p.Config.Trust.MockMode { + if !cfg.Trust.MockMode { if err := os.WriteFile(certFile, pem.EncodeToMemory(certBlock), 0644); err != nil { return err } @@ -61,7 +61,7 @@ func (p *Provision) RunTUI(ctx context.Context, drv *ui.Driver, domains ...strin chainData = append(chainData, pem.EncodeToMemory(chainBlock)...) } - if !p.Config.Trust.MockMode { + if !cfg.Trust.MockMode { if err := os.WriteFile(chainFile, chainData, 0644); err != nil { return err } @@ -78,7 +78,7 @@ func (p *Provision) RunTUI(ctx context.Context, drv *ui.Driver, domains ...strin Bytes: keyDER, } - if !p.Config.Trust.MockMode { + if !cfg.Trust.MockMode { if err := os.WriteFile(keyFile, pem.EncodeToMemory(keyBlock), 0644); err != nil { return err } diff --git a/cli.go b/cli.go index 59d8001..db86cee 100644 --- a/cli.go +++ b/cli.go @@ -6,6 +6,7 @@ import ( "github.com/muesli/termenv" "github.com/anchordotdev/cli/ui" + "github.com/spf13/cobra" ) type Config struct { @@ -50,6 +51,10 @@ type Config struct { } `cmd:"setup"` } `cmd:"lcl"` + Test struct { + SkipRunE bool `desc:"skip RunE for testing purposes"` + } + Trust struct { Org string `desc:"organization" flag:"org,o" env:"ORG" json:"org" toml:"org"` Realm string `desc:"realm" flag:"realm,r" env:"REALM" json:"realm" toml:"realm"` @@ -69,9 +74,7 @@ type Config struct { User struct { Auth struct { - SignIn struct { - Email string `desc:"primary email address" flag:"email,e" env:"EMAIL" toml:"email"` - } `cmd:"signin"` + SignIn struct{} `cmd:"signin"` SignOut struct{} `cmd:"signout"` @@ -90,3 +93,17 @@ type UI struct { RunTTY func(context.Context, termenv.File) error RunTUI func(context.Context, *ui.Driver) error } + +type ContextKey string + +func ConfigFromContext(ctx context.Context) *Config { + return ctx.Value(ContextKey("Config")).(*Config) +} + +func ConfigFromCmd(cmd *cobra.Command) *Config { + return ConfigFromContext(cmd.Context()) +} + +func ContextWithConfig(ctx context.Context, cfg *Config) context.Context { + return context.WithValue(ctx, ContextKey("Config"), cfg) +} diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..52fe5cf --- /dev/null +++ b/cmd.go @@ -0,0 +1,185 @@ +package cli + +import ( + "context" + + "github.com/MakeNowJust/heredoc" + "github.com/anchordotdev/cli/ui" + "github.com/joeshaw/envdecode" + "github.com/mcuadros/go-defaults" + "github.com/spf13/cobra" +) + +type CmdDef struct { + Name string + + Use string + Short string + Long string + + SubDefs []CmdDef +} + +var rootDef = CmdDef{ + Name: "anchor", + + Use: "anchor [flags]", + Short: "anchor - a CLI interface for anchor.dev", + Long: heredoc.Doc(` + anchor is a command line interface for the Anchor certificate management platform. + + It provides a developer friendly interface for certificate management. + `), + + SubDefs: []CmdDef{ + { + Name: "auth", + + Use: "auth [flags]", + Short: "Manage Anchor.dev Authentication", + + SubDefs: []CmdDef{ + { + Name: "signin", + + Use: "signin [flags]", + Short: "Authenticate with your account", + Long: heredoc.Doc(` + Sign into your Anchor account for your local system user. + + Generate a new Personal Access Token (PAT) and store it in the system keychain + for the local system user. + `), + }, + { + Name: "signout", + + Use: "signout [flags]", + Short: "Invalidate your local Anchor account session", + Long: heredoc.Doc(` + Sign out of your Anchor account for your local system user. + + Remove your Personal Access Token (PAT) from the system keychain for your local + system user. + `), + }, + { + Name: "whoami", + + Use: "whoami [flags]", + Short: "Identify current account", + Long: heredoc.Doc(` + Print the details of the Anchor account for your local system user. + `), + }, + }, + }, + { + Name: "lcl", + + Use: "lcl [flags]", + Short: "Manage lcl.host Local Development Environment", + + SubDefs: []CmdDef{ + { + Name: "audit", + + Use: "audit [flags]", + Short: "Audit lcl.host HTTPS Local Development Environment", + }, + }, + }, + { + Name: "trust", + }, + { + Name: "version", + }, + }, +} + +var cmdDefByCommands = map[*cobra.Command]*CmdDef{} + +type UIer interface { + UI() UI +} + +func NewCmd[T UIer](parent *cobra.Command, name string, fn func(*cobra.Command)) *cobra.Command { + var def, parentDef *CmdDef + if parent != nil { + var ok bool + if parentDef, ok = cmdDefByCommands[parent]; !ok { + panic("unregistered parent command") + } + for _, sub := range parentDef.SubDefs { + if sub.Name == name { + def = &sub + break + } + } + if def == nil { + panic("missing subcommand definition") + } + } else { + def = &rootDef + } + + cfg := new(Config) + defaults.SetDefaults(cfg) + if err := envdecode.Decode(cfg); err != nil && err != envdecode.ErrNoTargetFieldsAreSet { + panic(err) + } + + cmd := &cobra.Command{ + Use: def.Use, + Short: def.Short, + Long: def.Long, + // FIMXE: add PersistentPreRunE version check + } + + if parent != nil { + parent.AddCommand(cmd) + } + + ctx := ContextWithConfig(context.Background(), cfg) + cmd.SetContext(ctx) + + fn(cmd) + + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + cfg := ConfigFromCmd(cmd) + if cfg.Test.SkipRunE { + return nil + } + + ctx, cancel := context.WithCancelCause(cmd.Context()) + defer cancel(nil) + + drv, prg := ui.NewDriverTUI(ctx) + errc := make(chan error) + + go func() { + defer close(errc) + + _, err := prg.Run() + cancel(err) + + errc <- err + }() + + var t T + if err := t.UI().RunTUI(ctx, drv); err != nil && err != context.Canceled { + prg.Quit() + + <-errc // TODO: report this somewhere? + return err + } + + prg.Quit() + + return <-errc // TODO: special handling for a UI error + } + + cmdDefByCommands[cmd] = def + return cmd +} diff --git a/cmd/anchor/main.go b/cmd/anchor/main.go index 4dffd28..972dfb6 100644 --- a/cmd/anchor/main.go +++ b/cmd/anchor/main.go @@ -38,7 +38,7 @@ var ( SubCommands: []*cli.Command{ { - UI: auth.SignIn{Config: cfg}.UI(), + UI: auth.SignIn{}.UI(), Name: "signin", Use: "signin", @@ -51,7 +51,7 @@ var ( `), }, { - UI: auth.SignOut{Config: cfg}.UI(), + UI: auth.SignOut{}.UI(), Name: "signout", Use: "signout", @@ -64,7 +64,7 @@ var ( `), }, { - UI: auth.WhoAmI{Config: cfg}.UI(), + UI: auth.WhoAmI{}.UI(), Name: "whoami", Use: "whoami", @@ -76,7 +76,7 @@ var ( }, }, { - UI: lcl.Command{Config: cfg}.UI(), + UI: lcl.Command{}.UI(), Name: "lcl", Use: "lcl ", @@ -84,35 +84,35 @@ var ( SubCommands: []*cli.Command{ { - UI: lcl.Audit{Config: cfg}.UI(), + UI: lcl.Audit{}.UI(), Name: "audit", Use: "audit", Short: "Audit lcl.host HTTPS Local Development Environment", }, { - UI: lcl.LclClean{Config: cfg}.UI(), + UI: lcl.LclClean{}.UI(), Name: "clean", Use: "clean", Short: "Clean lcl.host CA Certificates from the Local Trust Store(s)", }, { - UI: lcl.LclConfig{Config: cfg}.UI(), + UI: lcl.LclConfig{}.UI(), Name: "config", Use: "config", Short: "Configure System for lcl.host Local Development", }, { - UI: lcl.MkCert{Config: cfg}.UI(), + UI: lcl.MkCert{}.UI(), Name: "mkcert", Use: "mkcert", Short: "Provision Certificate for lcl.host Local Development", }, { - UI: lcl.Setup{Config: cfg}.UI(), + UI: lcl.Setup{}.UI(), Name: "setup", Use: "setup", @@ -121,7 +121,7 @@ var ( }, }, { - UI: trust.Command{Config: cfg}.UI(), + UI: trust.Command{}.UI(), Name: "trust", Use: "trust [org[/realm[/ca]]]", @@ -138,7 +138,7 @@ var ( SubCommands: []*cli.Command{ { - UI: trust.Audit{Config: cfg}.UI(), + UI: trust.Audit{}.UI(), Name: "audit", Use: "audit [org[/realm[/ca]]]", @@ -158,7 +158,7 @@ var ( `), }, { - UI: trust.Clean{Config: cfg}.UI(), + UI: trust.Clean{}.UI(), Name: "clean", Use: "clean TODO", @@ -179,8 +179,6 @@ var ( Preflight: versionCheck, } - cfg = new(cli.Config) - // Version info set by GoReleaser via ldflags version, commit, date string @@ -196,7 +194,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if err := cmd.Execute(ctx, cfg); err != nil { + if err := cmd.Execute(ctx); err != nil { os.Exit(1) } } @@ -215,8 +213,8 @@ func versionCheck(ctx context.Context) error { } if release.TagName == nil || *release.TagName != "v"+version { - fmt.Println(ui.StepHint(fmt.Sprintf("A new release of the anchor CLI is available."))) - command := "brew update && brew upgrade anchordotdev/tap/anchor" + fmt.Println(ui.StepHint("A new release of the anchor CLI is available.")) + command := "brew update && brew upgrade anchor" if err := clipboard.WriteAll(command); err == nil { fmt.Println(ui.StepAlert(fmt.Sprintf("Copied %s to your clipboard.", ui.Announce(command)))) } diff --git a/cmdtest/cmdtest.go b/cmdtest/cmdtest.go new file mode 100644 index 0000000..3fadbd2 --- /dev/null +++ b/cmdtest/cmdtest.go @@ -0,0 +1,28 @@ +package cmdtest + +import ( + "bytes" + "io" + "testing" + + "github.com/charmbracelet/x/exp/teatest" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestOutput(t *testing.T, cmd *cobra.Command, args ...string) { + b := new(bytes.Buffer) + cmd.SetErr(b) + cmd.SetOut(b) + cmd.SetArgs(args) + + err := cmd.Execute() + require.NoError(t, err) + + out, err := io.ReadAll(b) + if err != nil { + t.Fatal(err) + } + + teatest.RequireEqualOutput(t, out) +} diff --git a/command.go b/command.go index 9139afb..893d570 100644 --- a/command.go +++ b/command.go @@ -32,8 +32,10 @@ type Command struct { Preflight func(context.Context) error } -func (c *Command) Execute(ctx context.Context, cfg *Config) error { +func (c *Command) Execute(ctx context.Context) error { + cfg := new(Config) defaults.SetDefaults(cfg) + ctx = ContextWithConfig(ctx, cfg) // enable ANSI processing for Windows, see: https://github.com/muesli/termenv#platform-support restoreConsole, err := termenv.EnableVirtualTerminalProcessing(termenv.DefaultOutput()) @@ -60,6 +62,7 @@ func (c *Command) cobraCommand(ctx context.Context, cfgv reflect.Value) *cobra.C Hidden: c.Hidden, SilenceUsage: true, } + cmd.SetContext(ctx) if c.Preflight != nil { cmd.PersistentPreRunE = func(_ *cobra.Command, _ []string) error { @@ -69,10 +72,10 @@ func (c *Command) cobraCommand(ctx context.Context, cfgv reflect.Value) *cobra.C switch { case c.RunTUI != nil: - cmd.RunE = func(_ *cobra.Command, args []string) error { + cmd.RunE = func(cmd *cobra.Command, args []string) error { // TODO: positional args - ctx, cancel := context.WithCancelCause(ctx) + ctx, cancel := context.WithCancelCause(cmd.Context()) defer cancel(nil) drv, prg := ui.NewDriverTUI(ctx) @@ -99,9 +102,12 @@ func (c *Command) cobraCommand(ctx context.Context, cfgv reflect.Value) *cobra.C return <-errc // TODO: special handling for a UI error } case c.RunTTY != nil: - cmd.RunE = func(_ *cobra.Command, args []string) error { + cmd.RunE = func(cmd *cobra.Command, args []string) error { // TODO: positional args + ctx, cancel := context.WithCancelCause(cmd.Context()) + defer cancel(nil) + return c.RunTTY(ctx, termenv.DefaultOutput().TTY()) } } diff --git a/go.mod b/go.mod index 253cabf..48b4dc1 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.8.4 github.com/zalando/go-keyring v0.2.4 golang.org/x/crypto v0.21.0 golang.org/x/exp v0.0.0-20240318143956-a85f2c67cd81 diff --git a/keyring/keyring.go b/keyring/keyring.go index db7b254..1adf772 100644 --- a/keyring/keyring.go +++ b/keyring/keyring.go @@ -65,5 +65,6 @@ func (k *Keyring) Set(id label, secret string) error { } func (k *Keyring) service(id label) string { - return k.Config.API.URL + " " + string(id) + url := k.Config.API.URL + return url + " " + string(id) } diff --git a/lcl/audit.go b/lcl/audit.go index 945373d..1db5817 100644 --- a/lcl/audit.go +++ b/lcl/audit.go @@ -9,11 +9,14 @@ import ( "github.com/anchordotdev/cli/lcl/models" "github.com/anchordotdev/cli/trust" "github.com/anchordotdev/cli/ui" + "github.com/spf13/cobra" ) -type Audit struct { - Config *cli.Config +var CmdAuthLclAudit = cli.NewCmd[Audit](CmdLcl, "audit", func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs +}) +type Audit struct { anc *api.Session orgSlug, realmSlug string } @@ -27,7 +30,6 @@ func (c Audit) UI() cli.UI { func (c Audit) run(ctx context.Context, drv *ui.Driver) error { var err error cmd := &auth.Client{ - Config: c.Config, Anc: c.anc, Source: "lclhost", } @@ -89,7 +91,7 @@ func (c Audit) perform(ctx context.Context, drv *ui.Driver) (*LclAuditResult, er drv.Activate(ctx, &models.AuditTrust{}) // FIXME: use config anc? - trustStoreAuditResult, err := trust.PerformAudit(ctx, c.Config, c.anc, c.orgSlug, c.realmSlug) + trustStoreAuditResult, err := trust.PerformAudit(ctx, c.anc, c.orgSlug, c.realmSlug) if err != nil { return nil, err } diff --git a/lcl/audit_test.go b/lcl/audit_test.go index 2475162..fafa71f 100644 --- a/lcl/audit_test.go +++ b/lcl/audit_test.go @@ -6,37 +6,43 @@ import ( "testing" "github.com/anchordotdev/cli" + "github.com/anchordotdev/cli/cmdtest" "github.com/anchordotdev/cli/ui/uitest" ) +func TestCmdDefLclAudit(t *testing.T) { + cmd := CmdAuthLclAudit + cfg := cli.ConfigFromCmd(cmd) + cfg.Test.SkipRunE = true + root := cmd.Root() + + t.Run("--help", func(t *testing.T) { + cmdtest.TestOutput(t, root, "lcl", "audit", "--help") + }) +} + func TestAudit(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("no pty support on windows") } + if srv.IsProxy() { + t.Skip("lcl audit unsupported in proxy mode") + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() cfg := new(cli.Config) cfg.API.URL = srv.URL - + cfg.Trust.Stores = []string{"mock"} var err error if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) t.Run("basics", func(t *testing.T) { - if srv.IsProxy() { - t.Skip("lcl audit unsupported in proxy mode") - } - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - cmd := Audit{ - Config: cfg, - } - - uitest.TestTUIOutput(ctx, t, cmd.UI()) + uitest.TestTUIOutput(ctx, t, new(Audit).UI()) }) } diff --git a/lcl/clean.go b/lcl/clean.go index d186ce5..f99e0f9 100644 --- a/lcl/clean.go +++ b/lcl/clean.go @@ -12,8 +12,6 @@ import ( ) type LclClean struct { - Config *cli.Config - anc *api.Session orgSlug, realmSlug string } @@ -25,17 +23,18 @@ func (c LclClean) UI() cli.UI { } func (c LclClean) run(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + var err error clientCmd := &auth.Client{ - Config: c.Config, - Anc: c.anc, + Anc: c.anc, } c.anc, err = clientCmd.Perform(ctx, drv) if err != nil { return err } - c.Config.Trust.Clean.States = []string{"all"} + cfg.Trust.Clean.States = []string{"all"} if c.orgSlug == "" { userInfo, err := c.anc.UserInfo(ctx) @@ -51,11 +50,10 @@ func (c LclClean) run(ctx context.Context, drv *ui.Driver) error { drv.Activate(ctx, &models.LclCleanHeader{}) drv.Activate(ctx, &models.LclCleanHint{ - TrustStores: c.Config.Trust.Stores, + TrustStores: cfg.Trust.Stores, }) cmd := &trust.Clean{ - Config: c.Config, Anc: c.anc, OrgSlug: c.orgSlug, RealmSlug: c.realmSlug, diff --git a/lcl/clean_test.go b/lcl/clean_test.go index 1f7e11b..1dc1859 100644 --- a/lcl/clean_test.go +++ b/lcl/clean_test.go @@ -17,11 +17,11 @@ func TestClean(t *testing.T) { cfg.Trust.MockMode = true cfg.Trust.NoSudo = true cfg.Trust.Stores = []string{"mock"} - var err error if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) t.Run("basics", func(t *testing.T) { if srv.IsProxy() { @@ -31,9 +31,7 @@ func TestClean(t *testing.T) { ctx, cancel := context.WithCancel(ctx) defer cancel() - cmd := LclClean{ - Config: cfg, - } + cmd := LclClean{} uitest.TestTUIOutput(ctx, t, cmd.UI()) }) diff --git a/lcl/config.go b/lcl/config.go index f78aa37..d25236a 100644 --- a/lcl/config.go +++ b/lcl/config.go @@ -24,8 +24,6 @@ import ( var loopbackAddrs = []string{"127.0.0.1", "::1"} type LclConfig struct { - Config *cli.Config - anc *api.Session orgSlug, realmSlug string } @@ -39,7 +37,6 @@ func (c LclConfig) UI() cli.UI { func (c LclConfig) runTUI(ctx context.Context, drv *ui.Driver) error { var err error cmd := &auth.Client{ - Config: c.Config, Anc: c.anc, Source: "lclhost", } @@ -60,6 +57,8 @@ func (c LclConfig) runTUI(ctx context.Context, drv *ui.Driver) error { } func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + if c.orgSlug == "" { userInfo, err := c.anc.UserInfo(ctx) if err != nil { @@ -72,7 +71,7 @@ func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { c.realmSlug = "localhost" } - _, diagPort, err := net.SplitHostPort(c.Config.Lcl.DiagnosticAddr) + _, diagPort, err := net.SplitHostPort(cfg.Lcl.DiagnosticAddr) if err != nil { return err } @@ -117,7 +116,6 @@ func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { domains := []string{serviceName + ".lcl.host", serviceName + ".localhost"} cmdProvision := &Provision{ - Config: c.Config, Domains: domains, orgSlug: c.orgSlug, realmSlug: c.realmSlug, @@ -140,8 +138,8 @@ func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { // FIXME: ? spinner while booting server, transitioning to server booted message srvDiag := &diagnostic.Server{ - Addr: c.Config.Lcl.DiagnosticAddr, - LclHostURL: c.Config.Lcl.LclHostURL, + Addr: cfg.Lcl.DiagnosticAddr, + LclHostURL: cfg.Lcl.LclHostURL, GetCertificate: func(cii *tls.ClientHelloInfo) (*tls.Certificate, error) { return cert, nil }, @@ -152,7 +150,7 @@ func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { } requestc := srvDiag.RequestChan() - auditInfo, err := trust.PerformAudit(ctx, c.Config, c.anc, c.orgSlug, c.realmSlug) + auditInfo, err := trust.PerformAudit(ctx, c.anc, c.orgSlug, c.realmSlug) if err != nil { return err } @@ -182,7 +180,7 @@ func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { return ctx.Err() } - if !c.Config.Trust.MockMode { + if !cfg.Trust.MockMode { if err := browser.OpenURL(httpURL.String()); err != nil { return err } @@ -201,7 +199,6 @@ func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { } cmdTrust := &trust.Command{ - Config: c.Config, Anc: c.anc, OrgSlug: c.orgSlug, RealmSlug: c.realmSlug, @@ -235,7 +232,7 @@ func (c LclConfig) perform(ctx context.Context, drv *ui.Driver) error { return ctx.Err() } - if !c.Config.Trust.MockMode { + if !cfg.Trust.MockMode { if err := browser.OpenURL(httpsURL.String()); err != nil { return err } diff --git a/lcl/config_test.go b/lcl/config_test.go index df7426d..1cdc704 100644 --- a/lcl/config_test.go +++ b/lcl/config_test.go @@ -37,11 +37,11 @@ func TestLclConfig(t *testing.T) { cfg.Trust.MockMode = true cfg.Trust.NoSudo = true cfg.Trust.Stores = []string{"mock"} - var err error if cfg.API.Token, err = srv.GeneratePAT("lcl_config@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) t.Run("basics", func(t *testing.T) { ctx, cancel := context.WithCancel(ctx) @@ -49,9 +49,7 @@ func TestLclConfig(t *testing.T) { drv, tm := uitest.TestTUI(ctx, t) - cmd := LclConfig{ - Config: cfg, - } + cmd := LclConfig{} errc := make(chan error, 1) go func() { diff --git a/lcl/lcl.go b/lcl/lcl.go index 4e09a4d..70a23b3 100644 --- a/lcl/lcl.go +++ b/lcl/lcl.go @@ -15,11 +15,14 @@ import ( "github.com/anchordotdev/cli/lcl/models" "github.com/anchordotdev/cli/ui" "github.com/anchordotdev/cli/version" + "github.com/spf13/cobra" ) -type Command struct { - Config *cli.Config +var CmdLcl = cli.NewCmd[Command](cli.CmdRoot, "lcl", func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs +}) +type Command struct { anc *api.Session } @@ -34,7 +37,6 @@ func (c *Command) run(ctx context.Context, drv *ui.Driver) error { var err error cmd := &auth.Client{ - Config: c.Config, Anc: c.anc, Hint: &models.LclSignInHint{}, Source: "lclhost", @@ -60,7 +62,6 @@ func (c *Command) run(ctx context.Context, drv *ui.Driver) error { drv.Activate(ctx, &models.AuditHint{}) cmdAudit := &Audit{ - Config: c.Config, anc: c.anc, orgSlug: orgSlug, realmSlug: realmSlug, @@ -79,7 +80,6 @@ func (c *Command) run(ctx context.Context, drv *ui.Driver) error { drv.Activate(ctx, &models.LclConfigHint{}) cmdConfig := &LclConfig{ - Config: c.Config, anc: c.anc, orgSlug: orgSlug, realmSlug: realmSlug, @@ -95,7 +95,6 @@ func (c *Command) run(ctx context.Context, drv *ui.Driver) error { drv.Activate(ctx, &models.SetupHint{}) cmdSetup := &Setup{ - Config: c.Config, anc: c.anc, orgSlug: orgSlug, } diff --git a/lcl/lcl_test.go b/lcl/lcl_test.go index 2ceedf9..65fee3a 100644 --- a/lcl/lcl_test.go +++ b/lcl/lcl_test.go @@ -18,6 +18,7 @@ import ( "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api/apitest" + "github.com/anchordotdev/cli/cmdtest" "github.com/anchordotdev/cli/truststore" "github.com/anchordotdev/cli/ui/uitest" ) @@ -39,6 +40,15 @@ func TestMain(m *testing.M) { srv.Close() } +func TestCmdLcl(t *testing.T) { + cmd := CmdLcl + root := cmd.Root() + + t.Run("--help", func(t *testing.T) { + cmdtest.TestOutput(t, root, "lcl", "--help") + }) +} + func TestLcl(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -65,12 +75,12 @@ func TestLcl(t *testing.T) { cfg.Trust.MockMode = true cfg.Trust.NoSudo = true cfg.Trust.Stores = []string{"mock"} - - setupGuideURL := cfg.AnchorURL + "lcl/services/test-app/guide" - if cfg.API.Token, err = srv.GeneratePAT("lcl@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) + + setupGuideURL := cfg.AnchorURL + "lcl/services/test-app/guide" t.Run("basics", func(t *testing.T) { ctx, cancel := context.WithCancel(ctx) @@ -78,9 +88,7 @@ func TestLcl(t *testing.T) { drv, tm := uitest.TestTUI(ctx, t) - cmd := Command{ - Config: cfg, - } + cmd := Command{} errc := make(chan error, 1) go func() { diff --git a/lcl/mkcert.go b/lcl/mkcert.go index ebda5c3..d573636 100644 --- a/lcl/mkcert.go +++ b/lcl/mkcert.go @@ -15,8 +15,7 @@ import ( ) type MkCert struct { - Config *cli.Config - anc *api.Session + anc *api.Session domains []string eab *api.Eab @@ -37,7 +36,6 @@ func (c MkCert) UI() cli.UI { func (c *MkCert) run(ctx context.Context, drv *ui.Driver) error { var err error cmd := &auth.Client{ - Config: c.Config, Anc: c.anc, Source: "lclhost", } @@ -52,8 +50,7 @@ func (c *MkCert) run(ctx context.Context, drv *ui.Driver) error { } cmdCert := cert.Provision{ - Cert: tlsCert, - Config: c.Config, + Cert: tlsCert, } if err := cmdCert.RunTUI(ctx, drv, c.domains...); err != nil { @@ -64,6 +61,8 @@ func (c *MkCert) run(ctx context.Context, drv *ui.Driver) error { } func (c *MkCert) perform(ctx context.Context, drv *ui.Driver) (*tls.Certificate, error) { + cfg := cli.ConfigFromContext(ctx) + var err error if c.chainSlug == "" { @@ -71,8 +70,8 @@ func (c *MkCert) perform(ctx context.Context, drv *ui.Driver) (*tls.Certificate, } if len(c.domains) == 0 { - if c.Config.Lcl.MkCert.Domains != "" { - c.domains = strings.Split(c.Config.Lcl.MkCert.Domains, ",") + if cfg.Lcl.MkCert.Domains != "" { + c.domains = strings.Split(cfg.Lcl.MkCert.Domains, ",") } if len(c.domains) == 0 { return nil, errors.New("domains is required") @@ -92,14 +91,14 @@ func (c *MkCert) perform(ctx context.Context, drv *ui.Driver) (*tls.Certificate, } if c.serviceSlug == "" { - c.serviceSlug = c.Config.Lcl.Service + c.serviceSlug = cfg.Lcl.Service if c.serviceSlug == "" { return nil, errors.New("service is required") } } if c.subCaSubjectUID == "" { - c.subCaSubjectUID = c.Config.Lcl.MkCert.SubCa + c.subCaSubjectUID = cfg.Lcl.MkCert.SubCa if c.subCaSubjectUID == "" { return nil, errors.New("subca is required") } @@ -110,7 +109,7 @@ func (c *MkCert) perform(ctx context.Context, drv *ui.Driver) (*tls.Certificate, return nil, err } - acmeURL := c.Config.AnchorURL + "/" + url.QueryEscape(c.orgSlug) + "/" + url.QueryEscape(c.realmSlug) + "/x509/" + c.chainSlug + "/acme" + acmeURL := cfg.AnchorURL + "/" + url.QueryEscape(c.orgSlug) + "/" + url.QueryEscape(c.realmSlug) + "/x509/" + c.chainSlug + "/acme" tlsCert, err := provisionCert(c.eab, c.domains, acmeURL) if err != nil { diff --git a/lcl/mkcert_test.go b/lcl/mkcert_test.go index 6a558d5..d6057a6 100644 --- a/lcl/mkcert_test.go +++ b/lcl/mkcert_test.go @@ -19,11 +19,11 @@ func TestLclMkcert(t *testing.T) { cfg.Trust.MockMode = true cfg.Trust.NoSudo = true cfg.Trust.Stores = []string{"mock"} - var err error if cfg.API.Token, err = srv.GeneratePAT("lcl_mkcert@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) t.Run("basics", func(t *testing.T) { t.Skip("pending better support for building needed models before running") @@ -36,7 +36,6 @@ func TestLclMkcert(t *testing.T) { defer cancel() cmd := MkCert{ - Config: cfg, domains: []string{"hi-lcl-mkcert.lcl.host", "hi-lcl-mkcert.localhost"}, subCaSubjectUID: "ABCD:EF12:23456", } diff --git a/lcl/provision.go b/lcl/provision.go index f7dcdf1..59b23f1 100644 --- a/lcl/provision.go +++ b/lcl/provision.go @@ -4,15 +4,12 @@ import ( "context" "crypto/tls" - "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/api" "github.com/anchordotdev/cli/lcl/models" "github.com/anchordotdev/cli/ui" ) type Provision struct { - Config *cli.Config - Domains []string orgSlug, realmSlug string } @@ -54,7 +51,6 @@ func (p *Provision) run(ctx context.Context, drv *ui.Driver, anc *api.Session, s } cmdMkCert := &MkCert{ - Config: p.Config, anc: anc, chainSlug: attach.Relationships.Chain.Slug, domains: p.Domains, diff --git a/lcl/provision_test.go b/lcl/provision_test.go index 8658ec2..2ebfe3e 100644 --- a/lcl/provision_test.go +++ b/lcl/provision_test.go @@ -42,7 +42,6 @@ func TestProvision(t *testing.T) { drv, tm := uitest.TestTUI(ctx, t) cmd := &Provision{ - Config: cfg, Domains: []string{"subdomain.lcl.host", "subdomain.localhost"}, } diff --git a/lcl/setup.go b/lcl/setup.go index 422dfc2..a874196 100644 --- a/lcl/setup.go +++ b/lcl/setup.go @@ -24,8 +24,6 @@ import ( ) type Setup struct { - Config *cli.Config - anc *api.Session orgSlug string } @@ -39,7 +37,6 @@ func (c Setup) UI() cli.UI { func (c Setup) run(ctx context.Context, drv *ui.Driver) error { var err error cmd := &auth.Client{ - Config: c.Config, Anc: c.anc, Source: "lclhost", } @@ -60,6 +57,8 @@ func (c Setup) run(ctx context.Context, drv *ui.Driver) error { } func (c Setup) perform(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + drv.Activate(ctx, &models.SetupScan{}) if c.orgSlug == "" { @@ -77,8 +76,8 @@ func (c Setup) perform(ctx context.Context, drv *ui.Driver) error { dirFS := os.DirFS(path).(detection.FS) detectors := detection.DefaultDetectors - if c.Config.Lcl.Setup.Language != "" { - if langDetector, ok := detection.DetectorsByFlag[c.Config.Lcl.Setup.Language]; !ok { + if cfg.Lcl.Setup.Language != "" { + if langDetector, ok := detection.DetectorsByFlag[cfg.Lcl.Setup.Language]; !ok { return errors.New("invalid language specified") } else { detectors = []detection.Detector{langDetector} @@ -159,7 +158,6 @@ func (c Setup) perform(ctx context.Context, drv *ui.Driver) error { realmSlug := "localhost" cmdProvision := &Provision{ - Config: c.Config, Domains: domains, orgSlug: c.orgSlug, realmSlug: realmSlug, @@ -171,15 +169,14 @@ func (c Setup) perform(ctx context.Context, drv *ui.Driver) error { } cmdCert := cert.Provision{ - Cert: tlsCert, - Config: c.Config, + Cert: tlsCert, } if err := cmdCert.RunTUI(ctx, drv, domains...); err != nil { return err } - setupGuideURL := c.Config.AnchorURL + "/" + url.QueryEscape(c.orgSlug) + "/services/" + url.QueryEscape(service.Slug) + "/guide" + setupGuideURL := cfg.AnchorURL + "/" + url.QueryEscape(c.orgSlug) + "/services/" + url.QueryEscape(service.Slug) + "/guide" setupGuideConfirmCh := make(chan struct{}) drv.Activate(ctx, &models.SetupGuidePrompt{ @@ -194,7 +191,7 @@ func (c Setup) perform(ctx context.Context, drv *ui.Driver) error { return ctx.Err() } - if !c.Config.Trust.MockMode { + if !cfg.Trust.MockMode { if err := browser.OpenURL(setupGuideURL); err != nil { return err } diff --git a/lcl/setup_test.go b/lcl/setup_test.go index f2f01d3..2fdc035 100644 --- a/lcl/setup_test.go +++ b/lcl/setup_test.go @@ -25,13 +25,13 @@ func TestSetup(t *testing.T) { cfg.Trust.MockMode = true cfg.Trust.NoSudo = true cfg.Trust.Stores = []string{"mock"} - - setupGuideURL := cfg.AnchorURL + "lcl_setup/services/test-app/guide" - var err error if cfg.API.Token, err = srv.GeneratePAT("lcl_setup@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) + + setupGuideURL := cfg.AnchorURL + "lcl_setup/services/test-app/guide" t.Run("basics", func(t *testing.T) { ctx, cancel := context.WithCancel(ctx) @@ -39,9 +39,7 @@ func TestSetup(t *testing.T) { drv, tm := uitest.TestTUI(ctx, t) - cmd := Setup{ - Config: cfg, - } + cmd := Setup{} errc := make(chan error, 1) go func() { diff --git a/lcl/testdata/TestCmdDefLclAudit/--help.golden b/lcl/testdata/TestCmdDefLclAudit/--help.golden new file mode 100644 index 0000000..c1c1cde --- /dev/null +++ b/lcl/testdata/TestCmdDefLclAudit/--help.golden @@ -0,0 +1,7 @@ +Audit lcl.host HTTPS Local Development Environment + +Usage: + anchor lcl audit [flags] + +Flags: + -h, --help help for audit diff --git a/lcl/testdata/TestCmdDefLclAudit/basics.golden b/lcl/testdata/TestCmdDefLclAudit/basics.golden new file mode 100644 index 0000000..b8af6f0 --- /dev/null +++ b/lcl/testdata/TestCmdDefLclAudit/basics.golden @@ -0,0 +1,27 @@ +─── Client ───────────────────────────────────────────────────────────────────── + * Checking authentication: probing credentials locally…* +─── Client ───────────────────────────────────────────────────────────────────── + * Checking authentication: testing credentials remotely…* +─── AuditHeader ──────────────────────────────────────────────────────────────── +# Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` +─── AuditHint ────────────────────────────────────────────────────────────────── +# Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` + | We'll begin by checking your system to determine what you need for your setup. +─── AuditResources ───────────────────────────────────────────────────────────── +# Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` + | We'll begin by checking your system to determine what you need for your setup. + * Checking resources on Anchor.dev…* +─── AuditResources ───────────────────────────────────────────────────────────── +# Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` + | We'll begin by checking your system to determine what you need for your setup. + - Checked resources on Anchor.dev: need to provision resources. +─── AuditTrust ───────────────────────────────────────────────────────────────── +# Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` + | We'll begin by checking your system to determine what you need for your setup. + - Checked resources on Anchor.dev: need to provision resources. + * Scanning local and expected CA certificates…* +─── AuditTrust ───────────────────────────────────────────────────────────────── +# Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` + | We'll begin by checking your system to determine what you need for your setup. + - Checked resources on Anchor.dev: need to provision resources. + - Scanned local and expected CA certificates: need to install 2 missing certificates. diff --git a/lcl/testdata/TestCmdLcl/--help.golden b/lcl/testdata/TestCmdLcl/--help.golden new file mode 100644 index 0000000..85ae667 --- /dev/null +++ b/lcl/testdata/TestCmdLcl/--help.golden @@ -0,0 +1,13 @@ +Manage lcl.host Local Development Environment + +Usage: + anchor lcl [flags] + anchor lcl [command] + +Available Commands: + audit Audit lcl.host HTTPS Local Development Environment + +Flags: + -h, --help help for lcl + +Use "anchor lcl [command] --help" for more information about a command. diff --git a/root.go b/root.go new file mode 100644 index 0000000..697d90b --- /dev/null +++ b/root.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/anchordotdev/cli/ui" +) + +var CmdRoot = NewCmd[ShowHelp](nil, "anchor", func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs + + // allow pass through of update arg for teatest golden tests + cmd.Flags().Bool("update", false, "update .golden files") + if err := cmd.Flags().MarkHidden("update"); err != nil { + panic(err) + } + cmd.Flags().Bool("prism-proxy", false, "run prism in proxy mode") + if err := cmd.Flags().MarkHidden("prism-proxy"); err != nil { + panic(err) + } +}) + +type ShowHelp struct{} + +func (c ShowHelp) UI() UI { + return UI{ + RunTUI: c.RunTUI, + } +} + +func (c ShowHelp) RunTUI(ctx context.Context, drv *ui.Driver) error { + return pflag.ErrHelp +} diff --git a/root_test.go b/root_test.go new file mode 100644 index 0000000..67fca1b --- /dev/null +++ b/root_test.go @@ -0,0 +1,30 @@ +package cli_test + +import ( + "flag" + "testing" + + "github.com/anchordotdev/cli" + _ "github.com/anchordotdev/cli/auth" + "github.com/anchordotdev/cli/cmdtest" + _ "github.com/anchordotdev/cli/lcl" + _ "github.com/anchordotdev/cli/trust" + _ "github.com/anchordotdev/cli/version" +) + +var ( + _ = flag.Bool("prism-verbose", false, "ignored") + _ = flag.Bool("prism-proxy", false, "ignored") +) + +func TestCmdRoot(t *testing.T) { + root := cli.CmdRoot + + t.Run("root", func(t *testing.T) { + cmdtest.TestOutput(t, root) + }) + + t.Run("--help", func(t *testing.T) { + cmdtest.TestOutput(t, root, "--help") + }) +} diff --git a/testdata/TestCmdRoot/--help.golden b/testdata/TestCmdRoot/--help.golden new file mode 100644 index 0000000..bb88da6 --- /dev/null +++ b/testdata/TestCmdRoot/--help.golden @@ -0,0 +1,18 @@ +anchor is a command line interface for the Anchor certificate management platform. + +It provides a developer friendly interface for certificate management. + +Usage: + anchor [flags] + anchor [command] + +Available Commands: + auth Manage Anchor.dev Authentication + completion Generate the autocompletion script for the specified shell + help Help about any command + lcl Manage lcl.host Local Development Environment + +Flags: + -h, --help help for anchor + +Use "anchor [command] --help" for more information about a command. diff --git a/testdata/TestCmdRoot/root.golden b/testdata/TestCmdRoot/root.golden new file mode 100644 index 0000000..bb88da6 --- /dev/null +++ b/testdata/TestCmdRoot/root.golden @@ -0,0 +1,18 @@ +anchor is a command line interface for the Anchor certificate management platform. + +It provides a developer friendly interface for certificate management. + +Usage: + anchor [flags] + anchor [command] + +Available Commands: + auth Manage Anchor.dev Authentication + completion Generate the autocompletion script for the specified shell + help Help about any command + lcl Manage lcl.host Local Development Environment + +Flags: + -h, --help help for anchor + +Use "anchor [command] --help" for more information about a command. diff --git a/trust/audit.go b/trust/audit.go index 5e4fa8d..b422291 100644 --- a/trust/audit.go +++ b/trust/audit.go @@ -13,9 +13,7 @@ import ( "github.com/anchordotdev/cli/truststore" ) -type Audit struct { - Config *cli.Config -} +type Audit struct{} func (a Audit) UI() cli.UI { return cli.UI{ @@ -24,12 +22,14 @@ func (a Audit) UI() cli.UI { } func (a *Audit) run(ctx context.Context, tty termenv.File) error { - anc, err := api.NewClient(a.Config) + cfg := cli.ConfigFromContext(ctx) + + anc, err := api.NewClient(cfg) if err != nil { return err } - org, realm, err := fetchOrgAndRealm(ctx, a.Config, anc) + org, realm, err := fetchOrgAndRealm(ctx, anc) if err != nil { return err } @@ -39,7 +39,7 @@ func (a *Audit) run(ctx context.Context, tty termenv.File) error { return err } - stores, _, err := loadStores(a.Config) + stores, _, err := loadStores(cfg) if err != nil { return err } diff --git a/trust/audit_test.go b/trust/audit_test.go index c4bbeab..7f2642a 100644 --- a/trust/audit_test.go +++ b/trust/audit_test.go @@ -28,18 +28,18 @@ func TestAudit(t *testing.T) { cfg := new(cli.Config) cfg.API.URL = srv.URL cfg.Trust.Stores = []string{"mock"} - var err error if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) anc, err := api.NewClient(cfg) if err != nil { t.Fatal(err) } - org, realm, err := fetchOrgAndRealm(ctx, cfg, anc) + org, realm, err := fetchOrgAndRealm(ctx, anc) if err != nil { t.Fatal(err) } @@ -57,9 +57,7 @@ func TestAudit(t *testing.T) { } defer func() { truststore.MockCAs = nil }() - cmd := &Audit{ - Config: cfg, - } + cmd := &Audit{} buf, err := apitest.RunTTY(ctx, cmd.UI()) if err != nil { diff --git a/trust/clean.go b/trust/clean.go index 2c39485..890159f 100644 --- a/trust/clean.go +++ b/trust/clean.go @@ -13,8 +13,6 @@ import ( ) type Clean struct { - Config *cli.Config - Anc *api.Session OrgSlug, RealmSlug string } @@ -26,10 +24,11 @@ func (c Clean) UI() cli.UI { } func (c *Clean) runTUI(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + var err error cmd := &auth.Client{ - Config: c.Config, - Anc: c.Anc, + Anc: c.Anc, } c.Anc, err = cmd.Perform(ctx, drv) if err != nil { @@ -38,8 +37,8 @@ func (c *Clean) runTUI(ctx context.Context, drv *ui.Driver) error { drv.Activate(ctx, &models.TrustCleanHeader{}) drv.Activate(ctx, &models.TrustCleanHint{ - CertStates: c.Config.Trust.Clean.States, - TrustStores: c.Config.Trust.Stores, + CertStates: cfg.Trust.Clean.States, + TrustStores: cfg.Trust.Stores, }) err = c.Perform(ctx, drv) @@ -51,9 +50,11 @@ func (c *Clean) runTUI(ctx context.Context, drv *ui.Driver) error { } func (c Clean) Perform(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + var err error if c.OrgSlug == "" && c.RealmSlug == "" { - c.OrgSlug, c.RealmSlug, err = fetchOrgAndRealm(ctx, c.Config, c.Anc) + c.OrgSlug, c.RealmSlug, err = fetchOrgAndRealm(ctx, c.Anc) if err != nil { return err } @@ -66,7 +67,7 @@ func (c Clean) Perform(ctx context.Context, drv *ui.Driver) error { return err } - stores, sudoMgr, err := loadStores(c.Config) + stores, sudoMgr, err := loadStores(cfg) if err != nil { return err } @@ -89,7 +90,7 @@ func (c Clean) Perform(ctx context.Context, drv *ui.Driver) error { return err } - targetCAs := info.AllCAs(c.Config.Trust.Clean.States...) + targetCAs := info.AllCAs(cfg.Trust.Clean.States...) drv.Send(targetCAs) tmpDir, err := os.MkdirTemp("", "anchor-trust-clean") @@ -102,11 +103,11 @@ func (c Clean) Perform(ctx context.Context, drv *ui.Driver) error { confirmc := make(chan struct{}) drv.Activate(ctx, &models.TrustCleanCA{ CA: ca, - Config: c.Config, + Config: cfg, ConfirmCh: confirmc, }) - if !c.Config.NonInteractive { + if !cfg.NonInteractive { select { case <-confirmc: case <-ctx.Done(): diff --git a/trust/trust.go b/trust/trust.go index bca61a0..f970648 100644 --- a/trust/trust.go +++ b/trust/trust.go @@ -21,8 +21,6 @@ import ( ) type Command struct { - Config *cli.Config - Anc *api.Session OrgSlug, RealmSlug string } @@ -34,6 +32,8 @@ func (c Command) UI() cli.UI { } func (c *Command) runTUI(ctx context.Context, drv *ui.Driver) error { + cfg := cli.ConfigFromContext(ctx) + anc := c.Anc if anc == nil { var err error @@ -49,14 +49,14 @@ func (c *Command) runTUI(ctx context.Context, drv *ui.Driver) error { } var err error - if orgSlug, realmSlug, err = fetchOrgAndRealm(ctx, c.Config, anc); err != nil { + if orgSlug, realmSlug, err = fetchOrgAndRealm(ctx, anc); err != nil { return err } } confirmc := make(chan struct{}) drv.Activate(ctx, &models.TrustPreflight{ - Config: c.Config, + Config: cfg, ConfirmCh: confirmc, }) @@ -65,7 +65,7 @@ func (c *Command) runTUI(ctx context.Context, drv *ui.Driver) error { return err } - stores, sudoMgr, err := loadStores(c.Config) + stores, sudoMgr, err := loadStores(cfg) if err != nil { return err } @@ -97,7 +97,7 @@ func (c *Command) runTUI(ctx context.Context, drv *ui.Driver) error { return nil } - if !c.Config.NonInteractive { + if !cfg.NonInteractive { select { case <-confirmc: case <-ctx.Done(): @@ -140,12 +140,14 @@ func (c *Command) runTUI(ctx context.Context, drv *ui.Driver) error { } func (c *Command) apiClient(ctx context.Context, drv *ui.Driver) (*api.Session, error) { - anc, err := api.NewClient(c.Config) + cfg := cli.ConfigFromContext(ctx) + + anc, err := api.NewClient(cfg) if errors.Is(err, api.ErrSignedOut) { if err := c.runSignIn(ctx, drv); err != nil { return nil, err } - if anc, err = api.NewClient(c.Config); err != nil { + if anc, err = api.NewClient(cfg); err != nil { return nil, err } } else if err != nil { @@ -161,7 +163,9 @@ func (c *Command) runSignIn(ctx context.Context, drv *ui.Driver) error { return cmdSignIn.RunTUI(ctx, drv) } -func fetchOrgAndRealm(ctx context.Context, cfg *cli.Config, anc *api.Session) (string, string, error) { +func fetchOrgAndRealm(ctx context.Context, anc *api.Session) (string, string, error) { + cfg := cli.ConfigFromContext(ctx) + org, realm := cfg.Trust.Org, cfg.Trust.Realm if (org == "") != (realm == "") { return "", "", errors.New("--org and --realm flags must both be present or absent") @@ -181,7 +185,9 @@ func fetchOrgAndRealm(ctx context.Context, cfg *cli.Config, anc *api.Session) (s return org, realm, nil } -func PerformAudit(ctx context.Context, cfg *cli.Config, anc *api.Session, org string, realm string) (*truststore.AuditInfo, error) { +func PerformAudit(ctx context.Context, anc *api.Session, org string, realm string) (*truststore.AuditInfo, error) { + cfg := cli.ConfigFromContext(ctx) + cas, err := fetchExpectedCAs(ctx, anc, org, realm) if err != nil { return nil, err @@ -244,13 +250,16 @@ func loadStores(cfg *cli.Config) ([]truststore.Store, *SudoManager, error) { } rootFS := truststore.RootFS() + noSudo := cfg.Trust.NoSudo sysFS := &SudoManager{ CmdFS: rootFS, - NoSudo: cfg.Trust.NoSudo, + NoSudo: noSudo, } + trustStores := cfg.Trust.Stores + var stores []truststore.Store - for _, storeName := range cfg.Trust.Stores { + for _, storeName := range trustStores { switch storeName { case "system": systemStore := &truststore.Platform{ diff --git a/trust/trust_test.go b/trust/trust_test.go index 02ee1ec..f50f2ed 100644 --- a/trust/trust_test.go +++ b/trust/trust_test.go @@ -38,11 +38,11 @@ func TestTrust(t *testing.T) { cfg.Trust.MockMode = true cfg.Trust.NoSudo = true cfg.Trust.Stores = []string{"mock"} - var err error if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { t.Fatal(err) } + ctx = cli.ContextWithConfig(ctx, cfg) t.Run("basics", func(t *testing.T) { if !srv.IsProxy() { @@ -52,9 +52,7 @@ func TestTrust(t *testing.T) { ctx, cancel := context.WithCancel(ctx) defer cancel() - cmd := Command{ - Config: cfg, - } + cmd := Command{} uitest.TestTUIOutput(ctx, t, cmd.UI()) }) diff --git a/truststore/nss.go b/truststore/nss.go index e8c5aea..63d01d4 100644 --- a/truststore/nss.go +++ b/truststore/nss.go @@ -107,7 +107,7 @@ func (s *NSS) Check() (bool, error) { } } if s.certutilPath == "" { - return true, Error{ + return false, Error{ Op: OpCheck, Warning: NSSError{ diff --git a/ui/uitest/uitest.go b/ui/uitest/uitest.go index 3fce888..f0b7355 100644 --- a/ui/uitest/uitest.go +++ b/ui/uitest/uitest.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/exp/teatest" "github.com/muesli/termenv" + "github.com/stretchr/testify/require" "github.com/anchordotdev/cli" "github.com/anchordotdev/cli/ui" @@ -47,7 +48,27 @@ func (p program) Run() (tea.Model, error) { panic("TODO") } +func TestTUIError(ctx context.Context, t *testing.T, tui cli.UI, msgAndArgs ...interface{}) { + _, errc := testTUI(ctx, t, tui) + err := <-errc + require.Error(t, err, msgAndArgs...) +} + func TestTUIOutput(ctx context.Context, t *testing.T, tui cli.UI) { + drv, errc := testTUI(ctx, t, tui) + + out, err := io.ReadAll(drv.Out) + if err != nil { + t.Fatal(err) + } + if err := <-errc; err != nil { + t.Fatal(err) + } + + teatest.RequireEqualOutput(t, out) +} + +func testTUI(ctx context.Context, t *testing.T, tui cli.UI) (ui.Driver, chan error) { drv := ui.NewDriverTest(ctx) tm := teatest.NewTestModel(t, drv, teatest.WithInitialTermSize(128, 64)) @@ -62,13 +83,6 @@ func TestTUIOutput(ctx context.Context, t *testing.T, tui cli.UI) { }() tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3)) - out, err := io.ReadAll(drv.Out) - if err != nil { - t.Fatal(err) - } - if err := <-errc; err != nil { - t.Fatal(err) - } - teatest.RequireEqualOutput(t, out) + return *drv, errc }