├── .github └── workflows │ ├── release-notify.yml │ └── release.yml ├── LICENSE ├── README.md ├── anchorcli └── category.go ├── api ├── acme.go ├── api.go ├── apitest │ ├── apitest.go │ ├── apitest_unix.go │ └── apitest_windows.go ├── openapi.gen.go ├── openapi.go ├── tools.go └── util.go ├── auth ├── auth.go ├── auth_test.go ├── client.go ├── models │ ├── client.go │ ├── signin.go │ ├── signout.go │ └── whoami.go ├── signin.go ├── signin_test.go ├── signout.go ├── signout_test.go ├── testdata │ ├── TestCmdAuth │ │ ├── --help.golden │ │ └── auth.golden │ ├── TestCmdAuthSignin │ │ └── --help.golden │ ├── TestCmdAuthSignout │ │ └── --help.golden │ ├── TestCmdAuthWhoAmI │ │ └── --help.golden │ ├── TestSignout │ │ └── signed-out.golden │ └── TestWhoAmI │ │ ├── signed-in-but-out-of-date-cli-release.golden │ │ ├── signed-in.golden │ │ └── signed-out.golden ├── whoami.go └── whoami_test.go ├── cert ├── models │ └── provision.go └── provision.go ├── cli.go ├── cli_test.go ├── client.go ├── clipboard └── clipboard.go ├── clitest └── clitest.go ├── cmd.go ├── cmd └── anchor │ ├── .goreleaser.yaml │ ├── main.go │ └── windows │ ├── als2.ico │ └── app.wxs ├── cmdtest └── cmdtest.go ├── component ├── component_test.go ├── config_via_test.go ├── fetcher.go ├── models │ ├── config_via.go │ └── selectors.go ├── selector.go ├── selector_test.go └── testdata │ ├── TestConfigVia │ ├── default.golden │ ├── env.golden │ ├── flag.golden │ └── toml.golden │ └── TestSelector │ ├── orgs_double.golden │ ├── orgs_empty.golden │ ├── orgs_solo.golden │ └── orgs_solo_creatable.golden ├── config.go ├── config_test.go ├── detection ├── detection.go ├── detection_test.go ├── filesystem.go ├── filesystem_test.go ├── framework.go ├── framework_test.go ├── languages.go ├── nextjs.go ├── package_managers.go └── package_managers │ └── npm.go ├── diagnostic ├── server.go └── server_test.go ├── ext509 ├── anchor.go └── oid │ └── oid.go ├── go.mod ├── go.sum ├── internal └── must │ └── x509.go ├── keyring ├── keyring.go └── keyring_test.go ├── lcl ├── audit.go ├── audit_test.go ├── bootstrap.go ├── bootstrap_test.go ├── clean.go ├── clean_test.go ├── lcl.go ├── lcl_test.go ├── mkcert.go ├── mkcert_test.go ├── models │ ├── audit.go │ ├── bootstrap.go │ ├── clean.go │ ├── lcl.go │ └── setup.go ├── setup.go ├── setup_test.go ├── testdata │ ├── TestAudit │ │ ├── basics.golden │ │ └── missing-localhost-ca.golden │ ├── TestBootstrap │ │ └── basics.golden │ ├── TestClean │ │ └── basics.golden │ ├── TestCmdBootstrap │ │ └── --help.golden │ ├── TestCmdLcl │ │ └── --help.golden │ ├── TestCmdLclAudit │ │ └── --help.golden │ ├── TestCmdLclClean │ │ └── --help.golden │ ├── TestCmdLclMkCert │ │ └── --help.golden │ ├── TestCmdLclSetup │ │ └── --help.golden │ ├── TestCmdLclTrust │ │ └── --help.golden │ ├── TestLcl │ │ ├── basics.golden │ │ ├── non-personal-cas-missing-unix.golden │ │ ├── non-personal-cas-missing-windows.golden │ │ └── skip-diagnostic.golden │ ├── TestSetup │ │ ├── automated-basics.golden │ │ ├── create-org-existing-service-basics.golden │ │ ├── create-service-automated-basics.golden │ │ ├── create-service-manual-basics.golden │ │ ├── create-service-with-custom-domain.golden │ │ ├── create-service-with-parameterized-name.golden │ │ ├── existing-service-basics.golden │ │ └── manual-basics.golden │ └── TestTrust │ │ ├── basics-unix.golden │ │ └── basics-windows.golden ├── trust.go └── trust_test.go ├── models └── cli.go ├── org ├── create.go ├── create_test.go ├── models │ └── create.go ├── org.go ├── org_test.go └── testdata │ ├── TestCmdOrg │ └── --help.golden │ └── TestCreateOrg │ └── basics.golden ├── package.json ├── root.go ├── root_test.go ├── service ├── env.go ├── env_test.go ├── models │ ├── env.go │ └── verify.go ├── service.go ├── service_test.go ├── testdata │ ├── TestCmdServiceEnv │ │ └── --help.golden │ ├── TestCmdServiceVerify │ │ └── --help.golden │ ├── TestServiceEnv │ │ ├── basics_display.golden │ │ ├── basics_dotenv.golden │ │ ├── basics_export.golden │ │ └── basics_unattached_service_display.golden │ ├── TestVerify.golden │ └── TestVerifyErrors │ │ ├── dns-failure.golden │ │ ├── tcp-failure-connection-failure.golden │ │ ├── tcp-failure-connection-timeout.golden │ │ ├── tls-failure-non-tls-server.golden │ │ └── tls-failure-unknown-cert.golden ├── verify.go └── verify_test.go ├── stacktrace ├── stacktrace.go └── stacktrace_test.go ├── testdata ├── TestCmdRoot │ ├── --help.golden │ └── root.golden ├── TestError │ ├── golden-unix.golden │ └── golden-windows.golden └── TestPanic │ ├── golden-unix.golden │ └── golden-windows.golden ├── testflags └── testflags.go ├── toml └── toml.go ├── trust ├── audit.go ├── audit_test.go ├── clean.go ├── clean_test.go ├── models │ ├── audit.go │ ├── clean.go │ └── trust.go ├── runtime_detector.go ├── runtime_detector_test.go ├── testdata │ ├── TestAudit │ │ └── expected,_missing,_and_extra_CAs.golden │ ├── TestClean │ │ └── basics.golden │ ├── TestCmdTrust │ │ └── --help.golden │ ├── TestCmdTrustAudit │ │ └── --help.golden │ ├── TestCmdTrustClean │ │ └── --help.golden │ └── TestTrust │ │ ├── basics-unix.golden │ │ ├── basics-windows.golden │ │ ├── noop.golden │ │ └── wsl-vm.golden ├── trust.go └── trust_test.go ├── truststore ├── audit.go ├── audit_test.go ├── brew.go ├── brew_test.go ├── doc.go ├── errors.go ├── fs.go ├── java.go ├── mock.go ├── mock_test.go ├── models │ └── audit.go ├── nss.go ├── nss_test.go ├── platform.go ├── platform_darwin.go ├── platform_linux.go ├── platform_test.go ├── platform_windows.go ├── truststore.go └── truststore_test.go ├── ui ├── driver.go ├── models.go ├── styles.go └── uitest │ └── uitest.go └── version ├── command.go ├── command_test.go ├── models ├── upgrade.go └── version.go ├── testdata ├── TestCmdVersion │ └── --help.golden ├── TestCommand │ ├── golden-darwin_arm64.golden │ ├── golden-linux_amd64.golden │ └── golden-windows_amd64.golden └── TestUpgrade │ ├── upgrade-available-unix.golden │ ├── upgrade-available-windows.golden │ └── upgrade-unavailable.golden ├── upgrade.go ├── upgrade_test.go └── version.go /.github/workflows/release-notify.yml: -------------------------------------------------------------------------------- 1 | name: Release Notify 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | discord: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: sarisia/actions-status-discord@v1 12 | with: 13 | webhook: ${{ secrets.DISCORD_WEBHOOK }} 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anchor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub Anchor Banner](https://anchor.dev/images/AnchorGradient_featured2.jpg) 2 | 3 | # Introduction 4 | 5 | `anchor` provides a command line interface for the [Anchor.dev](https://anchor.dev) certificate management platform. 6 | 7 | - Need a secure browser context for development? 8 | - There's a better option than localhost. 9 | - Effortlessly get HTTPS in your local development environment. Get set up in two easy commmands. 10 | 11 | For more information visit [Lcl.host](https://lcl.host) or the [Anchor Docs](https://anchor.dev/docs) 12 | 13 | ## Installation 14 | 15 | ### macOS 16 | 17 | Available via [Homebrew][] or as a downloadable binary from the [releases page][]. 18 | 19 | #### Homebrew 20 | 21 | Install: 22 | ``` 23 | brew install anchordotdev/tap/anchor 24 | ``` 25 | 26 | Upgrade: 27 | ``` 28 | brew update && brew upgrade anchordotdev/tap/anchor 29 | ``` 30 | 31 | ### Linux & BSD 32 | 33 | Available via [Homebrew][] or as a downloadable binary from the [releases page][]. 34 | 35 | #### Homebrew 36 | 37 | Install: 38 | ``` 39 | brew install anchordotdev/tap/anchor 40 | ``` 41 | 42 | Upgrade: 43 | ``` 44 | brew upgrade anchordotdev/tap/anchor 45 | ``` 46 | 47 | ### Windows 48 | 49 | Available via [Winget][] or as a downloadable binary from the [releases page][]. 50 | 51 | ### Winget 52 | 53 | Install: 54 | ``` 55 | winget install anchor 56 | ``` 57 | 58 | Upgrade: 59 | ``` 60 | winget upgrade anchor 61 | ``` 62 | 63 | ### Install from source 64 | 65 | Install: 66 | ``` 67 | go install github.com/anchordotdev/cli/cmd/anchor@latest 68 | ``` 69 | 70 | [Winget]: https://learn.microsoft.com/en-us/windows/package-manager/winget/ 71 | [Homebrew]: https://brew.sh 72 | [releases page]: https://github.com/anchordotdev/cli/releases/latest 73 | 74 | ## Setup 75 | 76 | - After installing, run `anchor lcl` and follow the instructions. 77 | -------------------------------------------------------------------------------- /api/acme.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/base64" 6 | "time" 7 | 8 | "github.com/anchordotdev/cli" 9 | "golang.org/x/crypto/acme" 10 | "golang.org/x/crypto/acme/autocert" 11 | ) 12 | 13 | func ProvisionCert(eab *Eab, domains []string, acmeURL string) (*tls.Certificate, error) { 14 | hmacKey, err := base64.URLEncoding.DecodeString(eab.HmacKey) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | mgr := &autocert.Manager{ 20 | Prompt: autocert.AcceptTOS, 21 | HostPolicy: autocert.HostWhitelist(domains...), 22 | Client: &acme.Client{ 23 | DirectoryURL: acmeURL, 24 | UserAgent: cli.UserAgent(), 25 | }, 26 | ExternalAccountBinding: &acme.ExternalAccountBinding{ 27 | KID: eab.Kid, 28 | Key: hmacKey, 29 | }, 30 | RenewBefore: 24 * time.Hour, 31 | } 32 | 33 | // TODO: switch to using ACME package here, so that extra domains can be sent through for SAN extension 34 | clientHello := &tls.ClientHelloInfo{ 35 | ServerName: domains[0], 36 | CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, 37 | } 38 | 39 | return mgr.GetCertificate(clientHello) 40 | } 41 | -------------------------------------------------------------------------------- /api/apitest/apitest_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package apitest 4 | 5 | import ( 6 | "errors" 7 | "os/exec" 8 | "syscall" 9 | ) 10 | 11 | func isConnRefused(err error) bool { 12 | return errors.Is(err, syscall.ECONNREFUSED) 13 | } 14 | 15 | func setpgid(cmd *exec.Cmd) { 16 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 17 | // cmd.SysProcAttr = &syscall.SysProcAttr{} 18 | } 19 | 20 | func terminateProcess(cmd *exec.Cmd) error { 21 | err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 22 | if err != nil && !errors.Is(err, syscall.ESRCH) { 23 | return err 24 | } 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /api/apitest/apitest_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package apitest 4 | 5 | import ( 6 | "errors" 7 | "os" 8 | "os/exec" 9 | "syscall" 10 | 11 | "golang.org/x/sys/windows" 12 | ) 13 | 14 | // based on: https://github.com/go-cmd/cmd/blob/500562c204744af1802ae24316a7e0bf88dcc545/cmd_windows.go 15 | 16 | func isConnRefused(err error) bool { 17 | return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, windows.WSAECONNREFUSED) 18 | } 19 | 20 | func setpgid(cmd *exec.Cmd) { 21 | cmd.SysProcAttr = &syscall.SysProcAttr{} 22 | } 23 | 24 | func terminateProcess(cmd *exec.Cmd) error { 25 | p, err := os.FindProcess(cmd.Process.Pid) 26 | if err != nil { 27 | return err 28 | } 29 | return p.Kill() 30 | } 31 | -------------------------------------------------------------------------------- /api/openapi.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "slices" 4 | 5 | func (o Organization) Key() string { return o.Apid } 6 | func (o Organization) String() string { return o.Name } 7 | func (o Organization) Plural() string { return "organizations" } 8 | func (o Organization) Singular() string { return "organization" } 9 | 10 | func (r Realm) Key() string { return r.Apid } 11 | func (r Realm) String() string { return r.Name } 12 | func (r Realm) Plural() string { return "realms" } 13 | func (r Realm) Singular() string { return "realm" } 14 | 15 | func (s Service) Key() string { return s.Slug } 16 | func (s Service) String() string { return s.Name } 17 | func (s Service) Plural() string { return "services" } 18 | func (s Service) Singular() string { return "service" } 19 | 20 | func NonDiagnosticServices(s []Service) []Service { 21 | return slices.DeleteFunc(s, func(svc Service) bool { 22 | return svc.ServerType == ServiceServerTypeDiagnostic 23 | }) 24 | } 25 | 26 | var _ Filter[Service] = NonDiagnosticServices 27 | -------------------------------------------------------------------------------- /api/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package api 5 | 6 | import ( 7 | _ "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen" 8 | ) 9 | -------------------------------------------------------------------------------- /api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | func PointerTo[T any](v T) *T { 4 | return &v 5 | } 6 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/anchordotdev/cli" 7 | ) 8 | 9 | var CmdAuth = cli.NewCmd[cli.ShowHelp](cli.CmdRoot, "auth", func(cmd *cobra.Command) {}) 10 | -------------------------------------------------------------------------------- /auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/anchordotdev/cli/api/apitest" 9 | "github.com/anchordotdev/cli/cmdtest" 10 | ) 11 | 12 | var srv = &apitest.Server{ 13 | Host: "api.anchor.lcl.host", 14 | RootDir: "../..", 15 | } 16 | 17 | func TestMain(m *testing.M) { 18 | if err := srv.Start(context.Background()); err != nil { 19 | panic(err) 20 | } 21 | defer os.Exit(m.Run()) 22 | 23 | srv.Close() 24 | } 25 | 26 | func TestCmdAuth(t *testing.T) { 27 | t.Run("auth", func(t *testing.T) { 28 | cmdtest.TestHelp(t, CmdAuth, "auth") 29 | }) 30 | 31 | t.Run("--help", func(t *testing.T) { 32 | cmdtest.TestHelp(t, CmdAuth, "auth", "--help") 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /auth/client.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/api" 9 | "github.com/anchordotdev/cli/auth/models" 10 | "github.com/anchordotdev/cli/ui" 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | type Client struct { 15 | Anc *api.Session 16 | Hint tea.Model 17 | Source string 18 | } 19 | 20 | func (c Client) Perform(ctx context.Context, drv *ui.Driver) (*api.Session, error) { 21 | cfg := cli.ConfigFromContext(ctx) 22 | 23 | var newClientErr, userInfoErr error 24 | 25 | drv.Activate(ctx, &models.Client{}) 26 | 27 | if c.Anc == nil { 28 | c.Anc, newClientErr = api.NewClient(ctx, cfg) 29 | if newClientErr != nil && !errors.Is(newClientErr, api.ErrSignedOut) { 30 | return nil, newClientErr 31 | } 32 | } 33 | 34 | drv.Send(models.ClientProbed(true)) 35 | 36 | if newClientErr == nil { 37 | _, userInfoErr = c.Anc.UserInfo(ctx) 38 | if userInfoErr != nil && !errors.Is(userInfoErr, api.ErrSignedOut) { 39 | return nil, userInfoErr 40 | } 41 | } 42 | 43 | drv.Send(models.ClientTested(true)) 44 | 45 | if errors.Is(newClientErr, api.ErrSignedOut) || errors.Is(userInfoErr, api.ErrSignedOut) { 46 | if c.Hint == nil { 47 | c.Hint = models.SignInHint 48 | } 49 | cmd := &SignIn{ 50 | Hint: c.Hint, 51 | Source: c.Source, 52 | } 53 | ctx = cli.ContextWithConfig(ctx, cfg) 54 | err := cmd.RunTUI(ctx, drv) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | c.Anc, err = api.NewClient(ctx, cfg) 60 | if err != nil { 61 | return nil, err 62 | } 63 | } 64 | 65 | return c.Anc, nil 66 | } 67 | -------------------------------------------------------------------------------- /auth/models/client.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | type AuditAuthenticationWhoami string 13 | 14 | type ClientProbed bool 15 | type ClientTested bool 16 | 17 | type Client struct { 18 | spinner spinner.Model 19 | 20 | probed bool 21 | tested bool 22 | } 23 | 24 | func (m *Client) Init() tea.Cmd { 25 | m.spinner = ui.WaitingSpinner() 26 | 27 | return m.spinner.Tick 28 | } 29 | 30 | func (m *Client) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 31 | switch msg := msg.(type) { 32 | case ClientProbed: 33 | m.probed = bool(msg) 34 | return m, nil 35 | case ClientTested: 36 | m.tested = bool(msg) 37 | return m, nil 38 | default: 39 | var cmd tea.Cmd 40 | m.spinner, cmd = m.spinner.Update(msg) 41 | return m, cmd 42 | } 43 | } 44 | 45 | func (m *Client) View() string { 46 | var b strings.Builder 47 | 48 | if !m.probed { 49 | fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Checking authentication: probing credentials locally…%s", m.spinner.View()))) 50 | return b.String() 51 | } 52 | 53 | if !m.tested { 54 | fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Checking authentication: testing credentials remotely…%s", m.spinner.View()))) 55 | return b.String() 56 | } 57 | 58 | return b.String() 59 | } 60 | -------------------------------------------------------------------------------- /auth/models/signin.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | var ( 13 | SignInHeader = ui.Section{ 14 | Name: "SignInHeader", 15 | Model: ui.MessageLines{ 16 | ui.Header(fmt.Sprintf("Signin to Anchor.dev %s", ui.Whisper("`anchor auth signin`"))), 17 | }, 18 | } 19 | 20 | SignInHint = ui.Section{ 21 | Name: "SignInHint", 22 | Model: ui.MessageLines{ 23 | ui.StepHint("Please sign up or sign in with your Anchor account."), 24 | }, 25 | } 26 | ) 27 | 28 | type SignInPrompt struct { 29 | ConfirmCh chan<- struct{} 30 | InClipboard bool 31 | UserCode string 32 | VerificationURL string 33 | } 34 | 35 | func (SignInPrompt) Init() tea.Cmd { return nil } 36 | 37 | func (m *SignInPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 38 | switch msg := msg.(type) { 39 | case tea.KeyMsg: 40 | switch msg.Type { 41 | case tea.KeyEnter: 42 | if m.ConfirmCh != nil { 43 | close(m.ConfirmCh) 44 | m.ConfirmCh = nil 45 | } 46 | } 47 | } 48 | 49 | return m, nil 50 | } 51 | 52 | func (m *SignInPrompt) View() string { 53 | var b strings.Builder 54 | 55 | if m.ConfirmCh != nil { 56 | if m.InClipboard { 57 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Copied your user code %s to your clipboard.", ui.Emphasize(m.UserCode)))) 58 | } else { 59 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Copy your user code: %s", ui.Announce(m.UserCode)))) 60 | } 61 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s to open %s in your browser.", ui.Action("Press Enter"), ui.URL(m.VerificationURL)))) 62 | return b.String() 63 | } 64 | 65 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Copied your user code %s to your clipboard.", ui.Emphasize(m.UserCode)))) 66 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Opened %s in your browser.", ui.URL(m.VerificationURL)))) 67 | 68 | return b.String() 69 | } 70 | 71 | type SignInChecker struct { 72 | whoami string 73 | 74 | spinner spinner.Model 75 | } 76 | 77 | func (m *SignInChecker) Init() tea.Cmd { 78 | m.spinner = ui.WaitingSpinner() 79 | 80 | return m.spinner.Tick 81 | } 82 | 83 | type UserSignInMsg string 84 | 85 | func (m *SignInChecker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 86 | switch msg := msg.(type) { 87 | case UserSignInMsg: 88 | m.whoami = string(msg) 89 | return m, nil 90 | } 91 | 92 | var cmd tea.Cmd 93 | m.spinner, cmd = m.spinner.Update(msg) 94 | return m, cmd 95 | } 96 | 97 | func (m *SignInChecker) View() string { 98 | var b strings.Builder 99 | if m.whoami == "" { 100 | fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Signing in… %s", m.spinner.View()))) 101 | } else { 102 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Signed in as %s.", ui.Emphasize(m.whoami)))) 103 | } 104 | return b.String() 105 | } 106 | 107 | type KeyringUnavailable struct { 108 | ShowGnomeKeyringHint bool 109 | } 110 | 111 | func (m *KeyringUnavailable) Init() tea.Cmd { return nil } 112 | 113 | func (m *KeyringUnavailable) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 114 | 115 | func (m *KeyringUnavailable) View() string { 116 | var b strings.Builder 117 | fmt.Fprintln(&b, ui.Warning("Unable to access keyring, credentials will not be stored.")) 118 | 119 | if m.ShowGnomeKeyringHint { 120 | fmt.Fprintln(&b, ui.StepHint("gnome-keyring is required for secure credential storage.")) 121 | fmt.Fprintln(&b, ui.StepHint("Please install with your host package manager")) 122 | } 123 | 124 | return b.String() 125 | } 126 | -------------------------------------------------------------------------------- /auth/models/signout.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchordotdev/cli/ui" 7 | ) 8 | 9 | var ( 10 | SignOutHeader = ui.Section{ 11 | Name: "SignOutHeader", 12 | Model: ui.MessageLines{ 13 | ui.Header(fmt.Sprintf("Signout from Anchor.dev %s", ui.Whisper("`anchor auth signout`"))), 14 | }, 15 | } 16 | 17 | SignOutSignedOut = ui.Section{ 18 | Name: "SignOutSignedOut", 19 | Model: ui.MessageLines{ 20 | ui.StepDone("Not signed in."), 21 | ui.StepHint("Run `anchor auth signin` to sign in."), 22 | }, 23 | } 24 | 25 | SignOutSuccess = ui.Section{ 26 | Name: "SignOutSuccess", 27 | Model: ui.MessageLines{ 28 | ui.StepDone("Signed out."), 29 | }, 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /auth/models/whoami.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | var WhoAmIHeader = ui.Section{ 13 | Name: "WhoAmIHeader", 14 | Model: ui.MessageLines{ 15 | ui.Header(fmt.Sprintf("Identify Current Anchor.dev Account %s", ui.Whisper("`anchor auth whoami`"))), 16 | }, 17 | } 18 | 19 | type WhoAmIChecker struct { 20 | signedout bool 21 | whoami string 22 | 23 | spinner spinner.Model 24 | } 25 | 26 | func (m *WhoAmIChecker) Init() tea.Cmd { 27 | m.spinner = ui.WaitingSpinner() 28 | 29 | return m.spinner.Tick 30 | } 31 | 32 | type UserWhoAmIMsg string 33 | type UserWhoAmISignedOutMsg bool 34 | 35 | func (m *WhoAmIChecker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 36 | switch msg := msg.(type) { 37 | case UserWhoAmIMsg: 38 | m.whoami = string(msg) 39 | return m, nil 40 | case UserWhoAmISignedOutMsg: 41 | m.signedout = bool(msg) 42 | return m, nil 43 | } 44 | 45 | var cmd tea.Cmd 46 | m.spinner, cmd = m.spinner.Update(msg) 47 | return m, cmd 48 | } 49 | 50 | func (m *WhoAmIChecker) View() string { 51 | var b strings.Builder 52 | 53 | if m.signedout { 54 | fmt.Fprintln(&b, ui.StepDone("Identified Anchor.dev account: not signed in.")) 55 | fmt.Fprintln(&b, ui.StepHint("Run `anchor auth signin` to sign in.")) 56 | return b.String() 57 | } 58 | 59 | if m.whoami == "" { 60 | fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Identifying Anchor.dev account… %s", m.spinner.View()))) 61 | return b.String() 62 | } 63 | 64 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Identified Anchor.dev account: %s", ui.Emphasize(m.whoami)))) 65 | return b.String() 66 | } 67 | -------------------------------------------------------------------------------- /auth/signin.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/atotto/clipboard" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/cli/browser" 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/anchordotdev/cli" 14 | "github.com/anchordotdev/cli/api" 15 | "github.com/anchordotdev/cli/auth/models" 16 | "github.com/anchordotdev/cli/keyring" 17 | climodels "github.com/anchordotdev/cli/models" 18 | "github.com/anchordotdev/cli/ui" 19 | ) 20 | 21 | var ( 22 | CmdAuthSignin = cli.NewCmd[SignIn](CmdAuth, "signin", func(cmd *cobra.Command) {}) 23 | 24 | ErrSigninFailed = errors.New("sign in failed") 25 | ) 26 | 27 | type SignIn struct { 28 | Source string 29 | 30 | Hint tea.Model 31 | } 32 | 33 | func (s SignIn) UI() cli.UI { 34 | return cli.UI{ 35 | RunTUI: s.RunTUI, 36 | } 37 | } 38 | 39 | func (s *SignIn) RunTUI(ctx context.Context, drv *ui.Driver) error { 40 | cfg := cli.ConfigFromContext(ctx) 41 | 42 | drv.Activate(ctx, models.SignInHeader) 43 | 44 | if s.Hint == nil { 45 | s.Hint = models.SignInHint 46 | } 47 | drv.Activate(ctx, s.Hint) 48 | 49 | anc, err := api.NewClient(ctx, cfg) 50 | if err != nil && !errors.Is(err, api.ErrSignedOut) { 51 | return err 52 | } 53 | 54 | codes, err := anc.GenerateUserFlowCodes(ctx, s.Source) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | // TODO: skipping TTY check since this is TUI mode, but is it needed? 60 | clipboardErr := clipboard.WriteAll(codes.UserCode) 61 | 62 | confirmc := make(chan struct{}) 63 | drv.Activate(ctx, &models.SignInPrompt{ 64 | ConfirmCh: confirmc, 65 | InClipboard: (clipboardErr == nil), 66 | UserCode: codes.UserCode, 67 | VerificationURL: codes.VerificationUri, 68 | }) 69 | 70 | if !cfg.NonInteractive { 71 | select { 72 | case <-confirmc: 73 | case <-ctx.Done(): 74 | return ctx.Err() 75 | } 76 | } 77 | 78 | if err := browser.OpenURL(codes.VerificationUri); err != nil { 79 | drv.Activate(ctx, &climodels.Browserless{Url: codes.VerificationUri}) 80 | } 81 | 82 | drv.Activate(ctx, new(models.SignInChecker)) 83 | 84 | var patToken string 85 | for patToken == "" { 86 | if patToken, err = anc.CreatePATToken(ctx, codes.DeviceCode); err != nil && err != api.ErrTransient { 87 | return err 88 | } 89 | 90 | if patToken == "" { 91 | time.Sleep(time.Duration(codes.Interval) * time.Second) 92 | } 93 | } 94 | cfg.API.Token = patToken 95 | 96 | anc, err = api.NewClient(ctx, cfg) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | userInfo, err := anc.UserInfo(ctx) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | kr := keyring.Keyring{Config: cfg} 107 | if err := kr.Set(keyring.APIToken, cfg.API.Token); err != nil { 108 | drv.Activate(ctx, &models.KeyringUnavailable{ 109 | ShowGnomeKeyringHint: errors.Is(err, api.ErrGnomeKeyringRequired), 110 | }) 111 | } 112 | 113 | drv.Send(models.UserSignInMsg(userInfo.Whoami)) 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /auth/signin_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/anchordotdev/cli/cmdtest" 7 | ) 8 | 9 | func TestCmdAuthSignin(t *testing.T) { 10 | t.Run("--help", func(t *testing.T) { 11 | cmdtest.TestHelp(t, CmdAuthSignin, "auth", "signin", "--help") 12 | }) 13 | } 14 | 15 | func TestSignIn(t *testing.T) { 16 | t.Run("cli-auth-success", func(t *testing.T) { 17 | t.Skip("cli auth test not yet implemented") 18 | }) 19 | 20 | t.Run("valid-config-token", func(t *testing.T) { 21 | t.Skip("cli auth test not yet implemented") 22 | }) 23 | 24 | t.Run("invalid-config-token", func(t *testing.T) { 25 | t.Skip("cli auth test not yet implemented") 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /auth/signout.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/auth/models" 9 | "github.com/anchordotdev/cli/keyring" 10 | "github.com/anchordotdev/cli/ui" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var CmdAuthSignout = cli.NewCmd[SignOut](CmdAuth, "signout", func(cmd *cobra.Command) {}) 15 | 16 | type SignOut struct{} 17 | 18 | func (s SignOut) UI() cli.UI { 19 | return cli.UI{ 20 | RunTUI: s.runTUI, 21 | } 22 | } 23 | 24 | func (s *SignOut) runTUI(ctx context.Context, drv *ui.Driver) error { 25 | cfg := cli.ConfigFromContext(ctx) 26 | 27 | drv.Activate(ctx, models.SignOutHeader) 28 | 29 | kr := keyring.Keyring{Config: cfg} 30 | err := kr.Delete(keyring.APIToken) 31 | 32 | if errors.Is(err, keyring.ErrNotFound) { 33 | drv.Activate(ctx, models.SignOutSignedOut) 34 | return nil 35 | } 36 | 37 | if err == nil { 38 | drv.Activate(ctx, models.SignOutSuccess) 39 | } 40 | 41 | return err 42 | } 43 | -------------------------------------------------------------------------------- /auth/signout_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/cmdtest" 9 | "github.com/anchordotdev/cli/ui/uitest" 10 | ) 11 | 12 | func TestCmdAuthSignout(t *testing.T) { 13 | t.Run("--help", func(t *testing.T) { 14 | cmdtest.TestHelp(t, CmdAuthSignout, "auth", "signout", "--help") 15 | }) 16 | } 17 | 18 | func TestSignout(t *testing.T) { 19 | ctx, cancel := context.WithCancel(context.Background()) 20 | defer cancel() 21 | 22 | cfg := new(cli.Config) 23 | cfg.Keyring.MockMode = true 24 | ctx = cli.ContextWithConfig(ctx, cfg) 25 | 26 | t.Run("signed-out", func(t *testing.T) { 27 | cmd := SignOut{} 28 | 29 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 30 | }) 31 | 32 | t.Run("signed-in", func(t *testing.T) { 33 | t.Skip("pending singleton keyring") 34 | // kr := keyring.Keyring{} 35 | // if err := kr.Set(keyring.APIToken, "secret"); err != nil { 36 | // t.Fatal(err) 37 | // } 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /auth/testdata/TestCmdAuth/--help.golden: -------------------------------------------------------------------------------- 1 | Manage Anchor.dev Authentication 2 | 3 | Usage: 4 | anchor auth [flags] 5 | anchor auth [command] 6 | 7 | Available Commands: 8 | signin Authenticate With Your Account 9 | signout Invalidate Local Anchor Session 10 | whoami Identify Current Anchor.dev Account 11 | 12 | Flags: 13 | -h, --help help for auth 14 | 15 | Global Flags: 16 | --api-token string Anchor API personal access token (PAT). 17 | --config string Service configuration file. (default "anchor.toml") 18 | --skip-config Skip loading configuration file. 19 | 20 | Use "anchor auth [command] --help" for more information about a command. 21 | -------------------------------------------------------------------------------- /auth/testdata/TestCmdAuth/auth.golden: -------------------------------------------------------------------------------- 1 | Manage Anchor.dev Authentication 2 | 3 | Usage: 4 | anchor auth [flags] 5 | anchor auth [command] 6 | 7 | Available Commands: 8 | signin Authenticate With Your Account 9 | signout Invalidate Local Anchor Session 10 | whoami Identify Current Anchor.dev Account 11 | 12 | Flags: 13 | -h, --help help for auth 14 | 15 | Global Flags: 16 | --api-token string Anchor API personal access token (PAT). 17 | --config string Service configuration file. (default "anchor.toml") 18 | --skip-config Skip loading configuration file. 19 | 20 | Use "anchor auth [command] --help" for more information about a command. 21 | -------------------------------------------------------------------------------- /auth/testdata/TestCmdAuthSignin/--help.golden: -------------------------------------------------------------------------------- 1 | Sign into your Anchor account for your local system user. 2 | 3 | Generate a new Personal Access Token (PAT) and store it in the system keychain 4 | for the local system user. 5 | 6 | Usage: 7 | anchor auth signin [flags] 8 | 9 | Flags: 10 | -h, --help help for signin 11 | 12 | Global Flags: 13 | --api-token string Anchor API personal access token (PAT). 14 | --config string Service configuration file. (default "anchor.toml") 15 | --skip-config Skip loading configuration file. 16 | -------------------------------------------------------------------------------- /auth/testdata/TestCmdAuthSignout/--help.golden: -------------------------------------------------------------------------------- 1 | Sign out of your Anchor account for your local system user. 2 | 3 | Remove your Personal Access Token (PAT) from the system keychain for your local 4 | system user. 5 | 6 | Usage: 7 | anchor auth signout [flags] 8 | 9 | Flags: 10 | -h, --help help for signout 11 | 12 | Global Flags: 13 | --api-token string Anchor API personal access token (PAT). 14 | --config string Service configuration file. (default "anchor.toml") 15 | --skip-config Skip loading configuration file. 16 | -------------------------------------------------------------------------------- /auth/testdata/TestCmdAuthWhoAmI/--help.golden: -------------------------------------------------------------------------------- 1 | Print the details of the Anchor account for your local system user. 2 | 3 | Usage: 4 | anchor auth whoami [flags] 5 | 6 | Flags: 7 | -h, --help help for whoami 8 | 9 | Global Flags: 10 | --api-token string Anchor API personal access token (PAT). 11 | --config string Service configuration file. (default "anchor.toml") 12 | --skip-config Skip loading configuration file. 13 | -------------------------------------------------------------------------------- /auth/testdata/TestSignout/signed-out.golden: -------------------------------------------------------------------------------- 1 | ─── SignOutHeader ────────────────────────────────────────────────────────────── 2 | 3 | # Signout from Anchor.dev `anchor auth signout` 4 | ─── SignOutSignedOut ─────────────────────────────────────────────────────────── 5 | 6 | # Signout from Anchor.dev `anchor auth signout` 7 | - Not signed in. 8 | | Run `anchor auth signin` to sign in. 9 | -------------------------------------------------------------------------------- /auth/testdata/TestWhoAmI/signed-in-but-out-of-date-cli-release.golden: -------------------------------------------------------------------------------- 1 | ─── WhoAmIHeader ─────────────────────────────────────────────────────────────── 2 | 3 | # Identify Current Anchor.dev Account `anchor auth whoami` 4 | ─── WhoAmIChecker ────────────────────────────────────────────────────────────── 5 | 6 | # Identify Current Anchor.dev Account `anchor auth whoami` 7 | * Identifying Anchor.dev account… ⠋ 8 | ─── MinimumVersionCheck ──────────────────────────────────────────────────────── 9 | 10 | # Identify Current Anchor.dev Account `anchor auth whoami` 11 | * Identifying Anchor.dev account… ⠋ 12 | 13 | # Error! This version of the Anchor CLI is out-of-date, please update. 14 | -------------------------------------------------------------------------------- /auth/testdata/TestWhoAmI/signed-in.golden: -------------------------------------------------------------------------------- 1 | ─── WhoAmIHeader ─────────────────────────────────────────────────────────────── 2 | 3 | # Identify Current Anchor.dev Account `anchor auth whoami` 4 | ─── WhoAmIChecker ────────────────────────────────────────────────────────────── 5 | 6 | # Identify Current Anchor.dev Account `anchor auth whoami` 7 | * Identifying Anchor.dev account… ⠋ 8 | ─── WhoAmIChecker ────────────────────────────────────────────────────────────── 9 | 10 | # Identify Current Anchor.dev Account `anchor auth whoami` 11 | - Identified Anchor.dev account: anky@anchor.dev 12 | -------------------------------------------------------------------------------- /auth/testdata/TestWhoAmI/signed-out.golden: -------------------------------------------------------------------------------- 1 | ─── WhoAmIHeader ─────────────────────────────────────────────────────────────── 2 | 3 | # Identify Current Anchor.dev Account `anchor auth whoami` 4 | ─── WhoAmIChecker ────────────────────────────────────────────────────────────── 5 | 6 | # Identify Current Anchor.dev Account `anchor auth whoami` 7 | * Identifying Anchor.dev account… ⠋ 8 | ─── WhoAmIChecker ────────────────────────────────────────────────────────────── 9 | 10 | # Identify Current Anchor.dev Account `anchor auth whoami` 11 | - Identified Anchor.dev account: not signed in. 12 | | Run `anchor auth signin` to sign in. 13 | -------------------------------------------------------------------------------- /auth/whoami.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/anchordotdev/cli" 12 | "github.com/anchordotdev/cli/api" 13 | "github.com/anchordotdev/cli/auth/models" 14 | "github.com/anchordotdev/cli/ui" 15 | ) 16 | 17 | var CmdAuthWhoami = cli.NewCmd[WhoAmI](CmdAuth, "whoami", func(cmd *cobra.Command) {}) 18 | 19 | type WhoAmI struct{} 20 | 21 | func (c WhoAmI) UI() cli.UI { 22 | return cli.UI{ 23 | RunTUI: c.runTUI, 24 | } 25 | } 26 | 27 | func (c *WhoAmI) runTUI(ctx context.Context, drv *ui.Driver) error { 28 | cfg := cli.ConfigFromContext(ctx) 29 | 30 | drv.Activate(ctx, models.WhoAmIHeader) 31 | drv.Activate(ctx, &models.WhoAmIChecker{}) 32 | 33 | anc, err := api.NewClient(ctx, cfg) 34 | if errors.Is(err, api.ErrSignedOut) { 35 | drv.Send(models.UserWhoAmISignedOutMsg(true)) 36 | return nil 37 | } 38 | if err != nil { 39 | return err 40 | } 41 | 42 | res, err := anc.Get("") 43 | if err != nil { 44 | return err 45 | } 46 | if res.StatusCode != http.StatusOK { 47 | return errors.New("unexpected response") 48 | } 49 | 50 | var userInfo *api.Root 51 | if err := json.NewDecoder(res.Body).Decode(&userInfo); err != nil { 52 | return err 53 | } 54 | 55 | drv.Send(models.UserWhoAmIMsg(userInfo.Whoami)) 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /auth/whoami_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/cmdtest" 9 | "github.com/anchordotdev/cli/ui/uitest" 10 | ) 11 | 12 | func TestCmdAuthWhoAmI(t *testing.T) { 13 | t.Run("--help", func(t *testing.T) { 14 | cmdtest.TestHelp(t, CmdAuthWhoami, "auth", "whoami", "--help") 15 | }) 16 | 17 | } 18 | 19 | func TestWhoAmI(t *testing.T) { 20 | ctx, cancel := context.WithCancel(context.Background()) 21 | defer cancel() 22 | 23 | cfg := new(cli.Config) 24 | cfg.API.URL = srv.URL 25 | cfg.Keyring.MockMode = true 26 | ctx = cli.ContextWithConfig(ctx, cfg) 27 | 28 | t.Run("signed-out", func(t *testing.T) { 29 | cmd := WhoAmI{} 30 | 31 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 32 | }) 33 | 34 | t.Run("signed-in", func(t *testing.T) { 35 | apiToken, err := srv.GeneratePAT("anky@anchor.dev") 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | cfg.API.Token = apiToken 40 | 41 | cmd := WhoAmI{} 42 | 43 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 44 | }) 45 | 46 | t.Run("signed-in-but-out-of-date-cli-release", func(t *testing.T) { 47 | apiToken, err := srv.GeneratePAT("anky@anchor.dev") 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | cfg.API.Token = apiToken 52 | 53 | defer func(prev string) { cli.Version.Version = prev }(cli.Version.Version) 54 | cli.Version.Version = "0.0.0" 55 | 56 | cmd := WhoAmI{} 57 | 58 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /cert/models/provision.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | type Provision struct { 13 | Domains []string 14 | OrgAPID string 15 | RealmAPID string 16 | ServiceAPID string 17 | 18 | certFile, chainFile, keyFile string 19 | 20 | spinner spinner.Model 21 | } 22 | 23 | func (m *Provision) Init() tea.Cmd { 24 | m.spinner = ui.WaitingSpinner() 25 | 26 | return m.spinner.Tick 27 | } 28 | 29 | type ProvisionedFiles [3]string 30 | 31 | func (m *Provision) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 32 | switch msg := msg.(type) { 33 | case ProvisionedFiles: 34 | m.certFile = msg[0] 35 | m.chainFile = msg[1] 36 | m.keyFile = msg[2] 37 | 38 | return m, nil 39 | default: 40 | var cmd tea.Cmd 41 | m.spinner, cmd = m.spinner.Update(msg) 42 | return m, cmd 43 | } 44 | } 45 | 46 | func (m *Provision) View() string { 47 | var b strings.Builder 48 | fmt.Fprintln(&b, ui.Header(fmt.Sprintf("Provision Certificate %s", ui.Whisper("`anchor lcl mkcert`")))) 49 | 50 | if m.certFile == "" { 51 | fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Provisioning certificate for [%s]… %s", 52 | ui.Domains(m.Domains), m.spinner.View()))) 53 | 54 | return b.String() 55 | } 56 | 57 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Provisioned certificate for [%s].", ui.Domains(m.Domains)))) 58 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Wrote certificate to %s", ui.Emphasize(m.certFile)))) 59 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Wrote chain to %s", ui.Emphasize(m.chainFile)))) 60 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Wrote key to %s", ui.Emphasize(m.keyFile)))) 61 | 62 | fmt.Fprintln(&b, ui.Header("Next Steps")) 63 | fmt.Fprintln(&b, ui.StepNext("To use these certificates please reference your language and/or framework docs.")) 64 | fmt.Fprintln(&b, ui.StepNext( 65 | fmt.Sprintf("When these expire, run `anchor lcl mkcert --domains %s --org %s --realm %s --service %s` to generate new ones.", 66 | strings.Join(m.Domains, ","), 67 | m.OrgAPID, 68 | m.RealmAPID, 69 | m.ServiceAPID, 70 | ), 71 | )) 72 | 73 | return b.String() 74 | } 75 | -------------------------------------------------------------------------------- /cert/provision.go: -------------------------------------------------------------------------------- 1 | package cert 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "fmt" 9 | "os" 10 | "strconv" 11 | 12 | "github.com/anchordotdev/cli" 13 | "github.com/anchordotdev/cli/cert/models" 14 | "github.com/anchordotdev/cli/ui" 15 | ) 16 | 17 | type Provision struct { 18 | Cert *tls.Certificate 19 | 20 | Domains []string 21 | OrgAPID string 22 | RealmAPID string 23 | ServiceAPID string 24 | } 25 | 26 | func (p *Provision) RunTUI(ctx context.Context, drv *ui.Driver) error { 27 | return p.Perform(ctx, drv) 28 | } 29 | 30 | func (p *Provision) Perform(ctx context.Context, drv *ui.Driver) error { 31 | cfg := cli.ConfigFromContext(ctx) 32 | 33 | drv.Activate(ctx, &models.Provision{ 34 | Domains: p.Domains, 35 | OrgAPID: p.OrgAPID, 36 | RealmAPID: p.RealmAPID, 37 | ServiceAPID: p.ServiceAPID, 38 | }) 39 | 40 | // TODO: as a stand-alone command, it makes no sense to expect a cert as an 41 | // initialize value for this command, but this is only used by the 'lcl 42 | // diagnostic' stuff for the time being, which already provisions a cert. 43 | 44 | cert := p.Cert 45 | 46 | prefix := cert.Leaf.Subject.CommonName 47 | if num := len(p.Domains); num > 1 { 48 | prefix += "+" + strconv.Itoa(num-1) 49 | } 50 | 51 | certFile := fmt.Sprintf("./%s-cert.pem", prefix) 52 | chainFile := fmt.Sprintf("./%s-chain.pem", prefix) 53 | keyFile := fmt.Sprintf("./%s-key.pem", prefix) 54 | 55 | certBlock := &pem.Block{ 56 | Type: "CERTIFICATE", 57 | Bytes: cert.Certificate[0], 58 | } 59 | 60 | if !cfg.Trust.MockMode { 61 | if err := os.WriteFile(certFile, pem.EncodeToMemory(certBlock), 0644); err != nil { 62 | return err 63 | } 64 | } 65 | 66 | var chainData []byte 67 | for _, certDER := range cert.Certificate { 68 | chainBlock := &pem.Block{ 69 | Type: "CERTIFICATE", 70 | Bytes: certDER, 71 | } 72 | 73 | chainData = append(chainData, pem.EncodeToMemory(chainBlock)...) 74 | } 75 | 76 | if !cfg.Trust.MockMode { 77 | if err := os.WriteFile(chainFile, chainData, 0644); err != nil { 78 | return err 79 | } 80 | } 81 | 82 | keyDER, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | keyBlock := &pem.Block{ 88 | Type: "PRIVATE KEY", 89 | Headers: make(map[string]string), 90 | Bytes: keyDER, 91 | } 92 | 93 | if !cfg.Trust.MockMode { 94 | if err := os.WriteFile(keyFile, pem.EncodeToMemory(keyBlock), 0644); err != nil { 95 | return err 96 | } 97 | } 98 | 99 | drv.Send(models.ProvisionedFiles{certFile, chainFile, keyFile}) 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "net/http" 4 | 5 | func Client(cfg *Config) (http.Client, error) { 6 | panic("TODO") 7 | } 8 | -------------------------------------------------------------------------------- /clipboard/clipboard.go: -------------------------------------------------------------------------------- 1 | package clipboard 2 | 3 | import ( 4 | "sync/atomic" 5 | 6 | "github.com/atotto/clipboard" 7 | ) 8 | 9 | type Mock atomic.Pointer[string] 10 | 11 | func (m *Mock) ReadAll() (string, error) { 12 | if ptr := ((*atomic.Pointer[string])(m)).Load(); ptr != nil { 13 | return *ptr, nil 14 | } 15 | return "", nil 16 | } 17 | 18 | func (m *Mock) WriteAll(text string) error { 19 | ((*atomic.Pointer[string])(m)).Store(&text) 20 | return nil 21 | } 22 | 23 | var System = system{} 24 | 25 | type system struct{} 26 | 27 | func (system) ReadAll() (string, error) { return clipboard.ReadAll() } 28 | 29 | func (system) WriteAll(text string) error { return clipboard.WriteAll(text) } 30 | -------------------------------------------------------------------------------- /clitest/clitest.go: -------------------------------------------------------------------------------- 1 | package clitest 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "testing/fstest" 7 | ) 8 | 9 | type TestFS fstest.MapFS 10 | 11 | func (t TestFS) Open(name string) (fs.File, error) { return (fstest.MapFS)(t).Open(name) } 12 | func (t TestFS) ReadDir(name string) ([]fs.DirEntry, error) { return (fstest.MapFS)(t).ReadDir(name) } 13 | func (t TestFS) Stat(name string) (fs.FileInfo, error) { return (fstest.MapFS)(t).Stat(name) } 14 | 15 | func (t TestFS) WriteFile(name string, data []byte, perm os.FileMode) error { 16 | t[name] = &fstest.MapFile{ 17 | Data: data, 18 | Mode: perm, 19 | } 20 | 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/anchor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/anchordotdev/cli" 9 | _ "github.com/anchordotdev/cli/auth" 10 | _ "github.com/anchordotdev/cli/lcl" 11 | _ "github.com/anchordotdev/cli/org" 12 | _ "github.com/anchordotdev/cli/service" 13 | _ "github.com/anchordotdev/cli/trust" 14 | versionpkg "github.com/anchordotdev/cli/version" 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/muesli/termenv" 17 | ) 18 | 19 | var ( 20 | // Version info set by GoReleaser via ldflags 21 | 22 | version, commit, date string 23 | ) 24 | 25 | func init() { 26 | if version != "" { 27 | cli.Version.Commit = commit 28 | cli.Version.Date = date 29 | cli.Version.Version = version 30 | } 31 | cli.CmdRoot.PersistentPostRunE = versionpkg.ReleaseCheck 32 | } 33 | 34 | func main() { 35 | // prevent delay/hang by setting lipgloss background before starting bubbletea 36 | // see: https://github.com/charmbracelet/lipgloss/issues/73 37 | lipgloss.SetHasDarkBackground(termenv.HasDarkBackground()) 38 | 39 | // enable ANSI processing for Windows, see: https://github.com/muesli/termenv#platform-support 40 | restoreConsole, err := termenv.EnableVirtualTerminalProcessing(termenv.DefaultOutput()) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | defer func() { 45 | err := restoreConsole() 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | }() 50 | 51 | ctx, cancel := context.WithCancel(cli.CmdRoot.Context()) 52 | defer cancel() 53 | 54 | if err := cli.CmdRoot.ExecuteContext(ctx); err != nil { 55 | os.Exit(1) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /cmd/anchor/windows/als2.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchordotdev/cli/75045a99b28dc370a5f9afbdaf9ea364dcc300bc/cmd/anchor/windows/als2.ico -------------------------------------------------------------------------------- /cmd/anchor/windows/app.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /cmdtest/cmdtest.go: -------------------------------------------------------------------------------- 1 | package cmdtest 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "testing" 8 | 9 | "github.com/joeshaw/envdecode" 10 | "github.com/spf13/cobra" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/anchordotdev/cli" 14 | "github.com/anchordotdev/cli/clitest" 15 | "github.com/anchordotdev/cli/ui/uitest" 16 | ) 17 | 18 | func Config(ctx context.Context) *cli.Config { 19 | cfg := new(cli.Config) 20 | cfg.Test.SystemFS = clitest.TestFS{} 21 | if err := cfg.Load(ctx); err != nil { 22 | panic(err) 23 | } 24 | return cfg 25 | } 26 | 27 | func TestCfg(t *testing.T, cmd *cobra.Command, args ...string) *cli.Config { 28 | cmd = cli.NewTestCmd(cmd) 29 | cfg := cli.ConfigFromCmd(cmd) 30 | cfg.Test.SkipRunE = true 31 | if err := envdecode.Decode(cfg); err != nil && err != envdecode.ErrNoTargetFieldsAreSet { 32 | t.Fatal(err) 33 | } 34 | 35 | _, err := execute(cmd, args...) 36 | require.NoError(t, err) 37 | 38 | return cfg 39 | } 40 | 41 | func TestError(t *testing.T, cmd *cobra.Command, args ...string) error { 42 | _, err := executeSkip(cmd, args...) 43 | require.Error(t, err) 44 | 45 | return err 46 | } 47 | 48 | func TestHelp(t *testing.T, cmd *cobra.Command, args ...string) { 49 | root := cmd.Root() 50 | 51 | b, err := execute(root, args...) 52 | require.NoError(t, err) 53 | 54 | out, err := io.ReadAll(b) 55 | require.NoError(t, err) 56 | 57 | uitest.TestGolden(t, string(out)) 58 | } 59 | 60 | func execute(cmd *cobra.Command, args ...string) (*bytes.Buffer, error) { 61 | b := new(bytes.Buffer) 62 | cmd.SetErr(b) 63 | cmd.SetOut(b) 64 | cmd.SetArgs(args) 65 | 66 | err := cmd.Execute() 67 | return b, err 68 | } 69 | 70 | func executeSkip(cmd *cobra.Command, args ...string) (*bytes.Buffer, error) { 71 | cmd = cli.NewTestCmd(cmd) 72 | cfg := cli.ConfigFromCmd(cmd) 73 | cfg.Test.SkipRunE = true 74 | 75 | return execute(cmd, args...) 76 | } 77 | -------------------------------------------------------------------------------- /component/component_test.go: -------------------------------------------------------------------------------- 1 | package component_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/anchordotdev/cli/api/apitest" 9 | ) 10 | 11 | var srv = &apitest.Server{ 12 | Host: "api.anchor.lcl.host", 13 | RootDir: "../..", 14 | } 15 | 16 | func TestMain(m *testing.M) { 17 | if err := srv.Start(context.Background()); err != nil { 18 | panic(err) 19 | } 20 | 21 | defer os.Exit(m.Run()) 22 | 23 | srv.Close() 24 | } 25 | -------------------------------------------------------------------------------- /component/config_via_test.go: -------------------------------------------------------------------------------- 1 | package component_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "testing/fstest" 7 | 8 | "github.com/MakeNowJust/heredoc" 9 | "github.com/anchordotdev/cli" 10 | "github.com/anchordotdev/cli/clitest" 11 | "github.com/anchordotdev/cli/cmdtest" 12 | "github.com/anchordotdev/cli/component/models" 13 | "github.com/anchordotdev/cli/ui" 14 | "github.com/anchordotdev/cli/ui/uitest" 15 | ) 16 | 17 | func TestConfigVia(t *testing.T) { 18 | 19 | cmd := configViaCommand{} 20 | 21 | t.Run("env", func(t *testing.T) { 22 | ctx, cancel := context.WithCancel(context.Background()) 23 | defer cancel() 24 | 25 | t.Setenv("ORG", "env-org") 26 | cfg := cmdtest.Config(ctx) 27 | ctx = cli.ContextWithConfig(ctx, cfg) 28 | 29 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 30 | }) 31 | 32 | t.Run("toml", func(t *testing.T) { 33 | ctx, cancel := context.WithCancel(context.Background()) 34 | defer cancel() 35 | 36 | cfg := new(cli.Config) 37 | cfg.Test.SystemFS = clitest.TestFS{ 38 | "anchor.toml": &fstest.MapFile{ 39 | Data: []byte(heredoc.Doc(` 40 | [org] 41 | apid = "toml-org" 42 | `)), 43 | }, 44 | } 45 | if err := cfg.Load(ctx); err != nil { 46 | panic(err) 47 | } 48 | ctx = cli.ContextWithConfig(ctx, cfg) 49 | 50 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 51 | }) 52 | 53 | t.Run("default", func(t *testing.T) { 54 | ctx, cancel := context.WithCancel(context.Background()) 55 | defer cancel() 56 | 57 | cfg := cmdtest.Config(ctx) 58 | ctx = cli.ContextWithConfig(ctx, cfg) 59 | 60 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 61 | }) 62 | 63 | t.Run("flag", func(t *testing.T) { 64 | ctx, cancel := context.WithCancel(context.Background()) 65 | defer cancel() 66 | 67 | cfg := cmdtest.Config(ctx) 68 | cfg.Org.APID = "flag-org" 69 | ctx = cli.ContextWithConfig(ctx, cfg) 70 | 71 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 72 | }) 73 | } 74 | 75 | type configViaCommand struct{} 76 | 77 | func (c configViaCommand) UI() cli.UI { 78 | return cli.UI{ 79 | RunTUI: c.run, 80 | } 81 | } 82 | 83 | func (*configViaCommand) run(ctx context.Context, drv *ui.Driver) error { 84 | cfg := cli.ConfigFromContext(ctx) 85 | 86 | if cfg.Org.APID != "" { 87 | drv.Activate(ctx, &models.ConfigVia{ 88 | Config: cfg, 89 | ConfigFetchFn: func(cfg *cli.Config) any { return cfg.Org.APID }, 90 | Flag: "--org", 91 | Singular: "organization", 92 | }) 93 | return nil 94 | } 95 | if cfg.API.URL != "" { 96 | drv.Activate(ctx, &models.ConfigVia{ 97 | Config: cfg, 98 | ConfigFetchFn: func(cfg *cli.Config) any { return cfg.API.URL }, 99 | Flag: "API_URL=", 100 | Singular: "api url", 101 | }) 102 | } 103 | 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /component/fetcher.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/anchordotdev/cli/component/models" 7 | "github.com/anchordotdev/cli/ui" 8 | ) 9 | 10 | type Choosable = models.Choosable 11 | 12 | type Fetcher[T Choosable] struct { 13 | FetchFn func() ([]T, error) 14 | } 15 | 16 | func (f *Fetcher[T]) Choices(ctx context.Context, drv *ui.Driver, flag string, creatable bool) ([]T, error) { 17 | var t T 18 | drv.Activate(ctx, &models.Fetcher[T]{ 19 | Flag: flag, 20 | Plural: t.Plural(), 21 | Singular: t.Singular(), 22 | Creatable: creatable, 23 | }) 24 | 25 | items, err := f.FetchFn() 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | drv.Send(items) 31 | return items, nil 32 | } 33 | -------------------------------------------------------------------------------- /component/models/config_via.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/ui" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | type ConfigVia struct { 13 | Config *cli.Config 14 | ConfigFetchFn cli.ConfigFetchFunc 15 | 16 | Flag, Singular string 17 | } 18 | 19 | func (m *ConfigVia) Init() tea.Cmd { return nil } 20 | 21 | func (m *ConfigVia) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 22 | 23 | func (m *ConfigVia) View() string { 24 | var b strings.Builder 25 | 26 | source := m.Config.ViaSource(m.ConfigFetchFn) 27 | value := fmt.Sprintf("%+v", m.ConfigFetchFn(m.Config)) 28 | 29 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Using %s %s from %s. %s", 30 | ui.Emphasize(value), 31 | m.Singular, 32 | source, 33 | ui.Whisper(fmt.Sprintf("You can also use `%s %s`.", m.Flag, value)), 34 | ))) 35 | 36 | return b.String() 37 | } 38 | -------------------------------------------------------------------------------- /component/models/selectors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/list" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | 11 | "github.com/anchordotdev/cli/ui" 12 | ) 13 | 14 | type Choosable interface { 15 | comparable 16 | 17 | Key() string 18 | String() string 19 | 20 | Singular() string 21 | Plural() string 22 | } 23 | 24 | type Fetcher[T Choosable] struct { 25 | Flag, Plural, Singular string 26 | 27 | Creatable bool 28 | 29 | items []T 30 | 31 | spinner spinner.Model 32 | } 33 | 34 | func (m *Fetcher[T]) Init() tea.Cmd { 35 | m.spinner = ui.WaitingSpinner() 36 | 37 | return m.spinner.Tick 38 | } 39 | 40 | func (m *Fetcher[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 41 | switch msg := msg.(type) { 42 | case []T: 43 | m.items = msg 44 | } 45 | 46 | var cmd tea.Cmd 47 | m.spinner, cmd = m.spinner.Update(msg) 48 | return m, cmd 49 | } 50 | 51 | func (m *Fetcher[T]) View() string { 52 | if m.items == nil { 53 | return fmt.Sprintln( 54 | ui.StepInProgress(fmt.Sprintf("Fetching %s…%s", 55 | m.Plural, 56 | m.spinner.View(), 57 | )), 58 | ) 59 | } 60 | 61 | switch len(m.items) { 62 | case 0: 63 | if m.Creatable { 64 | return fmt.Sprintln( 65 | ui.StepDone(fmt.Sprintf("No %s found, so we'll create one.", 66 | m.Plural, 67 | )), 68 | ) 69 | } 70 | 71 | return fmt.Sprintln( 72 | ui.StepAlert(fmt.Sprintf("No %s found!", 73 | m.Plural, 74 | )), 75 | ) 76 | case 1: 77 | item := m.items[0] 78 | 79 | if !m.Creatable { 80 | return fmt.Sprintln( 81 | ui.StepDone(fmt.Sprintf("Using %s, the only available %s. %s", 82 | ui.Emphasize(item.Key()), 83 | m.Singular, 84 | ui.Whisper( 85 | fmt.Sprintf("You can also use `%s %s`.", 86 | m.Flag, 87 | item.Key(), 88 | ), 89 | ), 90 | )), 91 | ) 92 | } 93 | } 94 | return "" 95 | } 96 | 97 | type Selector[T Choosable] struct { 98 | Prompt string 99 | Flag string 100 | Choices []ui.ListItem[T] 101 | 102 | ChoiceCh chan<- T 103 | 104 | chosen ui.ListItem[T] 105 | list list.Model 106 | } 107 | 108 | func (m *Selector[T]) Init() tea.Cmd { 109 | m.list = ui.List(m.Choices) 110 | 111 | return nil 112 | } 113 | 114 | func (m *Selector[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 115 | switch msg := msg.(type) { 116 | case tea.KeyMsg: 117 | switch msg.Type { 118 | case tea.KeyEnter: 119 | if item, ok := m.list.SelectedItem().(ui.ListItem[T]); ok { 120 | if m.ChoiceCh != nil { 121 | m.chosen = item 122 | m.ChoiceCh <- m.chosen.Value 123 | close(m.ChoiceCh) 124 | m.ChoiceCh = nil 125 | } 126 | } 127 | case tea.KeyEsc: 128 | return m, ui.Exit 129 | } 130 | } 131 | 132 | var cmd tea.Cmd 133 | m.list, cmd = m.list.Update(msg) 134 | return m, cmd 135 | } 136 | 137 | func (m *Selector[T]) View() string { 138 | var b strings.Builder 139 | 140 | if m.ChoiceCh != nil { 141 | fmt.Fprintln(&b, ui.StepPrompt(m.Prompt)) 142 | fmt.Fprintln(&b, m.list.View()) 143 | return b.String() 144 | } 145 | 146 | var zeroT T 147 | if m.chosen.Value == zeroT { 148 | fmt.Fprintln(&b, ui.StepDone( 149 | fmt.Sprintf("Selected %s.", ui.Emphasize(m.chosen.String)), 150 | )) 151 | return b.String() 152 | } 153 | 154 | fmt.Fprintln(&b, ui.StepDone( 155 | fmt.Sprintf("Selected %s %s. %s", 156 | ui.Emphasize(m.chosen.Key), 157 | m.chosen.Value.Singular(), 158 | ui.Whisper(fmt.Sprintf("You can also use `%s %s`.", m.Flag, m.chosen.Key)), 159 | ), 160 | )) 161 | 162 | return b.String() 163 | } 164 | -------------------------------------------------------------------------------- /component/selector.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "golang.org/x/text/cases" 8 | "golang.org/x/text/language" 9 | 10 | "github.com/anchordotdev/cli/component/models" 11 | "github.com/anchordotdev/cli/ui" 12 | ) 13 | 14 | type Selector[T Choosable] struct { 15 | Prompt string 16 | Flag string 17 | 18 | Creatable bool 19 | 20 | Choices []T 21 | Fetcher *Fetcher[T] 22 | } 23 | 24 | func (s *Selector[T]) Choice(ctx context.Context, drv *ui.Driver) (*T, error) { 25 | if s.Fetcher != nil { 26 | var err error 27 | if s.Choices, err = s.Fetcher.Choices(ctx, drv, s.Flag, s.Creatable); err != nil { 28 | return nil, err 29 | } 30 | 31 | if len(s.Choices) == 1 && !s.Creatable { 32 | return &s.Choices[0], nil 33 | } 34 | if len(s.Choices) == 0 { 35 | if s.Creatable { 36 | return nil, nil 37 | } 38 | 39 | var t T 40 | return nil, ui.Error{ 41 | Model: ui.Section{ 42 | Name: "FetcherNoneFound", 43 | Model: ui.MessageLines{ 44 | ui.Header(fmt.Sprintf("%s %s", 45 | ui.Danger("Error!"), 46 | fmt.Sprintf("Cannot proceed without %s.", t.Singular()), 47 | )), 48 | }, 49 | }, 50 | } 51 | } 52 | } 53 | 54 | var choices []ui.ListItem[T] 55 | for _, item := range s.Choices { 56 | choice := ui.ListItem[T]{ 57 | Key: item.Key(), 58 | String: fmt.Sprintf("%s %s", 59 | item.String(), 60 | ui.Whisper(fmt.Sprintf("(%s)", item.Key())), 61 | ), 62 | Value: item, 63 | } 64 | choices = append(choices, choice) 65 | } 66 | if s.Creatable { 67 | var t T 68 | choices = append(choices, ui.ListItem[T]{ 69 | String: fmt.Sprintf("Create New %s", cases.Title(language.English).String(t.Singular())), 70 | }) 71 | } 72 | 73 | choicec := make(chan T) 74 | mdl := &models.Selector[T]{ 75 | Prompt: s.Prompt, 76 | Flag: s.Flag, 77 | 78 | Choices: choices, 79 | ChoiceCh: choicec, 80 | } 81 | drv.Activate(ctx, mdl) 82 | 83 | select { 84 | case choice := <-choicec: 85 | return &choice, nil 86 | case <-ctx.Done(): 87 | return nil, ctx.Err() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /component/testdata/TestConfigVia/default.golden: -------------------------------------------------------------------------------- 1 | ─── ConfigVia ────────────────────────────────────────────────────────────────── 2 | - Using https://api.anchor.dev/v0 api url from default. You can also use `API_URL= https://api.anchor.dev/v0`. 3 | -------------------------------------------------------------------------------- /component/testdata/TestConfigVia/env.golden: -------------------------------------------------------------------------------- 1 | ─── ConfigVia ────────────────────────────────────────────────────────────────── 2 | - Using env-org organization from env. You can also use `--org env-org`. 3 | -------------------------------------------------------------------------------- /component/testdata/TestConfigVia/flag.golden: -------------------------------------------------------------------------------- 1 | ─── ConfigVia ────────────────────────────────────────────────────────────────── 2 | - Using flag-org organization from flag. You can also use `--org flag-org`. 3 | -------------------------------------------------------------------------------- /component/testdata/TestConfigVia/toml.golden: -------------------------------------------------------------------------------- 1 | ─── ConfigVia ────────────────────────────────────────────────────────────────── 2 | - Using toml-org organization from anchor.toml. You can also use `--org toml-org`. 3 | -------------------------------------------------------------------------------- /component/testdata/TestSelector/orgs_double.golden: -------------------------------------------------------------------------------- 1 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 2 | * Fetching organizations…⠋ 3 | ─── Selector[github.com/anchordotdev/cli/api.Organization] ───────────────────── 4 | ? Which organization do you want for this test? 5 | > anky@anchor.dev (ankydotdev) 6 | AnkyCo (ankyco) 7 | ─── Selector[github.com/anchordotdev/cli/api.Organization] ───────────────────── 8 | ? Which organization do you want for this test? 9 | anky@anchor.dev (ankydotdev) 10 | > AnkyCo (ankyco) 11 | ─── Selector[github.com/anchordotdev/cli/api.Organization] ───────────────────── 12 | - Selected ankyco organization. You can also use `--org ankyco`. 13 | -------------------------------------------------------------------------------- /component/testdata/TestSelector/orgs_empty.golden: -------------------------------------------------------------------------------- 1 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 2 | * Fetching organizations…⠋ 3 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 4 | ! No organizations found! 5 | ─── FetcherNoneFound ─────────────────────────────────────────────────────────── 6 | ! No organizations found! 7 | 8 | # Error! Cannot proceed without organization. 9 | -------------------------------------------------------------------------------- /component/testdata/TestSelector/orgs_solo.golden: -------------------------------------------------------------------------------- 1 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 2 | * Fetching organizations…⠋ 3 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 4 | - Using solo-org-slug, the only available organization. You can also use `--org solo-org-slug`. 5 | -------------------------------------------------------------------------------- /component/testdata/TestSelector/orgs_solo_creatable.golden: -------------------------------------------------------------------------------- 1 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 2 | * Fetching organizations…⠋ 3 | ─── Selector[github.com/anchordotdev/cli/api.Organization] ───────────────────── 4 | ? Which organization do you want for this test? 5 | > Solo Org Slug (solo-org-slug) 6 | Create New Organization 7 | ─── Selector[github.com/anchordotdev/cli/api.Organization] ───────────────────── 8 | ? Which organization do you want for this test? 9 | Solo Org Slug (solo-org-slug) 10 | > Create New Organization 11 | ─── Selector[github.com/anchordotdev/cli/api.Organization] ───────────────────── 12 | - Selected Create New Organization. 13 | -------------------------------------------------------------------------------- /detection/detection.go: -------------------------------------------------------------------------------- 1 | package detection 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "github.com/anchordotdev/cli/anchorcli" 7 | ) 8 | 9 | // Confidence represents the confidence score 10 | type Confidence int 11 | 12 | // Different confidence levels 13 | const ( 14 | High Confidence = 100 15 | Medium Confidence = 60 16 | Low Confidence = 40 17 | None Confidence = 0 18 | ) 19 | 20 | // Confidence.String() returns the string representation of the confidence level 21 | func (s Confidence) String() string { 22 | switch s { 23 | case High: 24 | return "High" 25 | case Medium: 26 | return "Medium" 27 | case Low: 28 | return "Low" 29 | case None: 30 | return "None" 31 | default: 32 | return "Unknown" 33 | } 34 | } 35 | 36 | var ( 37 | DefaultDetectors = []Detector{ 38 | Go, 39 | Javascript, 40 | Python, 41 | Ruby, 42 | Custom, 43 | } 44 | 45 | DetectorsByFlag = map[string]Detector{ 46 | "django": Django, 47 | "flask": Flask, 48 | "go": Go, 49 | "javascript": Javascript, 50 | "nextjs": NextJS, 51 | "python": Python, 52 | "rails": Rails, 53 | "ruby": Ruby, 54 | "sinatra": Sinatra, 55 | } 56 | 57 | PositiveDetectionMessage = "%s project detected with confidence level %s!\n" 58 | ) 59 | 60 | type FS interface { 61 | fs.ReadFileFS 62 | fs.StatFS 63 | } 64 | 65 | // Match holds the detection result, confidence, and follow-up detectors 66 | type Match struct { 67 | Detector Detector 68 | Detected bool 69 | Confidence Confidence 70 | // MissingRequiredFiles represents a list of files that are required but missing. 71 | MissingRequiredFiles []string 72 | FollowUpDetectors []Detector 73 | Details string 74 | AnchorCategory *anchorcli.Category 75 | } 76 | 77 | // Detector interface represents a project detector 78 | type Detector interface { 79 | GetTitle() string 80 | Detect(FS) (Match, error) 81 | FollowUp() []Detector 82 | } 83 | 84 | func Perform(detectors []Detector, dirFS FS) (Results, error) { 85 | res := make(Results) 86 | 87 | for _, detector := range detectors { 88 | match, err := detector.Detect(dirFS) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if !match.Detected { 94 | res[None] = append(res[None], match) 95 | continue 96 | } 97 | 98 | res[match.Confidence] = append(res[match.Confidence], match) 99 | 100 | if followupResults, err := Perform(match.FollowUpDetectors, dirFS); err == nil { 101 | res.merge(followupResults) 102 | } else { 103 | return nil, err 104 | } 105 | } 106 | return res, nil 107 | } 108 | 109 | type Results map[Confidence][]Match 110 | 111 | func (r Results) merge(other Results) { 112 | for confidence, matches := range other { 113 | for _, match := range matches { 114 | if !match.Detected { 115 | continue 116 | } 117 | // Merge the results, putting the new matches at the front of the list 118 | r[confidence] = append([]Match{match}, r[confidence]...) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /detection/detection_test.go: -------------------------------------------------------------------------------- 1 | package detection 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | "testing/fstest" 7 | 8 | _ "github.com/anchordotdev/cli/testflags" 9 | ) 10 | 11 | func TestScore_String(t *testing.T) { 12 | testCases := []struct { 13 | confidence Confidence 14 | expectedString string 15 | }{ 16 | {High, "High"}, 17 | {Medium, "Medium"}, 18 | {Low, "Low"}, 19 | {None, "None"}, 20 | {Confidence(42), "Unknown"}, // Unknown confidence scores 21 | } 22 | 23 | for _, testCase := range testCases { 24 | t.Run(testCase.expectedString, func(t *testing.T) { 25 | actualString := testCase.confidence.String() 26 | if actualString != testCase.expectedString { 27 | t.Errorf("Expected string representation %s, but got %s", testCase.expectedString, actualString) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | func TestDefaultDetectors(t *testing.T) { 34 | // Verify that the default detectors are present 35 | if len(DefaultDetectors) < 1 { 36 | t.Errorf("Expected some default detectors, but got %d", len(DefaultDetectors)) 37 | } 38 | 39 | fakeFS := fstest.MapFS{ 40 | "Gemfile": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 41 | "Gemfile.lock": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 42 | "Pipfile": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 43 | "Pipfile.lock": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 44 | "package.json": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 45 | "requirements.txt": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 46 | "main.go": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 47 | "index.js": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 48 | "app.py": &fstest.MapFile{Data: []byte(""), Mode: 0644}, 49 | } 50 | 51 | for _, detector := range DefaultDetectors { 52 | t.Run(detector.GetTitle(), func(t *testing.T) { 53 | // Assume all detectors are FileDetectors right now 54 | det := detector.(*FileDetector) 55 | 56 | match, err := det.Detect(fakeFS) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | if !slices.Contains([]Confidence{High, Medium, Low, None}, match.Confidence) { 62 | t.Errorf("Expected confidence to be High, Medium or Low, but got %s", match.Confidence) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /detection/filesystem.go: -------------------------------------------------------------------------------- 1 | package detection 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "slices" 8 | 9 | "github.com/anchordotdev/cli/anchorcli" 10 | ) 11 | 12 | // FileDetector is a generic file-based project detector 13 | type FileDetector struct { 14 | Title string 15 | Paths []string 16 | RequiredFiles []string 17 | FollowUpDetectors []Detector 18 | AnchorCategory *anchorcli.Category 19 | } 20 | 21 | // GetTitle returns the name of the detector 22 | func (fd FileDetector) GetTitle() string { 23 | return fd.Title 24 | } 25 | 26 | // Detect checks if the directory contains any of the specified files 27 | func (fd FileDetector) Detect(dirFS FS) (Match, error) { 28 | var matchedPaths []string 29 | 30 | for _, path := range fd.Paths { 31 | if _, err := dirFS.Stat(path); err == nil { 32 | matchedPaths = append(matchedPaths, path) 33 | } else if !os.IsNotExist(err) { 34 | return Match{}, errors.Join(err, errors.New("project file detection failure")) 35 | } 36 | } 37 | 38 | // Calculate the match confidence based on the percentage of matched paths 39 | percentage := float64(len(matchedPaths)) / float64(len(fd.Paths)) 40 | var confidence Confidence 41 | 42 | // Assume a 25% window for each confidence level, anything less than 30% is None 43 | // Completely arbitrary, but it's a start. 44 | switch { 45 | case percentage >= 0.80: 46 | confidence = High 47 | case percentage >= 0.55: 48 | confidence = Medium 49 | case percentage >= 0.30: 50 | confidence = Low 51 | default: 52 | confidence = None 53 | } 54 | 55 | var missingRequiredFiles []string 56 | if len(matchedPaths) > 0 && fd.RequiredFiles != nil && len(fd.RequiredFiles) > 0 { 57 | for _, reqPath := range fd.RequiredFiles { 58 | if !slices.Contains(matchedPaths, reqPath) { 59 | missingRequiredFiles = append(missingRequiredFiles, reqPath) 60 | // Only lower confidence 61 | if confidence != None && confidence != Low { 62 | confidence = Low // Force confidence to low when required files are missing 63 | } 64 | continue 65 | } 66 | } 67 | } 68 | 69 | match := Match{ 70 | Detector: fd, 71 | Detected: len(matchedPaths) > 0, 72 | Confidence: confidence, 73 | FollowUpDetectors: fd.FollowUpDetectors, 74 | MissingRequiredFiles: missingRequiredFiles, 75 | } 76 | 77 | if fd.AnchorCategory != nil { 78 | match.AnchorCategory = fd.AnchorCategory 79 | } else { 80 | // Default to Custom Category if not specified by the detector 81 | match.AnchorCategory = anchorcli.CategoryCustom 82 | } 83 | 84 | // Return a Match with the calculated confidence, follow-ups and category 85 | return match, nil 86 | } 87 | 88 | // FollowUp returns additional detectors 89 | func (fd FileDetector) FollowUp() []Detector { 90 | return fd.FollowUpDetectors 91 | } 92 | 93 | // osFS is a simple wrapper around the os package's file system functions 94 | // so that we can mock them out for testing. 95 | type osFS struct{} 96 | 97 | // Stat wraps os.Stat 98 | func (osFS) Stat(path string) (fs.FileInfo, error) { return os.Stat(path) } 99 | func (osFS) Open(path string) (fs.File, error) { return os.Open(path) } 100 | 101 | var ( 102 | _ fs.FS = (*osFS)(nil) 103 | ) 104 | -------------------------------------------------------------------------------- /detection/framework.go: -------------------------------------------------------------------------------- 1 | package detection 2 | 3 | import ( 4 | "github.com/anchordotdev/cli/anchorcli" 5 | ) 6 | 7 | var ( 8 | 9 | // Python Frameworks 10 | 11 | Django = &FileDetector{ 12 | Title: "Django", 13 | Paths: []string{"manage.py"}, 14 | FollowUpDetectors: nil, 15 | AnchorCategory: anchorcli.CategoryPython, 16 | } 17 | Flask = &FileDetector{ 18 | Title: "Flask", 19 | Paths: []string{"app.py"}, 20 | FollowUpDetectors: nil, 21 | AnchorCategory: anchorcli.CategoryPython, 22 | } 23 | 24 | // Ruby Frameworks 25 | 26 | Rails = &FileDetector{ 27 | Title: "Ruby on Rails", 28 | Paths: []string{ 29 | "config.ru", "app", "config", "db", "lib", "public", "vendor", 30 | }, 31 | FollowUpDetectors: nil, 32 | AnchorCategory: anchorcli.CategoryRuby, 33 | } 34 | Sinatra = &FileDetector{ 35 | Title: "Sinatra", 36 | Paths: []string{ 37 | "app.rb", 38 | }, 39 | FollowUpDetectors: nil, 40 | AnchorCategory: anchorcli.CategoryRuby, 41 | RequiredFiles: []string{"app.rb"}, 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /detection/languages.go: -------------------------------------------------------------------------------- 1 | package detection 2 | 3 | import ( 4 | "github.com/anchordotdev/cli/anchorcli" 5 | ) 6 | 7 | var ( 8 | Custom = &FileDetector{ 9 | Title: "Custom", 10 | Paths: []string{}, 11 | FollowUpDetectors: nil, 12 | AnchorCategory: anchorcli.CategoryCustom, 13 | } 14 | 15 | Go = &FileDetector{ 16 | Title: "Go", 17 | Paths: []string{"main.go", "go.mod", "go.sum"}, 18 | FollowUpDetectors: nil, 19 | AnchorCategory: anchorcli.CategoryGo, 20 | } 21 | 22 | Javascript = &FileDetector{ 23 | Title: "JavaScript", 24 | Paths: []string{"package.json", "index.js", "app.js"}, 25 | FollowUpDetectors: []Detector{NextJS}, 26 | AnchorCategory: anchorcli.CategoryJavascript, 27 | } 28 | 29 | Python = &FileDetector{ 30 | Title: "Python", 31 | Paths: []string{"Pipfile", "Pipfile.lock", "requirements.txt"}, 32 | FollowUpDetectors: []Detector{Django, Flask}, 33 | AnchorCategory: anchorcli.CategoryPython, 34 | } 35 | 36 | Ruby = &FileDetector{ 37 | Title: "Ruby", 38 | Paths: []string{"Gemfile", "Gemfile.lock", "Rakefile"}, 39 | FollowUpDetectors: []Detector{Rails, Sinatra}, 40 | AnchorCategory: anchorcli.CategoryRuby, 41 | } 42 | ) 43 | -------------------------------------------------------------------------------- /detection/nextjs.go: -------------------------------------------------------------------------------- 1 | package detection 2 | 3 | import ( 4 | "github.com/anchordotdev/cli/anchorcli" 5 | "github.com/anchordotdev/cli/detection/package_managers" 6 | ) 7 | 8 | var NextJS = &NextJSDetector{} 9 | 10 | type NextJSDetector struct { 11 | FollowUpDetectors []Detector 12 | } 13 | 14 | func (d NextJSDetector) GetTitle() string { return "Next.js" } 15 | 16 | func (d NextJSDetector) FollowUp() []Detector { 17 | return d.FollowUpDetectors 18 | } 19 | 20 | func (d NextJSDetector) Detect(dirFS FS) (Match, error) { 21 | _, err := dirFS.Stat("package.json") 22 | if err != nil { 23 | return Match{}, err 24 | } 25 | 26 | packageData, err := dirFS.ReadFile("package.json") 27 | if err != nil { 28 | return Match{}, err 29 | } 30 | 31 | packages, err := package_managers.ParsePackageJSON(packageData) 32 | if err != nil { 33 | return Match{}, err 34 | } 35 | 36 | if packages.HasDependency("next") { 37 | return Match{ 38 | Detector: d, 39 | Detected: true, 40 | Confidence: High, 41 | AnchorCategory: anchorcli.CategoryJavascript, 42 | }, nil 43 | } 44 | return Match{}, nil 45 | } 46 | -------------------------------------------------------------------------------- /detection/package_managers.go: -------------------------------------------------------------------------------- 1 | package detection 2 | 3 | type PackageManager string 4 | type PackageManagerManifest string 5 | 6 | var SupportedPackageManagers = []PackageManager{ 7 | RubyGemsPkgManager, 8 | NPMPkgManager, 9 | YarnPkgManager, 10 | } 11 | 12 | const ( 13 | RubyGemsPkgManager PackageManager = "rubygems" 14 | NPMPkgManager PackageManager = "npm" 15 | YarnPkgManager PackageManager = "yarn" 16 | ) 17 | 18 | const ( 19 | Gemfile PackageManagerManifest = "Gemfile" 20 | GemfileLock PackageManagerManifest = "Gemfile.lock" 21 | PackageJSON PackageManagerManifest = "package.json" 22 | PackageLockJSON PackageManagerManifest = "package-lock.json" 23 | YarnLock PackageManagerManifest = "yarn.lock" 24 | ) 25 | 26 | func (pmm PackageManagerManifest) String() string { 27 | return string(pmm) 28 | } 29 | 30 | func (pm PackageManager) String() string { 31 | return string(pm) 32 | } 33 | -------------------------------------------------------------------------------- /detection/package_managers/npm.go: -------------------------------------------------------------------------------- 1 | package package_managers 2 | 3 | import "encoding/json" 4 | 5 | type PackageJSON struct { 6 | Dependencies map[string]string `json:"dependencies"` 7 | DevDependencies map[string]string `json:"devDependencies"` 8 | } 9 | 10 | func (p PackageJSON) HasDependency(name string) bool { 11 | if _, ok := p.Dependencies[name]; ok { 12 | return true 13 | } else if _, ok := p.DevDependencies[name]; ok { 14 | return true 15 | } 16 | 17 | return false 18 | } 19 | 20 | func ParsePackageJSON(contents []byte) (PackageJSON, error) { 21 | var packageJSON PackageJSON 22 | err := json.Unmarshal(contents, &packageJSON) 23 | return packageJSON, err 24 | } 25 | -------------------------------------------------------------------------------- /diagnostic/server_test.go: -------------------------------------------------------------------------------- 1 | package diagnostic 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "net" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/anchordotdev/cli/internal/must" 13 | _ "github.com/anchordotdev/cli/testflags" 14 | ) 15 | 16 | func TestServerSupportsDualProtocols(t *testing.T) { 17 | cert := leaf.TLS() 18 | 19 | srv := &Server{ 20 | Addr: ":0", 21 | GetCertificate: func(cii *tls.ClientHelloInfo) (*tls.Certificate, error) { 22 | return &cert, nil 23 | }, 24 | } 25 | 26 | if err := srv.Start(context.Background()); err != nil { 27 | t.Fatal(err) 28 | } 29 | defer srv.Close() 30 | 31 | srv.EnableTLS() 32 | 33 | _, port, err := net.SplitHostPort(srv.Addr) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | client := &http.Client{ 39 | Transport: &http.Transport{ 40 | ForceAttemptHTTP2: true, 41 | DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { 42 | return new(net.Dialer).DialContext(ctx, network, srv.Addr) 43 | }, 44 | TLSClientConfig: &tls.Config{ 45 | RootCAs: anchorCA.CertPool(), 46 | }, 47 | }, 48 | } 49 | 50 | resHTTP, err := client.Get("http://example.lcl.host.test:" + port) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | if want, got := http.StatusOK, resHTTP.StatusCode; want != got { 55 | t.Errorf("want http response status %d, got %d", want, got) 56 | } 57 | if got := resHTTP.TLS; got != nil { 58 | t.Errorf("want nil http response tls info, got %#v", got) 59 | } 60 | 61 | resHTTPS, err := client.Get("https://example.lcl.host.test:" + port) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | if want, got := http.StatusOK, resHTTPS.StatusCode; want != got { 66 | t.Errorf("want https response status %d, got %d", want, got) 67 | } 68 | if got := resHTTPS.TLS; got == nil { 69 | t.Error("https response tls info was nil") 70 | } 71 | } 72 | 73 | var ( 74 | anchorCA = must.CA(&x509.Certificate{ 75 | Subject: pkix.Name{ 76 | CommonName: "Example CA - AnchorCA", 77 | Organization: []string{"Example, Inc"}, 78 | }, 79 | KeyUsage: x509.KeyUsageCertSign, 80 | IsCA: true, 81 | }) 82 | 83 | subCA = anchorCA.Issue(&x509.Certificate{ 84 | Subject: pkix.Name{ 85 | CommonName: "Example CA - SubCA", 86 | Organization: []string{"Example, Inc"}, 87 | }, 88 | KeyUsage: x509.KeyUsageCertSign, 89 | IsCA: true, 90 | BasicConstraintsValid: true, 91 | }) 92 | 93 | leaf = subCA.Issue(&x509.Certificate{ 94 | Subject: pkix.Name{ 95 | CommonName: "example.lcl.host.test", 96 | Organization: []string{"Example, Inc"}, 97 | }, 98 | 99 | DNSNames: []string{"example.lcl.host.test", "*.example.lcl.host.test"}, 100 | }) 101 | ) 102 | -------------------------------------------------------------------------------- /ext509/anchor.go: -------------------------------------------------------------------------------- 1 | package ext509 2 | 3 | import ( 4 | "crypto/x509/pkix" 5 | "errors" 6 | "time" 7 | 8 | "golang.org/x/crypto/cryptobyte" 9 | xasn1 "golang.org/x/crypto/cryptobyte/asn1" 10 | 11 | "github.com/anchordotdev/cli/ext509/oid" 12 | ) 13 | 14 | // AnchorCertificate is a custom X.509 certificate extension used by Anchor 15 | // Security, Inc. to include important certificate metadata. 16 | type AnchorCertificate struct { 17 | AutoRenewAt time.Time 18 | RenewAfter time.Time 19 | } 20 | 21 | func (ac AnchorCertificate) Extension() (pkix.Extension, error) { 22 | var b cryptobyte.Builder 23 | b.AddValue(ac) 24 | 25 | buf, err := b.Bytes() 26 | if err != nil { 27 | return pkix.Extension{}, err 28 | } 29 | 30 | return pkix.Extension{ 31 | Id: oid.AnchorCertificateExtension, 32 | Critical: false, 33 | Value: buf, 34 | }, nil 35 | } 36 | 37 | var ( 38 | tagAutoRenewAt = xasn1.Tag(1).Constructed().ContextSpecific() 39 | tagRenewAfter = xasn1.Tag(2).Constructed().ContextSpecific() 40 | ) 41 | 42 | func (ac AnchorCertificate) Marshal(b *cryptobyte.Builder) error { 43 | // Anchor ::= SEQUENCE { 44 | // _reserved_ [0] RESERVED OPTIONAL, 45 | // autoRenewAt [1] GeneralizedTime OPTIONAL, 46 | // renewAfter [2] GeneralizedTime OPTIONAL } 47 | b.AddASN1(xasn1.SEQUENCE, func(b *cryptobyte.Builder) { 48 | if !ac.AutoRenewAt.IsZero() { 49 | b.AddASN1(tagAutoRenewAt, func(b *cryptobyte.Builder) { 50 | b.AddASN1GeneralizedTime(ac.AutoRenewAt.UTC().Round(time.Second)) 51 | }) 52 | } 53 | 54 | if !ac.RenewAfter.IsZero() { 55 | b.AddASN1(tagRenewAfter, func(b *cryptobyte.Builder) { 56 | b.AddASN1GeneralizedTime(ac.RenewAfter.UTC().Round(time.Second)) 57 | }) 58 | } 59 | }) 60 | return nil 61 | } 62 | 63 | func (ac *AnchorCertificate) Unmarshal(ext pkix.Extension) error { 64 | if !ext.Id.Equal(oid.AnchorCertificateExtension) || ext.Critical { 65 | return errors.New("ext509: not an Anchor Certificate Extension") 66 | } 67 | 68 | input := cryptobyte.String(ext.Value) 69 | if !input.ReadASN1(&input, xasn1.SEQUENCE) { 70 | return errors.New("ext509: malformed Anchor Certificate Extension") 71 | } 72 | 73 | for !input.Empty() { 74 | var ( 75 | buf cryptobyte.String 76 | tag xasn1.Tag 77 | ) 78 | 79 | if !input.ReadAnyASN1(&buf, &tag) { 80 | return errors.New("ext509: malformed Anchor Certificate Extension") 81 | } 82 | 83 | switch tag { 84 | case tagAutoRenewAt: 85 | if !buf.ReadASN1GeneralizedTime(&ac.AutoRenewAt) { 86 | return errors.New("ext509: malformed Anchor Certificate Extension: autoRenewAt") 87 | } 88 | case tagRenewAfter: 89 | if !buf.ReadASN1GeneralizedTime(&ac.RenewAfter) { 90 | return errors.New("ext509: malformed Anchor Certificate Extension: renewAfter") 91 | } 92 | } 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /ext509/oid/oid.go: -------------------------------------------------------------------------------- 1 | package oid 2 | 3 | import "encoding/asn1" 4 | 5 | var ( 6 | AnchorPEN = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 60900} 7 | AnchorCertificateExtension = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 60900, 1} 8 | ) 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/anchordotdev/cli 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/MakeNowJust/heredoc v1.0.0 7 | github.com/Masterminds/semver v1.5.0 8 | github.com/atotto/clipboard v0.1.4 9 | github.com/aymanbagabas/go-udiff v0.2.0 10 | github.com/benburkert/dns v0.0.0-20190225204957-d356cf78cdfc 11 | github.com/brianvoe/gofakeit/v7 v7.2.1 12 | github.com/charmbracelet/bubbles v0.20.0 13 | github.com/charmbracelet/bubbletea v1.3.5 14 | github.com/charmbracelet/lipgloss v1.1.0 15 | github.com/charmbracelet/x/exp/teatest v0.0.0-20240222131549-03ee51df8bea 16 | github.com/cli/browser v1.3.0 17 | github.com/fatih/structtag v1.2.0 18 | github.com/go-test/deep v1.1.1 19 | github.com/gofrs/flock v0.12.1 20 | github.com/google/go-github/v54 v54.0.0 21 | github.com/inetaf/tcpproxy v0.0.0-20240214030015-3ce58045626c 22 | github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd 23 | github.com/mcuadros/go-defaults v1.2.0 24 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 25 | github.com/muesli/termenv v0.16.0 26 | github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 27 | github.com/oapi-codegen/runtime v1.1.1 28 | github.com/pelletier/go-toml/v2 v2.2.4 29 | github.com/r3labs/diff/v3 v3.0.1 30 | github.com/spf13/cobra v1.9.1 31 | github.com/spf13/pflag v1.0.6 32 | github.com/stretchr/testify v1.10.0 33 | github.com/zalando/go-keyring v0.2.6 34 | golang.org/x/crypto v0.37.0 35 | golang.org/x/exp v0.0.0-20240318143956-a85f2c67cd81 36 | golang.org/x/sync v0.13.0 37 | golang.org/x/sys v0.33.0 38 | golang.org/x/text v0.24.0 39 | howett.net/plist v1.0.1 40 | ) 41 | 42 | require ( 43 | al.essio.dev/pkg/shellescape v1.5.1 // indirect 44 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 45 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 46 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 47 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 48 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 49 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect 50 | github.com/charmbracelet/x/term v0.2.1 // indirect 51 | github.com/cloudflare/circl v1.3.7 // indirect 52 | github.com/danieljoos/wincred v1.2.2 // indirect 53 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 54 | github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect 55 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 56 | github.com/getkin/kin-openapi v0.127.0 // indirect 57 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 58 | github.com/go-openapi/swag v0.23.0 // indirect 59 | github.com/godbus/dbus/v5 v5.1.0 // indirect 60 | github.com/google/go-cmp v0.7.0 // indirect 61 | github.com/google/go-querystring v1.1.0 // indirect 62 | github.com/google/uuid v1.6.0 // indirect 63 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 64 | github.com/invopop/yaml v0.3.1 // indirect 65 | github.com/josharian/intern v1.0.0 // indirect 66 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 67 | github.com/mailru/easyjson v0.7.7 // indirect 68 | github.com/mattn/go-isatty v0.0.20 // indirect 69 | github.com/mattn/go-localereader v0.0.1 // indirect 70 | github.com/mattn/go-runewidth v0.0.16 // indirect 71 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 72 | github.com/muesli/cancelreader v0.2.2 // indirect 73 | github.com/perimeterx/marshmallow v1.1.5 // indirect 74 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 75 | github.com/rivo/uniseg v0.4.7 // indirect 76 | github.com/sahilm/fuzzy v0.1.1 // indirect 77 | github.com/sergi/go-diff v1.3.1 // indirect 78 | github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect 79 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 80 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 81 | github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect 82 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 83 | golang.org/x/mod v0.24.0 // indirect 84 | golang.org/x/net v0.39.0 // indirect 85 | golang.org/x/oauth2 v0.24.0 // indirect 86 | golang.org/x/tools v0.29.0 // indirect 87 | gopkg.in/yaml.v2 v2.4.0 // indirect 88 | gopkg.in/yaml.v3 v3.0.1 // indirect 89 | ) 90 | -------------------------------------------------------------------------------- /internal/must/x509.go: -------------------------------------------------------------------------------- 1 | package must 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "crypto/tls" 11 | "crypto/x509" 12 | "crypto/x509/pkix" 13 | "encoding/pem" 14 | "math/big" 15 | "time" 16 | ) 17 | 18 | type Certificate tls.Certificate 19 | 20 | func CA(template *x509.Certificate) *Certificate { 21 | template.IsCA = true 22 | template.BasicConstraintsValid = true 23 | 24 | return Cert(template).SelfSign() 25 | } 26 | 27 | func Cert(template *x509.Certificate) *Certificate { 28 | leaf := new(x509.Certificate) 29 | *leaf = *template 30 | 31 | sigAlgo := leaf.SignatureAlgorithm 32 | if sigAlgo == x509.UnknownSignatureAlgorithm { 33 | sigAlgo = x509.ECDSAWithSHA256 34 | } 35 | 36 | priv := generateKey(sigAlgo) 37 | if leaf.PublicKey == nil { 38 | leaf.PublicKey = priv.Public() 39 | } 40 | 41 | if leaf.SerialNumber == nil { 42 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 43 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | leaf.SerialNumber = serialNumber 49 | leaf.SubjectKeyId = serialNumber.Bytes() 50 | } 51 | 52 | if leaf.NotAfter.IsZero() { 53 | now := time.Now() 54 | 55 | leaf.NotBefore = now.Add(-5 * time.Second) 56 | leaf.NotAfter = now.Add(5 * time.Minute) 57 | } 58 | 59 | return &Certificate{ 60 | PrivateKey: priv, 61 | Leaf: leaf, 62 | } 63 | } 64 | 65 | func Load(data string) *Certificate { 66 | blk, _ := pem.Decode([]byte(data)) 67 | cert, err := x509.ParseCertificate(blk.Bytes) 68 | if err != nil { 69 | panic(err) 70 | } 71 | return Cert(cert) 72 | } 73 | 74 | func (c *Certificate) New(name string) *Certificate { 75 | template := &x509.Certificate{ 76 | Subject: pkix.Name{ 77 | CommonName: name, 78 | }, 79 | DNSNames: []string{name}, 80 | } 81 | 82 | return c.Sign(Cert(template)) 83 | } 84 | 85 | func (c *Certificate) Issue(template *x509.Certificate) *Certificate { 86 | return c.Sign(Cert(template)) 87 | } 88 | 89 | func (c *Certificate) SelfSign() *Certificate { return c.Sign(c) } 90 | 91 | func (c *Certificate) Sign(template *Certificate) *Certificate { 92 | cert, err := x509.CreateCertificate(rand.Reader, template.Leaf, c.Leaf, template.Leaf.PublicKey, c.PrivateKey) 93 | if err != nil { 94 | panic(err) 95 | } 96 | 97 | leaf, err := x509.ParseCertificate(cert) 98 | if err != nil { 99 | panic(err) 100 | } 101 | 102 | return &Certificate{ 103 | Certificate: append([][]byte{cert}, c.Certificate...), 104 | PrivateKey: template.PrivateKey, 105 | Leaf: leaf, 106 | } 107 | } 108 | 109 | func (c *Certificate) CertPool() *x509.CertPool { 110 | pool := x509.NewCertPool() 111 | pool.AddCert(c.X509()) 112 | return pool 113 | } 114 | 115 | func (c *Certificate) TLS() tls.Certificate { return (tls.Certificate)(*c) } 116 | 117 | func (c *Certificate) X509() *x509.Certificate { return c.Leaf } 118 | 119 | func generateKey(sa x509.SignatureAlgorithm) (key crypto.Signer) { 120 | var err error 121 | switch sa { 122 | case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, x509.SHA256WithRSA, x509.SHA384WithRSA, x509.SHA512WithRSA, x509.SHA256WithRSAPSS, x509.SHA384WithRSAPSS, x509.SHA512WithRSAPSS: 123 | key, err = rsa.GenerateKey(rand.Reader, 2048) 124 | case x509.ECDSAWithSHA1, x509.ECDSAWithSHA256, x509.ECDSAWithSHA384, x509.ECDSAWithSHA512: 125 | key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 126 | case x509.PureEd25519: 127 | _, key, err = ed25519.GenerateKey(rand.Reader) 128 | } 129 | 130 | if err != nil { 131 | panic(err) 132 | } 133 | return key 134 | } 135 | -------------------------------------------------------------------------------- /keyring/keyring.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "os/user" 5 | "sync" 6 | 7 | "github.com/zalando/go-keyring" 8 | 9 | "github.com/anchordotdev/cli" 10 | ) 11 | 12 | var ErrNotFound = keyring.ErrNotFound 13 | 14 | type label string 15 | 16 | const ( 17 | APIToken label = "API Token" 18 | ) 19 | 20 | type Keyring struct { 21 | Config *cli.Config 22 | 23 | inito sync.Once 24 | } 25 | 26 | func (k *Keyring) init() { 27 | k.inito.Do(func() { 28 | if k.Config.Keyring.MockMode { 29 | keyring.MockInit() 30 | } 31 | }) 32 | } 33 | 34 | func (k *Keyring) Delete(id label) error { 35 | k.init() 36 | 37 | u, err := user.Current() 38 | if err != nil { 39 | return err 40 | } 41 | 42 | return keyring.Delete(k.service(id), u.Username) 43 | } 44 | 45 | func (k *Keyring) Get(id label) (string, error) { 46 | k.init() 47 | 48 | u, err := user.Current() 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | return keyring.Get(k.service(id), u.Username) 54 | } 55 | 56 | func (k *Keyring) Set(id label, secret string) error { 57 | k.init() 58 | 59 | u, err := user.Current() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | return keyring.Set(k.service(id), u.Username, secret) 65 | } 66 | 67 | func (k *Keyring) service(id label) string { 68 | url := k.Config.API.URL 69 | return url + " " + string(id) 70 | } 71 | -------------------------------------------------------------------------------- /keyring/keyring_test.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/anchordotdev/cli" 7 | _ "github.com/anchordotdev/cli/testflags" 8 | "github.com/zalando/go-keyring" 9 | ) 10 | 11 | func TestKeyring(t *testing.T) { 12 | cfg := new(cli.Config) 13 | cfg.Keyring.MockMode = true 14 | cfg.API.URL = "http://test-keyring-url.example.com/" 15 | 16 | kr := &Keyring{Config: cfg} 17 | 18 | val, err := kr.Get(label("test-missing-label")) 19 | if want, got := keyring.ErrNotFound, err; want != got { 20 | t.Fatalf("want read from empty keyring error %q, got %q", want, got) 21 | } 22 | if want, got := "", val; want != got { 23 | t.Errorf("want read from empty keyring value %q, got %q", want, got) 24 | } 25 | 26 | secret := "open sesame" 27 | if err := kr.Set(label("test-secret-label"), secret); err != nil { 28 | t.Fatal(err) 29 | } 30 | 31 | if val, err = kr.Get(label("test-secret-label")); err != nil { 32 | t.Fatal(err) 33 | } 34 | if want, got := secret, val; want != got { 35 | t.Errorf("want read after keyring write value %q, got %q", want, got) 36 | } 37 | 38 | if want, got := keyring.ErrNotFound, kr.Delete(label("test-missing-label")); want != got { 39 | t.Fatalf("want delete for unset keyring error %q, got %q", want, got) 40 | } 41 | 42 | if err := kr.Delete(label("test-secret-label")); err != nil { 43 | t.Fatal(err) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lcl/audit.go: -------------------------------------------------------------------------------- 1 | package lcl 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/anchordotdev/cli" 9 | "github.com/anchordotdev/cli/api" 10 | "github.com/anchordotdev/cli/auth" 11 | "github.com/anchordotdev/cli/lcl/models" 12 | "github.com/anchordotdev/cli/trust" 13 | "github.com/anchordotdev/cli/truststore" 14 | truststoreModels "github.com/anchordotdev/cli/truststore/models" 15 | "github.com/anchordotdev/cli/ui" 16 | ) 17 | 18 | var CmdLclAudit = cli.NewCmd[Audit](CmdLcl, "audit", func(cmd *cobra.Command) {}) 19 | 20 | type Audit struct { 21 | anc *api.Session 22 | 23 | cas []*truststore.CA 24 | } 25 | 26 | func (c Audit) UI() cli.UI { 27 | return cli.UI{ 28 | RunTUI: c.run, 29 | } 30 | } 31 | 32 | func (c Audit) run(ctx context.Context, drv *ui.Driver) error { 33 | var err error 34 | cmd := &auth.Client{ 35 | Anc: c.anc, 36 | Source: "lclhost", 37 | } 38 | c.anc, err = cmd.Perform(ctx, drv) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | drv.Activate(ctx, models.AuditHeader) 44 | drv.Activate(ctx, models.AuditHint) 45 | 46 | _, err = c.perform(ctx, drv) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (c Audit) perform(ctx context.Context, drv *ui.Driver) (*truststore.AuditInfo, error) { 55 | drv.Activate(ctx, &truststoreModels.TrustStoreAudit{}) 56 | 57 | stores, err := trust.LoadStores(ctx, drv) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | cas := c.cas 63 | if len(cas) == 0 { 64 | var err error 65 | if cas, err = trust.FetchLocalDevCAs(ctx, c.anc); err != nil { 66 | return nil, err 67 | } 68 | } 69 | 70 | auditInfo, err := trust.PerformAudit(ctx, stores, cas) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | drv.Send(truststoreModels.AuditInfoMsg(auditInfo)) 76 | 77 | return auditInfo, nil 78 | } 79 | -------------------------------------------------------------------------------- /lcl/audit_test.go: -------------------------------------------------------------------------------- 1 | package lcl 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/cmdtest" 9 | "github.com/anchordotdev/cli/ui/uitest" 10 | ) 11 | 12 | func TestCmdLclAudit(t *testing.T) { 13 | t.Run("--help", func(t *testing.T) { 14 | cmdtest.TestHelp(t, CmdLclAudit, "lcl", "audit", "--help") 15 | }) 16 | } 17 | 18 | func TestAudit(t *testing.T) { 19 | if srv.IsProxy() { 20 | t.Skip("lcl audit unsupported in proxy mode") 21 | } 22 | 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | defer cancel() 25 | 26 | cfg := new(cli.Config) 27 | cfg.API.URL = srv.URL 28 | cfg.Test.Prefer = map[string]cli.ConfigTestPrefer{ 29 | "/v0/orgs/org-slug/realms": { 30 | Example: "development", 31 | }, 32 | } 33 | cfg.Trust.Stores = []string{"mock"} 34 | var err error 35 | if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { 36 | t.Fatal(err) 37 | } 38 | ctx = cli.ContextWithConfig(ctx, cfg) 39 | 40 | t.Run("basics", func(t *testing.T) { 41 | uitest.TestTUIOutput(ctx, t, new(Audit).UI()) 42 | }) 43 | 44 | t.Run("missing-localhost-ca", func(t *testing.T) { 45 | cfg.Test.Prefer = map[string]cli.ConfigTestPrefer{ 46 | "/v0/orgs": { 47 | Example: "anky_personal", 48 | }, 49 | "/v0/orgs/ankydotdev/realms": { 50 | Example: "anky_personal", 51 | }, 52 | "/v0/orgs/ankydotdev/realms/localhost/x509/credentials": { 53 | Example: "anky_personal", 54 | }, 55 | } 56 | ctx = cli.ContextWithConfig(ctx, cfg) 57 | 58 | uitest.TestTUIOutput(ctx, t, new(Audit).UI()) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /lcl/clean.go: -------------------------------------------------------------------------------- 1 | package lcl 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/anchordotdev/cli" 7 | "github.com/anchordotdev/cli/api" 8 | "github.com/anchordotdev/cli/auth" 9 | "github.com/anchordotdev/cli/lcl/models" 10 | "github.com/anchordotdev/cli/trust" 11 | "github.com/anchordotdev/cli/ui" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var CmdLclClean = cli.NewCmd[LclClean](CmdLcl, "clean", func(cmd *cobra.Command) {}) 16 | 17 | type LclClean struct { 18 | anc *api.Session 19 | } 20 | 21 | func (c LclClean) UI() cli.UI { 22 | return cli.UI{ 23 | RunTUI: c.run, 24 | } 25 | } 26 | 27 | func (c LclClean) run(ctx context.Context, drv *ui.Driver) error { 28 | cfg := cli.ConfigFromContext(ctx) 29 | 30 | var err error 31 | clientCmd := &auth.Client{ 32 | Anc: c.anc, 33 | } 34 | c.anc, err = clientCmd.Perform(ctx, drv) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | cfg.Trust.Clean.States = []string{"all"} 40 | 41 | orgAPID, err := c.personalOrgAPID(ctx) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | realmAPID, err := c.localhostRealmAPID() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | drv.Activate(ctx, models.LclCleanHeader) 52 | drv.Activate(ctx, &models.LclCleanHint{ 53 | TrustStores: cfg.Trust.Stores, 54 | }) 55 | 56 | cmd := &trust.Clean{ 57 | Anc: c.anc, 58 | OrgAPID: orgAPID, 59 | RealmAPID: realmAPID, 60 | } 61 | 62 | err = cmd.Perform(ctx, drv) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (c LclClean) personalOrgAPID(ctx context.Context) (string, error) { 71 | userInfo, err := c.anc.UserInfo(ctx) 72 | if err != nil { 73 | return "", err 74 | } 75 | return userInfo.PersonalOrg.Slug, nil 76 | } 77 | 78 | func (c LclClean) localhostRealmAPID() (string, error) { 79 | return "localhost", nil 80 | } 81 | -------------------------------------------------------------------------------- /lcl/clean_test.go: -------------------------------------------------------------------------------- 1 | package lcl 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/cmdtest" 9 | "github.com/anchordotdev/cli/ui/uitest" 10 | ) 11 | 12 | func TestCmdLclClean(t *testing.T) { 13 | t.Run("--help", func(t *testing.T) { 14 | cmdtest.TestHelp(t, CmdLclClean, "lcl", "clean", "--help") 15 | }) 16 | } 17 | 18 | func TestClean(t *testing.T) { 19 | ctx, cancel := context.WithCancel(context.Background()) 20 | defer cancel() 21 | 22 | cfg := new(cli.Config) 23 | cfg.API.URL = srv.URL 24 | cfg.Trust.MockMode = true 25 | cfg.Trust.NoSudo = true 26 | cfg.Trust.Stores = []string{"mock"} 27 | var err error 28 | if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { 29 | t.Fatal(err) 30 | } 31 | ctx = cli.ContextWithConfig(ctx, cfg) 32 | 33 | t.Run("basics", func(t *testing.T) { 34 | if srv.IsProxy() { 35 | t.Skip("lcl clean unsupported in proxy mode") 36 | } 37 | 38 | ctx, cancel := context.WithCancel(ctx) 39 | defer cancel() 40 | 41 | cmd := LclClean{} 42 | 43 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /lcl/mkcert_test.go: -------------------------------------------------------------------------------- 1 | package lcl 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/cmdtest" 9 | "github.com/anchordotdev/cli/ui/uitest" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestCmdLclMkCert(t *testing.T) { 14 | t.Run("--help", func(t *testing.T) { 15 | cmdtest.TestHelp(t, CmdLclMkCert, "lcl", "mkcert", "--help") 16 | }) 17 | 18 | t.Run("--domains test.lcl.host,test.localhost", func(t *testing.T) { 19 | cfg := cmdtest.TestCfg(t, CmdLclMkCert, "--domains", "test.lcl.host,test.localhost") 20 | require.Equal(t, []string{"test.lcl.host", "test.localhost"}, cfg.Lcl.MkCert.Domains) 21 | }) 22 | 23 | t.Run("--org test-org", func(t *testing.T) { 24 | cfg := cmdtest.TestCfg(t, CmdLclMkCert, "--org", "test-org") 25 | require.Equal(t, "test-org", cfg.Org.APID) 26 | }) 27 | 28 | t.Run("--service test-service", func(t *testing.T) { 29 | cfg := cmdtest.TestCfg(t, CmdLclMkCert, "--service", "test-service") 30 | require.Equal(t, "test-service", cfg.Service.APID) 31 | }) 32 | 33 | t.Run("--realm test-realm", func(t *testing.T) { 34 | cfg := cmdtest.TestCfg(t, CmdLclMkCert, "--realm", "test-realm") 35 | require.Equal(t, "test-realm", cfg.Lcl.RealmAPID) 36 | }) 37 | } 38 | 39 | func TestLclMkcert(t *testing.T) { 40 | ctx, cancel := context.WithCancel(context.Background()) 41 | defer cancel() 42 | 43 | cfg := new(cli.Config) 44 | cfg.API.URL = srv.URL 45 | cfg.Dashboard.URL = "http://anchor.lcl.host:" + srv.RailsPort 46 | cfg.Service.APID = "hi-lcl-mkcert" 47 | cfg.Trust.MockMode = true 48 | cfg.Trust.NoSudo = true 49 | cfg.Trust.Stores = []string{"mock"} 50 | var err error 51 | if cfg.API.Token, err = srv.GeneratePAT("lcl_mkcert@anchor.dev"); err != nil { 52 | t.Fatal(err) 53 | } 54 | ctx = cli.ContextWithConfig(ctx, cfg) 55 | 56 | t.Run("basics", func(t *testing.T) { 57 | t.Skip("pending better support for building needed models before running") 58 | 59 | if !srv.IsProxy() { 60 | t.Skip("mkcert unsupported in proxy mode") 61 | } 62 | 63 | ctx, cancel := context.WithCancel(ctx) 64 | defer cancel() 65 | 66 | cmd := MkCert{ 67 | Domains: []string{"hi-lcl-mkcert.lcl.host", "hi-lcl-mkcert.localhost"}, 68 | } 69 | 70 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /lcl/models/audit.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchordotdev/cli/ui" 7 | ) 8 | 9 | type AuditUnauthenticated bool 10 | 11 | var ( 12 | AuditHeader = ui.Section{ 13 | Name: "AuditHeader", 14 | Model: ui.MessageLines{ 15 | ui.Header(fmt.Sprintf("Audit lcl.host HTTPS Local Development Environment %s", ui.Whisper("`anchor lcl audit`"))), 16 | }, 17 | } 18 | 19 | AuditHint = ui.Section{ 20 | Name: "AuditHint", 21 | Model: ui.MessageLines{ 22 | ui.StepHint("We'll compare your local development CA certificates from Anchor and your local trust stores."), 23 | }, 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /lcl/models/clean.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | var LclCleanHeader = ui.Section{ 12 | Name: "LclCleanHeader", 13 | Model: ui.MessageLines{ 14 | ui.Header(fmt.Sprintf("Clean lcl.host CA Certificates from Local Trust Store(s) %s", ui.Whisper("`anchor trust clean`"))), 15 | }, 16 | } 17 | 18 | type LclCleanHint struct { 19 | TrustStores []string 20 | } 21 | 22 | func (c *LclCleanHint) Init() tea.Cmd { return nil } 23 | 24 | func (c *LclCleanHint) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil } 25 | 26 | func (c *LclCleanHint) View() string { 27 | stores := strings.Join(c.TrustStores, ", ") 28 | 29 | var b strings.Builder 30 | fmt.Fprintln(&b, ui.Hint(fmt.Sprintf("We'll remove lcl.host CA certificates from the %s store(s).", stores))) 31 | 32 | return b.String() 33 | } 34 | -------------------------------------------------------------------------------- /lcl/testdata/TestAudit/basics.golden: -------------------------------------------------------------------------------- 1 | ─── Client ───────────────────────────────────────────────────────────────────── 2 | * Checking authentication: probing credentials locally…⠋ 3 | ─── Client ───────────────────────────────────────────────────────────────────── 4 | * Checking authentication: testing credentials remotely…⠋ 5 | ─── AuditHeader ──────────────────────────────────────────────────────────────── 6 | 7 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 8 | ─── AuditHint ────────────────────────────────────────────────────────────────── 9 | 10 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 11 | | We'll compare your local development CA certificates from Anchor and your local trust stores. 12 | ─── TrustStoreAudit ──────────────────────────────────────────────────────────── 13 | 14 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 15 | | We'll compare your local development CA certificates from Anchor and your local trust stores. 16 | * Comparing local and expected CA certificates…⠋ 17 | ─── TrustStoreAudit ──────────────────────────────────────────────────────────── 18 | 19 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 20 | | We'll compare your local development CA certificates from Anchor and your local trust stores. 21 | - Compared local and expected CA certificates: need to install 2 missing certificates. 22 | -------------------------------------------------------------------------------- /lcl/testdata/TestAudit/missing-localhost-ca.golden: -------------------------------------------------------------------------------- 1 | ─── Client ───────────────────────────────────────────────────────────────────── 2 | * Checking authentication: probing credentials locally…⠋ 3 | ─── Client ───────────────────────────────────────────────────────────────────── 4 | * Checking authentication: testing credentials remotely…⠋ 5 | ─── AuditHeader ──────────────────────────────────────────────────────────────── 6 | 7 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 8 | ─── AuditHint ────────────────────────────────────────────────────────────────── 9 | 10 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 11 | | We'll compare your local development CA certificates from Anchor and your local trust stores. 12 | ─── TrustStoreAudit ──────────────────────────────────────────────────────────── 13 | 14 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 15 | | We'll compare your local development CA certificates from Anchor and your local trust stores. 16 | * Comparing local and expected CA certificates…⠋ 17 | ─── TrustStoreAudit ──────────────────────────────────────────────────────────── 18 | 19 | # Audit lcl.host HTTPS Local Development Environment `anchor lcl audit` 20 | | We'll compare your local development CA certificates from Anchor and your local trust stores. 21 | - Compared local and expected CA certificates: need to install 2 missing certificates. 22 | -------------------------------------------------------------------------------- /lcl/testdata/TestClean/basics.golden: -------------------------------------------------------------------------------- 1 | ─── Client ───────────────────────────────────────────────────────────────────── 2 | * Checking authentication: probing credentials locally…⠋ 3 | ─── Client ───────────────────────────────────────────────────────────────────── 4 | * Checking authentication: testing credentials remotely…⠋ 5 | ─── LclCleanHeader ───────────────────────────────────────────────────────────── 6 | 7 | # Clean lcl.host CA Certificates from Local Trust Store(s) `anchor trust clean` 8 | ─── LclCleanHint ─────────────────────────────────────────────────────────────── 9 | 10 | # Clean lcl.host CA Certificates from Local Trust Store(s) `anchor trust clean` 11 | | We'll remove lcl.host CA certificates from the mock store(s). 12 | ─── TrustCleanAudit ──────────────────────────────────────────────────────────── 13 | 14 | # Clean lcl.host CA Certificates from Local Trust Store(s) `anchor trust clean` 15 | | We'll remove lcl.host CA certificates from the mock store(s). 16 | * Auditing local CA certificates…⠋ 17 | ─── TrustCleanAudit ──────────────────────────────────────────────────────────── 18 | 19 | # Clean lcl.host CA Certificates from Local Trust Store(s) `anchor trust clean` 20 | | We'll remove lcl.host CA certificates from the mock store(s). 21 | - Audited local CA certificates: need to remove 0 certificates. 22 | -------------------------------------------------------------------------------- /lcl/testdata/TestCmdBootstrap/--help.golden: -------------------------------------------------------------------------------- 1 | Initial System Configuration for lcl.host Local Development 2 | 3 | Usage: 4 | anchor lcl bootstrap [flags] 5 | 6 | Aliases: 7 | bootstrap, config 8 | 9 | Flags: 10 | -a, --addr string Address for local diagnostic web server. (default ":4433") 11 | -h, --help help for bootstrap 12 | 13 | Global Flags: 14 | --api-token string Anchor API personal access token (PAT). 15 | --config string Service configuration file. (default "anchor.toml") 16 | --skip-config Skip loading configuration file. 17 | -------------------------------------------------------------------------------- /lcl/testdata/TestCmdLcl/--help.golden: -------------------------------------------------------------------------------- 1 | Manage lcl.host Local Development Environment 2 | 3 | Usage: 4 | anchor lcl [flags] 5 | anchor lcl [command] 6 | 7 | Available Commands: 8 | audit Audit lcl.host HTTPS Local Development Environment 9 | bootstrap Initial System Configuration for lcl.host Local Development 10 | clean Clean lcl.host CA Certificates from the Local Trust Store(s) 11 | mkcert Provision Certificate for lcl.host Local Development 12 | setup Setup lcl.host Application 13 | trust Install CA Certificates for lcl.host Local Development 14 | 15 | Flags: 16 | -a, --addr string Address for local diagnostic web server. (default ":4433") 17 | --category string Language or software type of the service. 18 | --cert-style string Provisioning method for lcl.host certificates. 19 | --domains strings Domains to create certificate for. 20 | -h, --help help for lcl 21 | -o, --org string Organization for lcl.host local development environment management. 22 | --org-name string Name for created org. 23 | -r, --realm string Realm for lcl.host local development environment management. 24 | -s, --service string Service for lcl.host local development environment management. 25 | --subca string SubCA to create certificate for. 26 | 27 | Global Flags: 28 | --api-token string Anchor API personal access token (PAT). 29 | --config string Service configuration file. (default "anchor.toml") 30 | --skip-config Skip loading configuration file. 31 | 32 | Use "anchor lcl [command] --help" for more information about a command. 33 | -------------------------------------------------------------------------------- /lcl/testdata/TestCmdLclAudit/--help.golden: -------------------------------------------------------------------------------- 1 | Audit lcl.host HTTPS Local Development Environment 2 | 3 | Usage: 4 | anchor lcl audit [flags] 5 | 6 | Flags: 7 | -h, --help help for audit 8 | 9 | Global Flags: 10 | --api-token string Anchor API personal access token (PAT). 11 | --config string Service configuration file. (default "anchor.toml") 12 | --skip-config Skip loading configuration file. 13 | -------------------------------------------------------------------------------- /lcl/testdata/TestCmdLclClean/--help.golden: -------------------------------------------------------------------------------- 1 | Clean lcl.host CA Certificates from the Local Trust Store(s) 2 | 3 | Usage: 4 | anchor lcl clean [flags] 5 | 6 | Flags: 7 | -h, --help help for clean 8 | 9 | Global Flags: 10 | --api-token string Anchor API personal access token (PAT). 11 | --config string Service configuration file. (default "anchor.toml") 12 | --skip-config Skip loading configuration file. 13 | -------------------------------------------------------------------------------- /lcl/testdata/TestCmdLclMkCert/--help.golden: -------------------------------------------------------------------------------- 1 | Provision Certificate for lcl.host Local Development 2 | 3 | Usage: 4 | anchor lcl mkcert [flags] 5 | 6 | Flags: 7 | --domains strings Domains to create certificate for. 8 | -h, --help help for mkcert 9 | -o, --org string Organization to create certificate for. 10 | -r, --realm string Realm to create certificate for. 11 | -s, --service string Service to create certificate for. 12 | 13 | Global Flags: 14 | --api-token string Anchor API personal access token (PAT). 15 | --config string Service configuration file. (default "anchor.toml") 16 | --skip-config Skip loading configuration file. 17 | -------------------------------------------------------------------------------- /lcl/testdata/TestCmdLclSetup/--help.golden: -------------------------------------------------------------------------------- 1 | Setup lcl.host Application 2 | 3 | Usage: 4 | anchor lcl setup [flags] 5 | 6 | Flags: 7 | --category string Language or software type of the service. 8 | --cert-style string Provisioning method for lcl.host certificates. 9 | -h, --help help for setup 10 | -o, --org string Organization for lcl.host application setup. 11 | --org-name string Name for created org. 12 | -r, --realm string Realm for lcl.host application setup. 13 | -s, --service string Service for lcl.host application setup. 14 | 15 | Global Flags: 16 | --api-token string Anchor API personal access token (PAT). 17 | --config string Service configuration file. (default "anchor.toml") 18 | --skip-config Skip loading configuration file. 19 | -------------------------------------------------------------------------------- /lcl/testdata/TestCmdLclTrust/--help.golden: -------------------------------------------------------------------------------- 1 | Install CA Certificates for lcl.host Local Development 2 | 3 | Usage: 4 | anchor lcl trust [flags] 5 | 6 | Flags: 7 | -h, --help help for trust 8 | --no-sudo Disable sudo prompts. 9 | --trust-stores strings Trust stores to update. (default [homebrew,nss,system]) 10 | 11 | Global Flags: 12 | --api-token string Anchor API personal access token (PAT). 13 | --config string Service configuration file. (default "anchor.toml") 14 | --skip-config Skip loading configuration file. 15 | -------------------------------------------------------------------------------- /lcl/trust.go: -------------------------------------------------------------------------------- 1 | package lcl 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/anchordotdev/cli" 9 | "github.com/anchordotdev/cli/api" 10 | "github.com/anchordotdev/cli/auth" 11 | "github.com/anchordotdev/cli/lcl/models" 12 | "github.com/anchordotdev/cli/trust" 13 | "github.com/anchordotdev/cli/truststore" 14 | truststoreModels "github.com/anchordotdev/cli/truststore/models" 15 | "github.com/anchordotdev/cli/ui" 16 | ) 17 | 18 | var CmdLclTrust = cli.NewCmd[Trust](CmdLcl, "trust", func(cmd *cobra.Command) { 19 | cfg := cli.ConfigFromCmd(cmd) 20 | 21 | cmd.Flags().BoolVar(&cfg.Trust.NoSudo, "no-sudo", false, "Disable sudo prompts.") 22 | cmd.Flags().StringSliceVar(&cfg.Trust.Stores, "trust-stores", []string{"homebrew", "nss", "system"}, "Trust stores to update.") 23 | }) 24 | 25 | type Trust struct { 26 | anc *api.Session 27 | 28 | auditInfo *truststore.AuditInfo 29 | } 30 | 31 | func (c Trust) UI() cli.UI { 32 | return cli.UI{ 33 | RunTUI: c.run, 34 | } 35 | } 36 | 37 | func (c *Trust) run(ctx context.Context, drv *ui.Driver) error { 38 | // TODO: convert flags to fields on c 39 | 40 | var err error 41 | if c.anc, err = new(auth.Client).Perform(ctx, drv); err != nil { 42 | return err 43 | } 44 | 45 | drv.Activate(ctx, models.TrustHeader) 46 | drv.Activate(ctx, models.TrustHint) 47 | 48 | return c.perform(ctx, drv) 49 | } 50 | 51 | func (c *Trust) perform(ctx context.Context, drv *ui.Driver) error { 52 | if c.auditInfo == nil { 53 | var err error 54 | if c.auditInfo, err = c.performAudit(ctx, drv); err != nil { 55 | return err 56 | } 57 | } 58 | 59 | cmdTrust := &trust.Command{ 60 | Anc: c.anc, 61 | AuditInfo: c.auditInfo, 62 | } 63 | 64 | return cmdTrust.Perform(ctx, drv) 65 | } 66 | 67 | func (c *Trust) performAudit(ctx context.Context, drv *ui.Driver) (*truststore.AuditInfo, error) { 68 | drv.Activate(ctx, &truststoreModels.TrustStoreAudit{}) 69 | 70 | stores, err := trust.LoadStores(ctx, drv) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | cas, err := trust.FetchLocalDevCAs(ctx, c.anc) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | auditInfo, err := trust.PerformAudit(ctx, stores, cas) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | drv.Send(truststoreModels.AuditInfoMsg(auditInfo)) 86 | 87 | return auditInfo, nil 88 | } 89 | -------------------------------------------------------------------------------- /lcl/trust_test.go: -------------------------------------------------------------------------------- 1 | package lcl 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/anchordotdev/cli" 10 | "github.com/anchordotdev/cli/cmdtest" 11 | "github.com/anchordotdev/cli/truststore" 12 | "github.com/anchordotdev/cli/ui/uitest" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/x/exp/teatest" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestCmdLclTrust(t *testing.T) { 19 | t.Run("--help", func(t *testing.T) { 20 | cmdtest.TestHelp(t, CmdLclTrust, "lcl", "trust", "--help") 21 | }) 22 | 23 | t.Run("default --trust-stores", func(t *testing.T) { 24 | cfg := cmdtest.TestCfg(t, CmdLclTrust) 25 | require.Equal(t, []string{"homebrew", "nss", "system"}, cfg.Trust.Stores) 26 | }) 27 | 28 | t.Run("--trust-stores nss,system", func(t *testing.T) { 29 | cfg := cmdtest.TestCfg(t, CmdLclTrust, "--trust-stores", "nss,system") 30 | require.Equal(t, []string{"nss", "system"}, cfg.Trust.Stores) 31 | }) 32 | } 33 | 34 | func TestTrust(t *testing.T) { 35 | if srv.IsProxy() { 36 | t.Skip("trust skipped in proxy mode to avoid golden conflicts") 37 | } 38 | 39 | ctx, cancel := context.WithCancel(context.Background()) 40 | defer cancel() 41 | 42 | cfg := new(cli.Config) 43 | cfg.API.URL = srv.URL 44 | cfg.Test.Prefer = map[string]cli.ConfigTestPrefer{ 45 | "/v0/orgs/org-slug/realms": { 46 | Example: "development", 47 | }, 48 | } 49 | cfg.Trust.Stores = []string{"mock"} 50 | var err error 51 | if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { 52 | t.Fatal(err) 53 | } 54 | ctx = cli.ContextWithConfig(ctx, cfg) 55 | 56 | truststore.ResetMockCAs() 57 | t.Cleanup(truststore.ResetMockCAs) 58 | 59 | t.Run(fmt.Sprintf("basics-%s", uitest.TestTagOS()), func(t *testing.T) { 60 | ctx, cancel := context.WithCancel(ctx) 61 | defer cancel() 62 | 63 | drv, tm := uitest.TestTUI(ctx, t) 64 | 65 | cmd := Trust{} 66 | 67 | errc := make(chan error, 1) 68 | go func() { 69 | defer close(errc) 70 | 71 | if err := cmd.UI().RunTUI(ctx, drv); err != nil { 72 | errc <- err 73 | return 74 | } 75 | errc <- tm.Quit() 76 | }() 77 | 78 | uitest.WaitForGoldenContains(t, drv, errc, 79 | "! Press Enter to install missing certificates. (requires sudo)", 80 | ) 81 | 82 | tm.Send(tea.KeyMsg{ 83 | Type: tea.KeyEnter, 84 | }) 85 | 86 | tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3)) 87 | uitest.TestGolden(t, drv.Golden()) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /models/cli.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type ReportError struct { 13 | ConfirmCh chan<- struct{} 14 | 15 | Cmd *cobra.Command 16 | Args []string 17 | Msg any 18 | } 19 | 20 | func (m *ReportError) Init() tea.Cmd { return nil } 21 | 22 | func (m *ReportError) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 23 | switch msg := msg.(type) { 24 | case tea.KeyMsg: 25 | switch msg.Type { 26 | case tea.KeyEnter: 27 | if m.ConfirmCh != nil { 28 | close(m.ConfirmCh) 29 | m.ConfirmCh = nil 30 | } 31 | } 32 | } 33 | 34 | return m, nil 35 | } 36 | 37 | func (m *ReportError) View() string { 38 | var b strings.Builder 39 | 40 | fmt.Fprintln(&b, ui.Header(fmt.Sprintf("%s %s %s", 41 | ui.Danger("Error!"), 42 | m.Msg, 43 | ui.Whisper(fmt.Sprintf("`%s`", m.Cmd.CalledAs())), 44 | ))) 45 | 46 | fmt.Fprintln(&b, ui.StepHint("We are sorry you encountered this error.")) 47 | 48 | if m.ConfirmCh != nil { 49 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s to open an issue on Github.", 50 | ui.Action("Press Enter"), 51 | ))) 52 | } else { 53 | fmt.Fprintln(&b, ui.StepDone("Opened an issue on Github.")) 54 | } 55 | 56 | return b.String() 57 | } 58 | 59 | type Browserless struct { 60 | Url string 61 | } 62 | 63 | func (m *Browserless) Init() tea.Cmd { return nil } 64 | 65 | func (m *Browserless) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 66 | 67 | func (m *Browserless) View() string { 68 | var b strings.Builder 69 | 70 | fmt.Fprintln(&b, ui.Warning("Unable to open browser.")) 71 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s this in a browser to continue: %s.", 72 | ui.Action("Open"), 73 | ui.URL(m.Url), 74 | ))) 75 | 76 | return b.String() 77 | } 78 | -------------------------------------------------------------------------------- /org/create.go: -------------------------------------------------------------------------------- 1 | package org 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/anchordotdev/cli" 7 | "github.com/anchordotdev/cli/api" 8 | "github.com/anchordotdev/cli/auth" 9 | "github.com/anchordotdev/cli/org/models" 10 | "github.com/anchordotdev/cli/ui" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var CmdOrgCreate = cli.NewCmd[Create](CmdOrg, "create", func(cmd *cobra.Command) { 15 | cfg := cli.ConfigFromCmd(cmd) 16 | 17 | cmd.Flags().StringVar(&cfg.Org.Name, "org-name", "", "Name for created org.") 18 | }) 19 | 20 | type Create struct { 21 | Anc *api.Session 22 | 23 | OrgName string 24 | } 25 | 26 | func (c Create) UI() cli.UI { 27 | return cli.UI{ 28 | RunTUI: c.run, 29 | } 30 | } 31 | 32 | func (c *Create) run(ctx context.Context, drv *ui.Driver) error { 33 | var err error 34 | clientCmd := &auth.Client{ 35 | Anc: c.Anc, 36 | } 37 | c.Anc, err = clientCmd.Perform(ctx, drv) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | drv.Activate(ctx, &models.OrgCreateHeader{}) 43 | drv.Activate(ctx, &models.OrgCreateHint{}) 44 | 45 | _, err = c.Perform(ctx, drv) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func (c *Create) Perform(ctx context.Context, drv *ui.Driver) (*api.Organization, error) { 54 | cfg := cli.ConfigFromContext(ctx) 55 | 56 | var err error 57 | c.OrgName, err = c.orgName(ctx, cfg, drv) 58 | if err != nil { 59 | return nil, err 60 | } 61 | drv.Activate(ctx, &models.CreateOrgSpinner{}) 62 | 63 | org, err := c.Anc.CreateOrg(ctx, c.OrgName) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | drv.Send(ui.HideModelsMsg{ 69 | Models: []string{"CreateOrgNameInput", "CreateOrgSpinner"}, 70 | }) 71 | 72 | drv.Activate(ctx, &models.CreateOrgResult{ 73 | Org: *org, 74 | }) 75 | 76 | return org, nil 77 | } 78 | 79 | func (c *Create) orgName(ctx context.Context, cfg *cli.Config, drv *ui.Driver) (string, error) { 80 | if c.OrgName != "" { 81 | return c.OrgName, nil 82 | } 83 | if cfg.Org.Name != "" { 84 | c.OrgName = cfg.Org.Name 85 | return c.OrgName, nil 86 | } 87 | 88 | if c.OrgName == "" { 89 | inputc := make(chan string) 90 | drv.Activate(ctx, &models.CreateOrgNameInput{ 91 | InputCh: inputc, 92 | }) 93 | 94 | select { 95 | case orgName := <-inputc: 96 | c.OrgName = orgName 97 | return c.OrgName, nil 98 | case <-ctx.Done(): 99 | return "", ctx.Err() 100 | } 101 | } 102 | 103 | return "", nil 104 | } 105 | -------------------------------------------------------------------------------- /org/create_test.go: -------------------------------------------------------------------------------- 1 | package org 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/anchordotdev/cli" 12 | "github.com/anchordotdev/cli/cmdtest" 13 | "github.com/anchordotdev/cli/ui/uitest" 14 | "github.com/charmbracelet/x/exp/teatest" 15 | ) 16 | 17 | func TestCmdOrg(t *testing.T) { 18 | t.Run("--help", func(t *testing.T) { 19 | cmdtest.TestHelp(t, CmdOrgCreate, "org", "create", "--help") 20 | }) 21 | 22 | t.Run("--org-name org", func(t *testing.T) { 23 | cfg := cmdtest.TestCfg(t, CmdOrgCreate, "--org-name", "org") 24 | require.Equal(t, "org", cfg.Org.Name) 25 | }) 26 | } 27 | 28 | func TestCreateOrg(t *testing.T) { 29 | ctx, cancel := context.WithCancel(context.Background()) 30 | defer cancel() 31 | 32 | cfg := new(cli.Config) 33 | cfg.API.URL = srv.URL 34 | var err error 35 | if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { 36 | t.Fatal(err) 37 | } 38 | ctx = cli.ContextWithConfig(ctx, cfg) 39 | 40 | t.Run("basics", func(t *testing.T) { 41 | if srv.IsProxy() { 42 | t.Skip("org create unsupported in proxy mode") 43 | } 44 | 45 | ctx, cancel := context.WithCancel(ctx) 46 | defer cancel() 47 | 48 | drv, tm := uitest.TestTUI(ctx, t) 49 | 50 | cmd := Create{} 51 | 52 | errc := make(chan error, 1) 53 | go func() { 54 | errc <- cmd.UI().RunTUI(ctx, drv) 55 | errc <- tm.Quit() 56 | }() 57 | 58 | uitest.WaitForGoldenContains(t, drv, errc, 59 | "? What is the new organization's name?", 60 | ) 61 | tm.Send(tea.KeyMsg{ 62 | Runes: []rune("Org Name"), 63 | Type: tea.KeyRunes, 64 | }) 65 | tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 66 | 67 | tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3)) 68 | uitest.TestGolden(t, drv.Golden()) 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /org/org.go: -------------------------------------------------------------------------------- 1 | package org 2 | 3 | import ( 4 | "github.com/anchordotdev/cli" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var CmdOrg = cli.NewCmd[cli.ShowHelp](cli.CmdRoot, "org", func(cmd *cobra.Command) { 9 | }) 10 | -------------------------------------------------------------------------------- /org/org_test.go: -------------------------------------------------------------------------------- 1 | package org 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/anchordotdev/cli/api/apitest" 9 | _ "github.com/anchordotdev/cli/testflags" 10 | ) 11 | 12 | var srv = &apitest.Server{ 13 | Host: "api.anchor.lcl.host", 14 | RootDir: "../..", 15 | } 16 | 17 | func TestMain(m *testing.M) { 18 | if err := srv.Start(context.Background()); err != nil { 19 | panic(err) 20 | } 21 | 22 | defer os.Exit(m.Run()) 23 | 24 | srv.Close() 25 | } 26 | -------------------------------------------------------------------------------- /org/testdata/TestCmdOrg/--help.golden: -------------------------------------------------------------------------------- 1 | Create New Organization 2 | 3 | Usage: 4 | anchor org create [flags] 5 | 6 | Flags: 7 | -h, --help help for create 8 | --org-name string Name for created org. 9 | 10 | Global Flags: 11 | --api-token string Anchor API personal access token (PAT). 12 | --config string Service configuration file. (default "anchor.toml") 13 | --skip-config Skip loading configuration file. 14 | -------------------------------------------------------------------------------- /org/testdata/TestCreateOrg/basics.golden: -------------------------------------------------------------------------------- 1 | ─── Client ───────────────────────────────────────────────────────────────────── 2 | * Checking authentication: probing credentials locally…⠋ 3 | ─── Client ───────────────────────────────────────────────────────────────────── 4 | * Checking authentication: testing credentials remotely…⠋ 5 | ─── OrgCreateHeader ──────────────────────────────────────────────────────────── 6 | 7 | # Create New Organization `anchor org create` 8 | ─── OrgCreateHint ────────────────────────────────────────────────────────────── 9 | 10 | # Create New Organization `anchor org create` 11 | | We'll create a new organization to facilitate collaboration. 12 | ─── CreateOrgNameInput ───────────────────────────────────────────────────────── 13 | 14 | # Create New Organization `anchor org create` 15 | | We'll create a new organization to facilitate collaboration. 16 | ? What is the new organization's name? 17 | ? 18 | ─── CreateOrgNameInput ───────────────────────────────────────────────────────── 19 | 20 | # Create New Organization `anchor org create` 21 | | We'll create a new organization to facilitate collaboration. 22 | ? What is the new organization's name? 23 | ? Org Name 24 | ─── CreateOrgNameInput ───────────────────────────────────────────────────────── 25 | 26 | # Create New Organization `anchor org create` 27 | | We'll create a new organization to facilitate collaboration. 28 | - Entered Org Name organization name. 29 | ─── CreateOrgSpinner ─────────────────────────────────────────────────────────── 30 | 31 | # Create New Organization `anchor org create` 32 | | We'll create a new organization to facilitate collaboration. 33 | - Entered Org Name organization name. 34 | * Creating new organization…⠋ 35 | ─── CreateOrgSpinner ─────────────────────────────────────────────────────────── 36 | 37 | # Create New Organization `anchor org create` 38 | | We'll create a new organization to facilitate collaboration. 39 | ─── CreateOrgResult ──────────────────────────────────────────────────────────── 40 | 41 | # Create New Organization `anchor org create` 42 | | We'll create a new organization to facilitate collaboration. 43 | - Created Org Name (org-slug) organization. 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anchor", 3 | "private": true, 4 | "dependencies": {}, 5 | "scripts": {}, 6 | "devDependencies": { 7 | "@stoplight/prism-cli": "5.12.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /root.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var CmdRoot = NewCmd[ShowHelp](nil, "anchor", func(cmd *cobra.Command) { 8 | cfg := ConfigFromCmd(cmd) 9 | 10 | cmd.PersistentFlags().StringVar(&cfg.API.Token, "api-token", Defaults.API.Token, "Anchor API personal access token (PAT).") 11 | cmd.PersistentFlags().StringVar(&cfg.API.URL, "api-url", Defaults.API.URL, "Anchor API endpoint URL.") 12 | cmd.PersistentFlags().StringVar(&cfg.File.Path, "config", Defaults.File.Path, "Service configuration file.") 13 | cmd.PersistentFlags().StringVar(&cfg.Dashboard.URL, "dashboard-url", Defaults.Dashboard.URL, "Anchor dashboard URL.") 14 | cmd.PersistentFlags().BoolVar(&cfg.File.Skip, "skip-config", Defaults.File.Skip, "Skip loading configuration file.") 15 | 16 | if err := cmd.PersistentFlags().MarkHidden("api-url"); err != nil { 17 | panic(err) 18 | } 19 | if err := cmd.PersistentFlags().MarkHidden("dashboard-url"); err != nil { 20 | panic(err) 21 | } 22 | }) 23 | 24 | // ShowHelp calls cmd.HelpFunc() inside RunE instead of RunTUI 25 | 26 | type ShowHelp struct{} 27 | 28 | func (c ShowHelp) UI() UI { 29 | return UI{} 30 | } 31 | -------------------------------------------------------------------------------- /root_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/anchordotdev/cli" 8 | _ "github.com/anchordotdev/cli/auth" 9 | "github.com/anchordotdev/cli/cmdtest" 10 | _ "github.com/anchordotdev/cli/lcl" 11 | _ "github.com/anchordotdev/cli/service" 12 | _ "github.com/anchordotdev/cli/testflags" 13 | _ "github.com/anchordotdev/cli/trust" 14 | _ "github.com/anchordotdev/cli/version" 15 | ) 16 | 17 | func TestCmdRoot(t *testing.T) { 18 | t.Run("root", func(t *testing.T) { 19 | cmdtest.TestHelp(t, cli.CmdRoot) 20 | }) 21 | 22 | t.Run("--help", func(t *testing.T) { 23 | cmdtest.TestHelp(t, cli.CmdRoot, "--help") 24 | }) 25 | 26 | // config 27 | 28 | tests := []struct { 29 | name string 30 | 31 | argv []string 32 | env map[string]string 33 | 34 | want any 35 | get func(*cli.Config) any 36 | }{ 37 | // dashboard url 38 | { 39 | name: "default-dashboard-url", 40 | 41 | want: "https://anchor.dev", 42 | get: func(cli *cli.Config) any { return cli.Dashboard.URL }, 43 | }, 44 | { 45 | name: "--dashboard-url", 46 | 47 | argv: []string{"--dashboard-url", "https://anchor.example.com"}, 48 | 49 | want: "https://anchor.example.com", 50 | get: func(cli *cli.Config) any { return cli.Dashboard.URL }, 51 | }, 52 | { 53 | name: "ANCHOR_HOST", 54 | 55 | env: map[string]string{"ANCHOR_HOST": "https://anchor.example.com"}, 56 | 57 | want: "https://anchor.example.com", 58 | get: func(cli *cli.Config) any { return cli.Dashboard.URL }, 59 | }, 60 | 61 | // api url 62 | { 63 | name: "default-api-url", 64 | 65 | want: "https://api.anchor.dev/v0", 66 | get: func(cli *cli.Config) any { return cli.API.URL }, 67 | }, 68 | { 69 | name: "--api-url", 70 | 71 | argv: []string{"--api-url", "https://api.anchor.example.com"}, 72 | 73 | want: "https://api.anchor.example.com", 74 | get: func(cli *cli.Config) any { return cli.API.URL }, 75 | }, 76 | { 77 | name: "API_URL", 78 | 79 | env: map[string]string{"API_URL": "https://api.anchor.example.com"}, 80 | 81 | want: "https://api.anchor.example.com", 82 | get: func(cli *cli.Config) any { return cli.API.URL }, 83 | }, 84 | 85 | // api token 86 | { 87 | name: "default-api-token", 88 | 89 | want: "", 90 | get: func(cli *cli.Config) any { return cli.API.Token }, 91 | }, 92 | { 93 | name: "--api-token", 94 | 95 | argv: []string{"--api-token", "f00f"}, 96 | 97 | want: "f00f", 98 | get: func(cli *cli.Config) any { return cli.API.Token }, 99 | }, 100 | { 101 | name: "API_TOKEN", 102 | 103 | env: map[string]string{"API_TOKEN": "f00f00f"}, 104 | 105 | want: "f00f00f", 106 | get: func(cli *cli.Config) any { return cli.API.Token }, 107 | }, 108 | } 109 | 110 | for _, test := range tests { 111 | t.Run(test.name, func(t *testing.T) { 112 | for k, v := range test.env { 113 | t.Setenv(k, v) 114 | } 115 | 116 | cfg := cmdtest.TestCfg(t, cli.CmdRoot, test.argv...) 117 | 118 | if want, got := test.want, test.get(cfg); !reflect.DeepEqual(want, got) { 119 | t.Errorf("want dashboard host %s, got %s", want, got) 120 | } 121 | }) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /service/models/verify.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | var ( 13 | VerifyHeader = ui.Section{ 14 | Name: "VerifyHeader", 15 | Model: ui.MessageLines{ 16 | ui.Header(fmt.Sprintf("Verify Service TLS Setup and Configuration %s", ui.Whisper("`anchor service verify`"))), 17 | }, 18 | } 19 | 20 | VerifyHint = ui.Section{ 21 | Name: "VerifyHint", 22 | Model: ui.MessageLines{ 23 | ui.StepHint("We'll check your running app to ensure TLS works as expected."), 24 | }, 25 | } 26 | ) 27 | 28 | type Checker struct { 29 | Name string 30 | 31 | err error 32 | finished bool 33 | 34 | spinner spinner.Model 35 | } 36 | 37 | func (m *Checker) Init() tea.Cmd { 38 | m.spinner = ui.WaitingSpinner() 39 | 40 | return m.spinner.Tick 41 | } 42 | 43 | type checkerMsg struct { 44 | mdl *Checker 45 | err error 46 | } 47 | 48 | func (m *Checker) Pass() tea.Msg { 49 | return checkerMsg{ 50 | mdl: m, 51 | err: nil, 52 | } 53 | } 54 | 55 | func (m *Checker) Fail(err error) tea.Msg { 56 | return checkerMsg{ 57 | mdl: m, 58 | err: err, 59 | } 60 | } 61 | 62 | func (m *Checker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 63 | switch msg := msg.(type) { 64 | case checkerMsg: 65 | if msg.mdl == m { 66 | m.finished = true 67 | m.err = msg.err 68 | } 69 | return m, nil 70 | default: 71 | var cmd tea.Cmd 72 | m.spinner, cmd = m.spinner.Update(msg) 73 | return m, cmd 74 | } 75 | } 76 | 77 | func (m *Checker) View() string { 78 | var b strings.Builder 79 | switch { 80 | case !m.finished: 81 | fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Checking %s…%s", 82 | m.Name, 83 | m.spinner.View()))) 84 | case m.err == nil: 85 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Checked %s: success!", 86 | m.Name))) 87 | default: 88 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Checked %s: failed!", 89 | m.Name))) 90 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Error! %s", 91 | m.err.Error()))) 92 | } 93 | return b.String() 94 | } 95 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/anchordotdev/cli" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var CmdService = cli.NewCmd[cli.ShowHelp](cli.CmdRoot, "service", func(cmd *cobra.Command) { 9 | }) 10 | -------------------------------------------------------------------------------- /service/service_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/anchordotdev/cli/api/apitest" 9 | _ "github.com/anchordotdev/cli/testflags" 10 | ) 11 | 12 | var srv = &apitest.Server{ 13 | Host: "api.anchor.lcl.host", 14 | RootDir: "../..", 15 | } 16 | 17 | func TestMain(m *testing.M) { 18 | if err := srv.Start(context.Background()); err != nil { 19 | panic(err) 20 | } 21 | 22 | defer os.Exit(m.Run()) 23 | 24 | srv.Close() 25 | } 26 | -------------------------------------------------------------------------------- /service/testdata/TestCmdServiceEnv/--help.golden: -------------------------------------------------------------------------------- 1 | Fetch Environment Variables for Service 2 | 3 | Usage: 4 | anchor service env [flags] 5 | 6 | Flags: 7 | --env-output string Integration method for environment variables. 8 | -h, --help help for env 9 | -o, --org string Organization to trust. 10 | -r, --realm string Realm to trust. 11 | -s, --service string Service for ENV. 12 | 13 | Global Flags: 14 | --api-token string Anchor API personal access token (PAT). 15 | --config string Service configuration file. (default "anchor.toml") 16 | --skip-config Skip loading configuration file. 17 | -------------------------------------------------------------------------------- /service/testdata/TestCmdServiceVerify/--help.golden: -------------------------------------------------------------------------------- 1 | Verify Service TLS Setup and Configuration 2 | 3 | Usage: 4 | anchor service verify [flags] 5 | 6 | Flags: 7 | -h, --help help for verify 8 | -o, --org string Organization of the service to verify. 9 | -r, --realm string Realm instance of the service to verify. 10 | -s, --service string Service to verify. 11 | --timeout duration Time to wait for a successful verification of the service. (default 2m0s) 12 | 13 | Global Flags: 14 | --api-token string Anchor API personal access token (PAT). 15 | --config string Service configuration file. (default "anchor.toml") 16 | --skip-config Skip loading configuration file. 17 | -------------------------------------------------------------------------------- /stacktrace/stacktrace.go: -------------------------------------------------------------------------------- 1 | package stacktrace 2 | 3 | import ( 4 | "fmt" 5 | "go/build" 6 | "os" 7 | "regexp" 8 | "runtime/debug" 9 | "strings" 10 | ) 11 | 12 | var defaultCleaner *cleaner 13 | 14 | func init() { 15 | defaultCleaner = &cleaner{ 16 | GOPATH: build.Default.GOPATH, 17 | GOROOT: build.Default.GOROOT, 18 | } 19 | 20 | if pwd, _ := os.Getwd(); pwd != "" { 21 | defaultCleaner.PWD = pwd 22 | } 23 | if home, _ := os.UserHomeDir(); home != "" { 24 | defaultCleaner.HOME = home 25 | } 26 | } 27 | 28 | func CapturePanic(fn func() error) (err error) { 29 | defer func() { 30 | if msg := recover(); msg != nil { 31 | stack := debug.Stack() 32 | 33 | err = Error{ 34 | error: fmt.Errorf("%s", msg), 35 | Stack: defaultCleaner.clean(string(stack)), 36 | } 37 | } 38 | }() 39 | 40 | err = fn() 41 | return 42 | } 43 | 44 | type Error struct { 45 | error 46 | 47 | Stack string 48 | } 49 | 50 | type cleaner struct { 51 | GOPATH, GOROOT string 52 | 53 | // optionals 54 | HOME, PWD string 55 | } 56 | 57 | var ( 58 | stackHexRegexp = regexp.MustCompile(`0x[0-9a-f]{2,}\??`) 59 | stackNilRegexp = regexp.MustCompile(`0x0`) 60 | ) 61 | 62 | func (c *cleaner) clean(stack string) string { 63 | replacements := []string{ 64 | normalizedStackPath(c.GOPATH), "", 65 | normalizedStackPath(c.GOROOT), "", 66 | } 67 | 68 | if pwd := c.PWD; len(pwd) > 0 { 69 | replacements = append(replacements, normalizedStackPath(pwd), "") 70 | } 71 | if home := c.HOME; len(home) > 0 { 72 | replacements = append(replacements, normalizedStackPath(home), "") 73 | } 74 | 75 | stackPathReplacer := strings.NewReplacer(replacements...) 76 | 77 | // TODO: more nuanced replace for other known values like true/false, maybe empty string? 78 | 79 | stack = stackPathReplacer.Replace(stack) 80 | stack = stackHexRegexp.ReplaceAllString(stack, "") 81 | stack = stackNilRegexp.ReplaceAllString(stack, "") 82 | stack = strings.TrimRight(stack, "\n") 83 | 84 | lines := strings.Split(string(stack), "\n") 85 | // omit lines: 0 go routine, 2-3 stack call, 4-5 cleanup call 86 | lines = lines[5:] 87 | 88 | for i, line := range lines { 89 | if strings.Contains(line, "") { 90 | // for lines like: `/src/runtime/debug/stack.go:24 +` 91 | lines[i] = fmt.Sprintf("%s: +", strings.Split(line, ":")[0]) 92 | } 93 | if strings.Contains(line, "in goroutine") { 94 | lines[i] = fmt.Sprintf("%s in gouroutine ", strings.Split(line, " in goroutine ")[0]) 95 | } 96 | } 97 | 98 | return strings.Join(lines, "\n") 99 | } 100 | func normalizedStackPath(path string) string { 101 | return strings.ReplaceAll(path, string(os.PathSeparator), "/") 102 | } 103 | -------------------------------------------------------------------------------- /stacktrace/stacktrace_test.go: -------------------------------------------------------------------------------- 1 | package stacktrace 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aymanbagabas/go-udiff" 7 | 8 | _ "github.com/anchordotdev/cli/testflags" 9 | ) 10 | 11 | func TestCleaner(t *testing.T) { 12 | cleaner := &cleaner{ 13 | GOPATH: gopath, 14 | GOROOT: goroot, 15 | HOME: home, 16 | PWD: pwd, 17 | } 18 | 19 | if want, got := cleanStack, cleaner.clean(originalStack); want != got { 20 | diff := udiff.Unified("want", "got", want, got) 21 | 22 | t.Fatalf("cleaned stack does not match.\n\nWant:\n\n%s\n\nGot:\n\n%s\n\nDiff:\n\n%s", want, got, diff) 23 | } 24 | } 25 | 26 | var ( 27 | originalStack = `goroutine 39 [running]: 28 | runtime/debug.Stack() 29 | /Users/benburkert/.asdf/installs/golang/1.22.3/go/src/runtime/debug/stack.go:24 +0x64 30 | github.com/anchordotdev/cli/stacktrace.CapturePanic.func1() 31 | /Users/benburkert/src/github.com/anchordotdev/anchor/cli/stacktrace/stacktrace.go:40 +0x40 32 | panic({0x104f45000?, 0x10503c250?}) 33 | /Users/benburkert/.asdf/installs/golang/1.22.3/go/src/runtime/panic.go:770 +0x124 34 | github.com/anchordotdev/cli_test.(*PanicCommand).run(0x104ee3160?, {0x105045020, 0x140002eb9e0}, 0x14000318150) 35 | /Users/benburkert/src/github.com/anchordotdev/anchor/cli/cli_test.go:117 +0xb0 36 | github.com/anchordotdev/cli_test.TestPanic.func1.1() 37 | /Users/benburkert/src/github.com/anchordotdev/anchor/cli/cli_test.go:138 +0x48 38 | github.com/anchordotdev/cli/stacktrace.CapturePanic(0x105045020?) 39 | /Users/benburkert/src/github.com/anchordotdev/anchor/cli/stacktrace/stacktrace.go:49 +0x58 40 | github.com/anchordotdev/cli_test.TestPanic.func1(0x140001b16c0) 41 | /Users/benburkert/src/github.com/anchordotdev/anchor/cli/cli_test.go:138 +0x74 42 | testing.tRunner(0x140001b16c0, 0x140001a6de0) 43 | /Users/benburkert/.asdf/installs/golang/1.22.3/go/src/testing/testing.go:1689 +0xec 44 | created by testing.(*T).Run in goroutine 38 45 | /Users/benburkert/.asdf/installs/golang/1.22.3/go/src/testing/testing.go:1742 +0x318` 46 | 47 | cleanStack = `panic({, }) 48 | /src/runtime/panic.go: + 49 | github.com/anchordotdev/cli_test.(*PanicCommand).run(, {, }, ) 50 | /cli/cli_test.go:117 + 51 | github.com/anchordotdev/cli_test.TestPanic.func1.1() 52 | /cli/cli_test.go:138 + 53 | github.com/anchordotdev/cli/stacktrace.CapturePanic() 54 | /cli/stacktrace/stacktrace.go:49 + 55 | github.com/anchordotdev/cli_test.TestPanic.func1() 56 | /cli/cli_test.go:138 + 57 | testing.tRunner(, ) 58 | /src/testing/testing.go: + 59 | created by testing.(*T).Run in gouroutine 60 | /src/testing/testing.go: +` 61 | 62 | gopath = "/Users/benburkert/.asdf/installs/golang/1.22.3/packages" 63 | goroot = "/Users/benburkert/.asdf/installs/golang/1.22.3/go" 64 | home = "/Users/benburkert" 65 | pwd = "/Users/benburkert/src/github.com/anchordotdev/anchor" 66 | ) 67 | -------------------------------------------------------------------------------- /testdata/TestCmdRoot/--help.golden: -------------------------------------------------------------------------------- 1 | anchor is a command line interface for the Anchor certificate management platform. 2 | 3 | It provides a developer friendly interface for certificate management. 4 | 5 | Usage: 6 | anchor [flags] 7 | anchor [command] 8 | 9 | Available Commands: 10 | auth Manage Anchor.dev Authentication 11 | completion Generate the autocompletion script for the specified shell 12 | help Help about any command 13 | lcl Manage lcl.host Local Development Environment 14 | org Manage Organizations 15 | service Manage services 16 | trust Manage CA Certificates in your Local Trust Store(s) 17 | version Show Version Info 18 | 19 | Flags: 20 | --api-token string Anchor API personal access token (PAT). 21 | --config string Service configuration file. (default "anchor.toml") 22 | -h, --help help for anchor 23 | --skip-config Skip loading configuration file. 24 | 25 | Use "anchor [command] --help" for more information about a command. 26 | -------------------------------------------------------------------------------- /testdata/TestCmdRoot/root.golden: -------------------------------------------------------------------------------- 1 | anchor is a command line interface for the Anchor certificate management platform. 2 | 3 | It provides a developer friendly interface for certificate management. 4 | 5 | Usage: 6 | anchor [flags] 7 | anchor [command] 8 | 9 | Available Commands: 10 | auth Manage Anchor.dev Authentication 11 | completion Generate the autocompletion script for the specified shell 12 | help Help about any command 13 | lcl Manage lcl.host Local Development Environment 14 | org Manage Organizations 15 | service Manage services 16 | trust Manage CA Certificates in your Local Trust Store(s) 17 | version Show Version Info 18 | 19 | Flags: 20 | --api-token string Anchor API personal access token (PAT). 21 | --config string Service configuration file. (default "anchor.toml") 22 | -h, --help help for anchor 23 | --skip-config Skip loading configuration file. 24 | 25 | Use "anchor [command] --help" for more information about a command. 26 | -------------------------------------------------------------------------------- /testdata/TestError/golden-unix.golden: -------------------------------------------------------------------------------- 1 | ─── TestHeader ───────────────────────────────────────────────────────────────── 2 | 3 | # Test error `anchor test error` 4 | ─── TestHint ─────────────────────────────────────────────────────────────────── 5 | 6 | # Test error `anchor test error` 7 | | Test error Hint. 8 | ─── ReportError ──────────────────────────────────────────────────────────────── 9 | 10 | # Test error `anchor test error` 11 | | Test error Hint. 12 | 13 | # Error! test error `` 14 | | We are sorry you encountered this error. 15 | ! Press Enter to open an issue on Github. 16 | ─── Browserless ──────────────────────────────────────────────────────────────── 17 | 18 | # Test error `anchor test error` 19 | | Test error Hint. 20 | 21 | # Error! test error `` 22 | | We are sorry you encountered this error. 23 | ! Press Enter to open an issue on Github. 24 | ! Warning: Unable to open browser. 25 | ! Open this in a browser to continue: https://github.com/anchordotdev/cli/issues/new?body=%2A%2AAre+there+any+additional+details+you+would+like+to+share%3F%2A%2A%0A%0A---%0A%0A%2A%2ACommand%3A%2A%2A+%60anchor%60%0A%2A%2AExecutable%3A%2A%2A+%60%2Ftmp%2Fgo-build0123456789%2Fb001%2Fexe%2Fanchor%60%0A%2A%2AVersion%3A%2A%2A+%60dev+%28goos%2Fgoarch%29+Commit%3A+none+BuildDate%3A+unknown%60%0A%2A%2AArguments%3A%2A%2A+%60%5B%5D%60%0A%2A%2AFlags%3A%2A%2A+%60%5B%5D%60%0A%2A%2ATimestamp%3A%2A%2A+%602024-01-02T15%3A04%3A05.987654321Z%60%0A%2A%2AStdout%3A%2A%2A%0A%60%60%60%0A++++++++++++++++++++++++++++++++%0A%23+Test+error+%60anchor+test+error%60%0A++++%7C+Test+error+Hint.%0A%60%60%60%0A&title=Error%3A+test+error. 26 | -------------------------------------------------------------------------------- /testdata/TestError/golden-windows.golden: -------------------------------------------------------------------------------- 1 | ─── TestHeader ───────────────────────────────────────────────────────────────── 2 | 3 | # Test error `anchor test error` 4 | ─── TestHint ─────────────────────────────────────────────────────────────────── 5 | 6 | # Test error `anchor test error` 7 | | Test error Hint. 8 | ─── ReportError ──────────────────────────────────────────────────────────────── 9 | 10 | # Test error `anchor test error` 11 | | Test error Hint. 12 | 13 | # Error! test error `` 14 | | We are sorry you encountered this error. 15 | ! Press Enter to open an issue on Github. 16 | ─── Browserless ──────────────────────────────────────────────────────────────── 17 | 18 | # Test error `anchor test error` 19 | | Test error Hint. 20 | 21 | # Error! test error `` 22 | | We are sorry you encountered this error. 23 | ! Press Enter to open an issue on Github. 24 | ! Warning: Unable to open browser. 25 | ! Open this in a browser to continue: https://github.com/anchordotdev/cli/issues/new?body=%2A%2AAre+there+any+additional+details+you+would+like+to+share%3F%2A%2A%0A%0A---%0A%0A%2A%2ACommand%3A%2A%2A+%60anchor%60%0A%2A%2AExecutable%3A%2A%2A+%60C%3A%5CUsers%5Cusername%5CAppData%5CLocal%5CTemp%5Cgo-build0123456789%2Fb001%2Fexe%2Fanchor.exe%60%0A%2A%2AVersion%3A%2A%2A+%60dev+%28goos%2Fgoarch%29+Commit%3A+none+BuildDate%3A+unknown%60%0A%2A%2AArguments%3A%2A%2A+%60%5B%5D%60%0A%2A%2AFlags%3A%2A%2A+%60%5B%5D%60%0A%2A%2ATimestamp%3A%2A%2A+%602024-01-02T15%3A04%3A05.987654321Z%60%0A%2A%2AStdout%3A%2A%2A%0A%60%60%60%0A++++++++++++++++++++++++++++++++%0A%23+Test+error+%60anchor+test+error%60%0A++++%7C+Test+error+Hint.%0A%60%60%60%0A&title=Error%3A+test+error. 26 | -------------------------------------------------------------------------------- /testdata/TestPanic/golden-unix.golden: -------------------------------------------------------------------------------- 1 | ─── TestHeader ───────────────────────────────────────────────────────────────── 2 | 3 | # Test panic `anchor test panic` 4 | ─── TestHint ─────────────────────────────────────────────────────────────────── 5 | 6 | # Test panic `anchor test panic` 7 | | Test panic Hint. 8 | ─── ReportError ──────────────────────────────────────────────────────────────── 9 | 10 | # Test panic `anchor test panic` 11 | | Test panic Hint. 12 | 13 | # Error! test panic `` 14 | | We are sorry you encountered this error. 15 | ! Press Enter to open an issue on Github. 16 | ─── Browserless ──────────────────────────────────────────────────────────────── 17 | 18 | # Test panic `anchor test panic` 19 | | Test panic Hint. 20 | 21 | # Error! test panic `` 22 | | We are sorry you encountered this error. 23 | ! Press Enter to open an issue on Github. 24 | ! Warning: Unable to open browser. 25 | ! Open this in a browser to continue: https://github.com/anchordotdev/cli/issues/new?body=%2A%2AAre+there+any+additional+details+you+would+like+to+share%3F%2A%2A%0A%0A---%0A%0A%2A%2ACommand%3A%2A%2A+%60anchor%60%0A%2A%2AExecutable%3A%2A%2A+%60%2Ftmp%2Fgo-build0123456789%2Fb001%2Fexe%2Fanchor%60%0A%2A%2AVersion%3A%2A%2A+%60dev+%28goos%2Fgoarch%29+Commit%3A+none+BuildDate%3A+unknown%60%0A%2A%2AArguments%3A%2A%2A+%60%5B%5D%60%0A%2A%2AFlags%3A%2A%2A+%60%5B%5D%60%0A%2A%2ATimestamp%3A%2A%2A+%602024-01-02T15%3A04%3A05.987654321Z%60%0A%2A%2AStack%3A%2A%2A%0A%60%60%60%0Apanic%28%7B%3Chex%3E%2C+%3Chex%3E%7D%29%0A%09%3Cgoroot%3E%2Fsrc%2Fruntime%2Fpanic.go%3A%3Cline%3E+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli_test.%28%2APanicCommand%29.run%28%3Chex%3E%2C+%7B%3Chex%3E%2C+%3Chex%3E%7D%2C+%3Chex%3E%29%0A%09%3Cpwd%3E%2Fcli_test.go%3A106+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli_test.TestPanic.func1.1%28%29%0A%09%3Cpwd%3E%2Fcli_test.go%3A127+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli%2Fstacktrace.CapturePanic%28%3Chex%3E%29%0A%09%3Cpwd%3E%2Fstacktrace%2Fstacktrace.go%3A40+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli_test.TestPanic.func1%28%3Chex%3E%29%0A%09%3Cpwd%3E%2Fcli_test.go%3A127+%2B%3Chex%3E%0Atesting.tRunner%28%3Chex%3E%2C+%3Chex%3E%29%0A%09%3Cgoroot%3E%2Fsrc%2Ftesting%2Ftesting.go%3A%3Cline%3E+%2B%3Chex%3E%0Acreated+by+testing.%28%2AT%29.Run+in+gouroutine+%3Cint%3E%0A%09%3Cgoroot%3E%2Fsrc%2Ftesting%2Ftesting.go%3A%3Cline%3E+%2B%3Chex%3E%0A%60%60%60%0A%2A%2AStdout%3A%2A%2A%0A%60%60%60%0A++++++++++++++++++++++++++++++++%0A%23+Test+panic+%60anchor+test+panic%60%0A++++%7C+Test+panic+Hint.%0A%60%60%60%0A&title=Error%3A+test+panic. 26 | -------------------------------------------------------------------------------- /testdata/TestPanic/golden-windows.golden: -------------------------------------------------------------------------------- 1 | ─── TestHeader ───────────────────────────────────────────────────────────────── 2 | 3 | # Test panic `anchor test panic` 4 | ─── TestHint ─────────────────────────────────────────────────────────────────── 5 | 6 | # Test panic `anchor test panic` 7 | | Test panic Hint. 8 | ─── ReportError ──────────────────────────────────────────────────────────────── 9 | 10 | # Test panic `anchor test panic` 11 | | Test panic Hint. 12 | 13 | # Error! test panic `` 14 | | We are sorry you encountered this error. 15 | ! Press Enter to open an issue on Github. 16 | ─── Browserless ──────────────────────────────────────────────────────────────── 17 | 18 | # Test panic `anchor test panic` 19 | | Test panic Hint. 20 | 21 | # Error! test panic `` 22 | | We are sorry you encountered this error. 23 | ! Press Enter to open an issue on Github. 24 | ! Warning: Unable to open browser. 25 | ! Open this in a browser to continue: https://github.com/anchordotdev/cli/issues/new?body=%2A%2AAre+there+any+additional+details+you+would+like+to+share%3F%2A%2A%0A%0A---%0A%0A%2A%2ACommand%3A%2A%2A+%60anchor%60%0A%2A%2AExecutable%3A%2A%2A+%60C%3A%5CUsers%5Cusername%5CAppData%5CLocal%5CTemp%5Cgo-build0123456789%2Fb001%2Fexe%2Fanchor.exe%60%0A%2A%2AVersion%3A%2A%2A+%60dev+%28goos%2Fgoarch%29+Commit%3A+none+BuildDate%3A+unknown%60%0A%2A%2AArguments%3A%2A%2A+%60%5B%5D%60%0A%2A%2AFlags%3A%2A%2A+%60%5B%5D%60%0A%2A%2ATimestamp%3A%2A%2A+%602024-01-02T15%3A04%3A05.987654321Z%60%0A%2A%2AStack%3A%2A%2A%0A%60%60%60%0Apanic%28%7B%3Chex%3E%2C+%3Chex%3E%7D%29%0A%09%3Cgoroot%3E%2Fsrc%2Fruntime%2Fpanic.go%3A%3Cline%3E+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli_test.%28%2APanicCommand%29.run%28%3Chex%3E%2C+%7B%3Chex%3E%2C+%3Chex%3E%7D%2C+%3Chex%3E%29%0A%09%3Cpwd%3E%2Fcli_test.go%3A106+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli_test.TestPanic.func1.1%28%29%0A%09%3Cpwd%3E%2Fcli_test.go%3A127+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli%2Fstacktrace.CapturePanic%28%3Chex%3E%29%0A%09%3Cpwd%3E%2Fstacktrace%2Fstacktrace.go%3A40+%2B%3Chex%3E%0Agithub.com%2Fanchordotdev%2Fcli_test.TestPanic.func1%28%3Chex%3E%29%0A%09%3Cpwd%3E%2Fcli_test.go%3A127+%2B%3Chex%3E%0Atesting.tRunner%28%3Chex%3E%2C+%3Chex%3E%29%0A%09%3Cgoroot%3E%2Fsrc%2Ftesting%2Ftesting.go%3A%3Cline%3E+%2B%3Chex%3E%0Acreated+by+testing.%28%2AT%29.Run+in+gouroutine+%3Cint%3E%0A%09%3Cgoroot%3E%2Fsrc%2Ftesting%2Ftesting.go%3A%3Cline%3E+%2B%3Chex%3E%0A%60%60%60%0A%2A%2AStdout%3A%2A%2A%0A%60%60%60%0A++++++++++++++++++++++++++++++++%0A%23+Test+panic+%60anchor+test+panic%60%0A++++%7C+Test+panic+Hint.%0A%60%60%60%0A&title=Error%3A+test+panic. 26 | -------------------------------------------------------------------------------- /testflags/testflags.go: -------------------------------------------------------------------------------- 1 | package testflags 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | func init() { 10 | pflag.CommandLine.String("api-lockfile", "tmp/apitest.lock", "rails server lockfile path") 11 | if err := pflag.CommandLine.MarkHidden("api-lockfile"); err != nil { 12 | panic(err) 13 | } 14 | 15 | pflag.CommandLine.String("oapi-config", "config/openapi.yml", "openapi spec file path") 16 | if err := pflag.CommandLine.MarkHidden("oapi-config"); err != nil { 17 | panic(err) 18 | } 19 | 20 | pflag.CommandLine.Bool("update", false, "update .golden files") 21 | if err := pflag.CommandLine.MarkHidden("update"); err != nil { 22 | panic(err) 23 | } 24 | 25 | pflag.CommandLine.Bool("prism-proxy", false, "run prism in proxy mode") 26 | if err := pflag.CommandLine.MarkHidden("prism-proxy"); err != nil { 27 | panic(err) 28 | } 29 | 30 | pflag.CommandLine.Bool("prism-verbose", false, "run prism in verbose mode") 31 | if err := pflag.CommandLine.MarkHidden("prism-verbose"); err != nil { 32 | panic(err) 33 | } 34 | 35 | pflag.CommandLine.AddGoFlagSet(flag.CommandLine) 36 | pflag.Parse() 37 | } 38 | -------------------------------------------------------------------------------- /toml/toml.go: -------------------------------------------------------------------------------- 1 | package toml 2 | 3 | import ( 4 | "io" 5 | "reflect" 6 | "slices" 7 | 8 | "github.com/fatih/structtag" 9 | "github.com/mohae/deepcopy" 10 | "github.com/pelletier/go-toml/v2" 11 | ) 12 | 13 | type Decoder = toml.Decoder 14 | 15 | func NewDecoder(r io.Reader) *Decoder { return toml.NewDecoder(r) } 16 | 17 | type Encoder[T any] struct { 18 | enc *toml.Encoder 19 | } 20 | 21 | func NewEncoder[T any](w io.Writer) *Encoder[T] { 22 | return &Encoder[T]{ 23 | enc: toml.NewEncoder(w), 24 | } 25 | } 26 | 27 | func (e *Encoder[T]) Encode(v T) error { 28 | vv := deepcopy.Copy(v).(T) 29 | 30 | val := reflect.ValueOf(vv) 31 | if val.Kind() != reflect.Pointer { 32 | val = reflect.ValueOf(&vv) 33 | } 34 | 35 | if err := applyReadonlyOption(val); err != nil { 36 | return err 37 | } 38 | return e.enc.Encode(vv) 39 | } 40 | 41 | func applyReadonlyOption(val reflect.Value) error { 42 | if val.Kind() != reflect.Pointer && val.Elem().Kind() != reflect.Struct { 43 | return nil 44 | } 45 | 46 | typ := val.Elem().Type() 47 | for i := 0; i < val.Elem().NumField(); i++ { 48 | if typ.Field(i).Type.Kind() == reflect.Struct { 49 | if err := applyReadonlyOption(val.Elem().Field(i).Addr()); err != nil { 50 | return err 51 | } 52 | } 53 | if typ.Field(i).Type.Kind() == reflect.Pointer && !val.Elem().Field(i).IsZero() { 54 | if err := applyReadonlyOption(val.Elem().Field(i)); err != nil { 55 | return err 56 | } 57 | } 58 | 59 | tags, err := structtag.Parse(string(typ.Field(i).Tag)) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | tomlTag, err := tags.Get("toml") 65 | if tomlTag == nil { 66 | continue 67 | } 68 | if err != nil { 69 | return err 70 | } 71 | 72 | if slices.Contains(tomlTag.Options, "readonly") { 73 | val.Elem().Field(i).Set(reflect.Zero(typ.Field(i).Type)) 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /trust/clean_test.go: -------------------------------------------------------------------------------- 1 | package trust 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/anchordotdev/cli" 8 | "github.com/anchordotdev/cli/cmdtest" 9 | "github.com/anchordotdev/cli/ui/uitest" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestCmdTrustClean(t *testing.T) { 14 | t.Run("--help", func(t *testing.T) { 15 | cmdtest.TestHelp(t, CmdTrustClean, "trust", "clean", "--help") 16 | }) 17 | 18 | t.Run("--cert-states all", func(t *testing.T) { 19 | cfg := cmdtest.TestCfg(t, CmdTrustClean, "--cert-states", "all") 20 | require.Equal(t, []string{"all"}, cfg.Trust.Clean.States) 21 | }) 22 | 23 | t.Run("--org testOrg", func(t *testing.T) { 24 | err := cmdtest.TestError(t, CmdTrustClean, "--org", "testOrg") 25 | require.ErrorContains(t, err, "if any flags in the group [org realm] are set they must all be set; missing [realm]") 26 | }) 27 | 28 | t.Run("-o testOrg", func(t *testing.T) { 29 | err := cmdtest.TestError(t, CmdTrustClean, "-o", "testOrg") 30 | require.ErrorContains(t, err, "if any flags in the group [org realm] are set they must all be set; missing [realm]") 31 | }) 32 | 33 | t.Run("-o testOrg -r testRealm", func(t *testing.T) { 34 | cfg := cmdtest.TestCfg(t, CmdTrustClean, "-o", "testOrg", "-r", "testRealm") 35 | require.Equal(t, "testOrg", cfg.Org.APID) 36 | require.Equal(t, "testRealm", cfg.Realm.APID) 37 | }) 38 | 39 | t.Run("--realm testRealm", func(t *testing.T) { 40 | err := cmdtest.TestError(t, CmdTrustClean, "--realm", "testRealm") 41 | require.ErrorContains(t, err, "if any flags in the group [org realm] are set they must all be set; missing [org]") 42 | }) 43 | 44 | t.Run("-r testRealm", func(t *testing.T) { 45 | err := cmdtest.TestError(t, CmdTrustClean, "-r", "testRealm") 46 | require.ErrorContains(t, err, "if any flags in the group [org realm] are set they must all be set; missing [org]") 47 | }) 48 | 49 | t.Run("default --trust-stores", func(t *testing.T) { 50 | cfg := cmdtest.TestCfg(t, CmdTrustClean) 51 | require.Equal(t, []string{"homebrew", "nss", "system"}, cfg.Trust.Stores) 52 | }) 53 | 54 | t.Run("--trust-stores nss,system", func(t *testing.T) { 55 | cfg := cmdtest.TestCfg(t, CmdTrustClean, "--trust-stores", "nss,system") 56 | require.Equal(t, []string{"nss", "system"}, cfg.Trust.Stores) 57 | }) 58 | } 59 | 60 | func TestClean(t *testing.T) { 61 | ctx, cancel := context.WithCancel(context.Background()) 62 | defer cancel() 63 | 64 | cfg := new(cli.Config) 65 | cfg.API.URL = srv.URL 66 | cfg.Trust.MockMode = true 67 | cfg.Trust.NoSudo = true 68 | cfg.Trust.Stores = []string{"mock"} 69 | var err error 70 | if cfg.API.Token, err = srv.GeneratePAT("anky@anchor.dev"); err != nil { 71 | t.Fatal(err) 72 | } 73 | ctx = cli.ContextWithConfig(ctx, cfg) 74 | 75 | t.Run("basics", func(t *testing.T) { 76 | if srv.IsProxy() { 77 | t.Skip("lcl clean unsupported in proxy mode") 78 | } 79 | 80 | ctx, cancel := context.WithCancel(ctx) 81 | defer cancel() 82 | 83 | cmd := Clean{} 84 | 85 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /trust/models/audit.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/anchordotdev/cli/truststore" 9 | "github.com/anchordotdev/cli/ui" 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | var ( 14 | TrustAuditHeader = ui.Section{ 15 | Name: "TrustAuditHeader", 16 | Model: ui.MessageLines{ 17 | ui.Header(fmt.Sprintf("Audit CA Certificates in Your Local Trust Store(s) %s", ui.Whisper("`anchor trust audit`"))), 18 | }, 19 | } 20 | 21 | TrustAuditHint = ui.Section{ 22 | Name: "TrustAuditHint", 23 | Model: ui.MessageLines{ 24 | ui.StepHint("We'll compare your CA certificates from Anchor and your local trust stores."), 25 | }, 26 | } 27 | ) 28 | 29 | type TrustAuditInfo struct { 30 | AuditInfo *truststore.AuditInfo 31 | Stores []truststore.Store 32 | } 33 | 34 | func (m *TrustAuditInfo) Init() tea.Cmd { return nil } 35 | 36 | func (m *TrustAuditInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 37 | 38 | func (m *TrustAuditInfo) View() string { 39 | var b strings.Builder 40 | 41 | for _, ca := range m.AuditInfo.Valid { 42 | fmt.Fprint(&b, ui.StepDone(fmt.Sprintf("%s - %s %s:", 43 | ui.Emphasize("VALID"), 44 | ui.Underline(ca.Subject.CommonName), 45 | ca.PublicKeyAlgorithm.String(), 46 | ))) 47 | 48 | printStoresInfo(&b, m.AuditInfo, ca, m.Stores) 49 | 50 | fmt.Fprintln(&b) 51 | } 52 | 53 | for _, ca := range m.AuditInfo.Missing { 54 | fmt.Fprint(&b, ui.StepDone(fmt.Sprintf("%s - %s %s:", 55 | ui.Emphasize("MISSING"), 56 | ui.Underline(ca.Subject.CommonName), 57 | ca.PublicKeyAlgorithm.String(), 58 | ))) 59 | 60 | printStoresInfo(&b, m.AuditInfo, ca, m.Stores) 61 | 62 | fmt.Fprintln(&b) 63 | } 64 | 65 | for _, ca := range m.AuditInfo.Extra { 66 | fmt.Fprint(&b, ui.StepDone(fmt.Sprintf("%s - %s %s:", 67 | ui.Emphasize("EXTRA"), 68 | ui.Underline(ca.Subject.CommonName), 69 | ca.PublicKeyAlgorithm.String(), 70 | ))) 71 | 72 | printStoresInfo(&b, m.AuditInfo, ca, m.Stores) 73 | 74 | fmt.Fprintln(&b) 75 | } 76 | 77 | return b.String() 78 | } 79 | 80 | func printStoresInfo(w io.Writer, auditInfo *truststore.AuditInfo, ca *truststore.CA, stores []truststore.Store) { 81 | var missingStores, trustedStores []string 82 | for _, store := range stores { 83 | if auditInfo.IsPresent(ca, store) { 84 | trustedStores = append(trustedStores, store.Description()) 85 | } else { 86 | missingStores = append(missingStores, store.Description()) 87 | } 88 | } 89 | if len(missingStores) > 0 { 90 | fmt.Fprintf(w, " missing from [%s]", ui.Whisper(strings.Join(missingStores, ", "))) 91 | } 92 | if len(trustedStores) > 0 { 93 | fmt.Fprintf(w, " trusted by [%s]", ui.Whisper(strings.Join(trustedStores, ", "))) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /trust/runtime_detector.go: -------------------------------------------------------------------------------- 1 | package trust 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli" 8 | ) 9 | 10 | func isVMOrContainer(cfg *cli.Config) bool { 11 | switch cfg.GOOS() { 12 | case "linux": 13 | // only WSL is detected for now. 14 | return isWSL(cfg) 15 | default: 16 | return false 17 | } 18 | } 19 | 20 | func isWSL(cfg *cli.Config) bool { 21 | f, err := cfg.ProcFS().Open("version") 22 | if err != nil { 23 | return false 24 | } 25 | 26 | buf, err := io.ReadAll(f) 27 | if err != nil { 28 | return false 29 | } 30 | kernel := string(buf) 31 | 32 | // https://superuser.com/questions/1725627/which-linux-kernel-do-i-have-in-wsl 33 | 34 | return strings.Contains(kernel, "Microsoft") || // WSL 1 35 | strings.Contains(kernel, "microsoft") // WSL 2 36 | } 37 | -------------------------------------------------------------------------------- /trust/runtime_detector_test.go: -------------------------------------------------------------------------------- 1 | package trust 2 | 3 | import ( 4 | "testing" 5 | "testing/fstest" 6 | 7 | "github.com/anchordotdev/cli" 8 | ) 9 | 10 | func TestIsVMOrContainer(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | 14 | cfg *cli.Config 15 | 16 | result bool 17 | }{ 18 | { 19 | name: "non-vm-or-container-linux", 20 | 21 | cfg: &cli.Config{ 22 | Test: cli.ConfigTest{ 23 | GOOS: "linux", 24 | ProcFS: fstest.MapFS{ 25 | "version": &fstest.MapFile{ 26 | Data: unameLinuxHost, 27 | }, 28 | }, 29 | }, 30 | }, 31 | 32 | result: false, 33 | }, 34 | { 35 | name: "WSL-1", 36 | 37 | cfg: &cli.Config{ 38 | Test: cli.ConfigTest{ 39 | GOOS: "linux", 40 | ProcFS: fstest.MapFS{ 41 | "version": &fstest.MapFile{ 42 | Data: unameWSL1, 43 | }, 44 | }, 45 | }, 46 | }, 47 | 48 | result: true, 49 | }, 50 | { 51 | name: "WSL-2", 52 | 53 | cfg: &cli.Config{ 54 | Test: cli.ConfigTest{ 55 | GOOS: "linux", 56 | ProcFS: fstest.MapFS{ 57 | "version": &fstest.MapFile{ 58 | Data: unameWSL2, 59 | }, 60 | }, 61 | }, 62 | }, 63 | 64 | result: true, 65 | }, 66 | } 67 | 68 | for _, test := range tests { 69 | t.Run(test.name, func(t *testing.T) { 70 | if want, got := test.result, isVMOrContainer(test.cfg); want != got { 71 | t.Errorf("want IsVMOrContainer result %t, got %t", want, got) 72 | } 73 | }) 74 | } 75 | } 76 | 77 | var ( 78 | unameWSL1 = []byte(`Linux db-d-18 4.4.0-19041-Microsoft #1237-Microsoft Sat Sep 11 14:32:00 PST 2021 x86_64 GNU/Linux`) 79 | unameWSL2 = []byte(`Linux db-d-18 5.4.72-microsoft-standard-WSL2 #1 SMP Wed Oct 28 23:40:43 UTC 2020 x86_64 GNU/Linux`) 80 | 81 | unameLinuxHost = []byte(`Linux geemus-framework 6.5.0-1020-oem #21-Ubuntu SMP PREEMPT_DYNAMIC Wed Apr 3 14:54:32 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux`) 82 | ) 83 | -------------------------------------------------------------------------------- /trust/testdata/TestClean/basics.golden: -------------------------------------------------------------------------------- 1 | ─── Client ───────────────────────────────────────────────────────────────────── 2 | * Checking authentication: probing credentials locally…⠋ 3 | ─── Client ───────────────────────────────────────────────────────────────────── 4 | * Checking authentication: testing credentials remotely…⠋ 5 | ─── TrustCleanHeader ─────────────────────────────────────────────────────────── 6 | 7 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 8 | ─── TrustCleanHint ───────────────────────────────────────────────────────────── 9 | 10 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 11 | | We'll remove CA certificates from the mock store(s). 12 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 13 | 14 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 15 | | We'll remove CA certificates from the mock store(s). 16 | * Fetching organizations…⠋ 17 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 18 | 19 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 20 | | We'll remove CA certificates from the mock store(s). 21 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 22 | ─── Fetcher[github.com/anchordotdev/cli/api.Realm] ───────────────────────────── 23 | 24 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 25 | | We'll remove CA certificates from the mock store(s). 26 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 27 | * Fetching realms…⠋ 28 | ─── Fetcher[github.com/anchordotdev/cli/api.Realm] ───────────────────────────── 29 | 30 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 31 | | We'll remove CA certificates from the mock store(s). 32 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 33 | - Using realm-slug, the only available realm. You can also use `--realm realm-slug`. 34 | ─── TrustCleanAudit ──────────────────────────────────────────────────────────── 35 | 36 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 37 | | We'll remove CA certificates from the mock store(s). 38 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 39 | - Using realm-slug, the only available realm. You can also use `--realm realm-slug`. 40 | * Auditing local CA certificates…⠋ 41 | ─── TrustCleanAudit ──────────────────────────────────────────────────────────── 42 | 43 | # Clean CA Certificates from Local Trust Store(s) `anchor trust clean` 44 | | We'll remove CA certificates from the mock store(s). 45 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 46 | - Using realm-slug, the only available realm. You can also use `--realm realm-slug`. 47 | - Audited local CA certificates: need to remove 0 certificates. 48 | -------------------------------------------------------------------------------- /trust/testdata/TestCmdTrust/--help.golden: -------------------------------------------------------------------------------- 1 | Install the AnchorCA certificates of a target organization, realm, or CA into 2 | your local system's trust store. The default target is the localhost realm of 3 | your personal organization. 4 | 5 | After installation of the AnchorCA certificates, Leaf certificates under the 6 | AnchorCA certificates will be trusted by browsers and programs on your system. 7 | 8 | Usage: 9 | anchor trust [flags] 10 | anchor trust [command] 11 | 12 | Available Commands: 13 | audit Audit CA Certificates in Your Local Trust Store(s) 14 | 15 | Flags: 16 | -h, --help help for trust 17 | --no-sudo Disable sudo prompts. 18 | -o, --org string Organization to trust. 19 | -r, --realm string Realm to trust. 20 | --trust-stores strings Trust stores to update. (default [homebrew,nss,system]) 21 | 22 | Global Flags: 23 | --api-token string Anchor API personal access token (PAT). 24 | --config string Service configuration file. (default "anchor.toml") 25 | --skip-config Skip loading configuration file. 26 | 27 | Use "anchor trust [command] --help" for more information about a command. 28 | -------------------------------------------------------------------------------- /trust/testdata/TestCmdTrustAudit/--help.golden: -------------------------------------------------------------------------------- 1 | Perform an audit of the local trust store(s) and report any expected, missing, 2 | or extra CA certificates per store. A set of expected CAs is fetched for the 3 | target org and (optional) realm. The default stores to audit are system, nss, 4 | and homebrew. 5 | 6 | CA certificate states: 7 | 8 | * VALID: an expected CA certificate is present in every trust store. 9 | * MISSING: an expected CA certificate is missing in one or more stores. 10 | * EXTRA: an unexpected CA certificate is present in one or more stores. 11 | 12 | Usage: 13 | anchor trust audit [flags] 14 | 15 | Flags: 16 | -h, --help help for audit 17 | -o, --org string Organization to trust. 18 | -r, --realm string Realm to trust. 19 | --trust-stores strings Trust stores to update. (default [homebrew,nss,system]) 20 | 21 | Global Flags: 22 | --api-token string Anchor API personal access token (PAT). 23 | --config string Service configuration file. (default "anchor.toml") 24 | --skip-config Skip loading configuration file. 25 | -------------------------------------------------------------------------------- /trust/testdata/TestCmdTrustClean/--help.golden: -------------------------------------------------------------------------------- 1 | Clean CA Certificates from your Local Trust Store(s) 2 | 3 | Usage: 4 | anchor trust clean [flags] 5 | 6 | Flags: 7 | --cert-states strings Cert states to clean. (default [expired]) 8 | -h, --help help for clean 9 | --no-sudo Disable sudo prompts. 10 | -o, --org string Organization to trust. 11 | -r, --realm string Realm to trust. 12 | --trust-stores strings Trust stores to update. (default [homebrew,nss,system]) 13 | 14 | Global Flags: 15 | --api-token string Anchor API personal access token (PAT). 16 | --config string Service configuration file. (default "anchor.toml") 17 | --skip-config Skip loading configuration file. 18 | -------------------------------------------------------------------------------- /trust/testdata/TestTrust/noop.golden: -------------------------------------------------------------------------------- 1 | ─── Client ───────────────────────────────────────────────────────────────────── 2 | * Checking authentication: probing credentials locally…⠋ 3 | ─── Client ───────────────────────────────────────────────────────────────────── 4 | * Checking authentication: testing credentials remotely…⠋ 5 | ─── TrustHeader ──────────────────────────────────────────────────────────────── 6 | 7 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 8 | ─── TrustHint ────────────────────────────────────────────────────────────────── 9 | 10 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 11 | | We'll check your local trust stores and make any needed updates. 12 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 13 | 14 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 15 | | We'll check your local trust stores and make any needed updates. 16 | * Fetching organizations…⠋ 17 | ─── Fetcher[github.com/anchordotdev/cli/api.Organization] ────────────────────── 18 | 19 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 20 | | We'll check your local trust stores and make any needed updates. 21 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 22 | ─── Fetcher[github.com/anchordotdev/cli/api.Realm] ───────────────────────────── 23 | 24 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 25 | | We'll check your local trust stores and make any needed updates. 26 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 27 | * Fetching realms…⠋ 28 | ─── Fetcher[github.com/anchordotdev/cli/api.Realm] ───────────────────────────── 29 | 30 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 31 | | We'll check your local trust stores and make any needed updates. 32 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 33 | - Using realm-slug, the only available realm. You can also use `--realm realm-slug`. 34 | ─── TrustStoreAudit ──────────────────────────────────────────────────────────── 35 | 36 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 37 | | We'll check your local trust stores and make any needed updates. 38 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 39 | - Using realm-slug, the only available realm. You can also use `--realm realm-slug`. 40 | * Comparing local and expected CA certificates…⠋ 41 | ─── TrustStoreAudit ──────────────────────────────────────────────────────────── 42 | 43 | # Manage CA Certificates in your Local Trust Store(s) `anchor trust` 44 | | We'll check your local trust stores and make any needed updates. 45 | - Using org-slug, the only available organization. You can also use `--org org-slug`. 46 | - Using realm-slug, the only available realm. You can also use `--realm realm-slug`. 47 | - Compared local and expected CA certificates: no updates needed. 48 | -------------------------------------------------------------------------------- /truststore/audit_test.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import ( 4 | "crypto/x509" 5 | "crypto/x509/pkix" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/anchordotdev/cli/internal/must" 10 | ) 11 | 12 | func TestAudit(t *testing.T) { 13 | MockCAs = []*CA{ 14 | validCA, 15 | extraCA, 16 | } 17 | defer func() { MockCAs = nil }() 18 | 19 | store := new(Mock) 20 | 21 | aud := Audit{ 22 | Expected: []*CA{validCA, missingCA}, 23 | 24 | Stores: []Store{store}, 25 | } 26 | 27 | info, err := aud.Perform() 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | if want, got := []*CA{validCA}, info.Valid; !reflect.DeepEqual(want, got) { 33 | t.Errorf("want valid cas %+v, got %+v", want, got) 34 | } 35 | 36 | if want, got := []*CA{missingCA}, info.Missing; !reflect.DeepEqual(want, got) { 37 | t.Errorf("want missing cas %+v, got %+v", want, got) 38 | } 39 | 40 | if want, got := []*CA{extraCA}, info.Extra; !reflect.DeepEqual(want, got) { 41 | t.Errorf("want missing cas %+v, got %+v", want, got) 42 | } 43 | 44 | if !info.IsPresent(validCA, store) { 45 | t.Errorf("want present ca %+v in store %+v, but was not", validCA, store) 46 | } 47 | if !info.IsPresent(extraCA, store) { 48 | t.Errorf("want extra ca %+v in store %+v, but was not", extraCA, store) 49 | } 50 | 51 | if info.IsPresent(missingCA, store) { 52 | t.Errorf("want missing ca %+v not in store %+v, but was", missingCA, store) 53 | } 54 | } 55 | 56 | var ( 57 | validCA = mustCA(must.CA(&x509.Certificate{ 58 | Subject: pkix.Name{ 59 | CommonName: "Valid CA", 60 | Organization: []string{"Example, Inc"}, 61 | }, 62 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, 63 | })) 64 | 65 | missingCA = mustCA(must.CA(&x509.Certificate{ 66 | Subject: pkix.Name{ 67 | CommonName: "Missing CA", 68 | Organization: []string{"Example, Inc"}, 69 | }, 70 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, 71 | })) 72 | 73 | extraCA = mustCA(must.CA(&x509.Certificate{ 74 | Subject: pkix.Name{ 75 | CommonName: "Extra CA", 76 | Organization: []string{"Example, Inc"}, 77 | }, 78 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, 79 | })) 80 | ) 81 | -------------------------------------------------------------------------------- /truststore/brew.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import ( 4 | "bytes" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | const certPath = "etc/ca-certificates/cert.pem" 13 | 14 | type Brew struct { 15 | RootDir string 16 | 17 | DataFS DataFS 18 | SysFS CmdFS 19 | 20 | brewPath string 21 | prefixPath string 22 | 23 | certPath string 24 | } 25 | 26 | func (s *Brew) Check() (bool, error) { 27 | if s.brewPath == "" { 28 | var err error 29 | if s.brewPath, err = s.SysFS.LookPath("brew"); err != nil { 30 | return false, err 31 | } 32 | } 33 | 34 | if s.prefixPath == "" { 35 | path, err := s.SysFS.Exec(s.SysFS.Command(s.brewPath, "--prefix")) 36 | if err != nil { 37 | return false, err 38 | } 39 | s.prefixPath = strings.TrimPrefix(strings.TrimSpace(string(path)), s.RootDir) 40 | } 41 | 42 | if s.certPath == "" { 43 | path := filepath.Join(s.prefixPath, certPath) 44 | if _, err := s.SysFS.Stat(path); err != nil { 45 | return false, err 46 | } 47 | s.certPath = path 48 | } 49 | 50 | return true, nil 51 | } 52 | 53 | func (s *Brew) CheckCA(ca *CA) (bool, error) { 54 | if ok, err := s.Check(); !ok { 55 | return ok, err 56 | } 57 | 58 | buf, err := s.DataFS.ReadFile(s.certPath) 59 | if err != nil { 60 | return false, err 61 | } 62 | 63 | var blk *pem.Block 64 | for len(buf) > 0 { 65 | if blk, buf = pem.Decode(buf); blk == nil { 66 | return false, nil 67 | } 68 | 69 | if bytes.Equal(ca.Raw, blk.Bytes) { 70 | return true, nil 71 | } 72 | } 73 | return false, nil 74 | } 75 | 76 | func (s *Brew) Description() string { return "Homebrew OpenSSL (ca-certificates)" } 77 | 78 | func (s *Brew) ListCAs() ([]*CA, error) { 79 | if ok, err := s.Check(); !ok { 80 | return nil, err 81 | } 82 | 83 | buf, err := s.DataFS.ReadFile(s.certPath) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | var cas []*CA 89 | for p, buf := pem.Decode(buf); p != nil; p, buf = pem.Decode(buf) { 90 | cert, err := parseCertificate(p.Bytes) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | ca := &CA{ 96 | Certificate: cert, 97 | UniqueName: cert.SerialNumber.Text(16), 98 | } 99 | 100 | cas = append(cas, ca) 101 | } 102 | 103 | return cas, nil 104 | } 105 | 106 | func (s *Brew) InstallCA(ca *CA) (bool, error) { 107 | if ok, err := s.Check(); !ok { 108 | return ok, err 109 | } 110 | 111 | blk := &pem.Block{ 112 | Type: "CERTIFICATE", 113 | Bytes: ca.Raw, 114 | } 115 | 116 | if err := s.DataFS.AppendToFile(s.certPath, pem.EncodeToMemory(blk)); err != nil { 117 | return false, err 118 | } 119 | return true, nil 120 | } 121 | 122 | func (s *Brew) UninstallCA(ca *CA) (bool, error) { 123 | if ok, err := s.Check(); !ok { 124 | return false, err 125 | } 126 | 127 | tmpf := s.certPath + ".tmp" 128 | if _, err := s.DataFS.Stat(tmpf); err != nil && !os.IsNotExist(err) { 129 | return false, err 130 | } 131 | 132 | odata, err := s.DataFS.ReadFile(s.certPath) 133 | if err != nil { 134 | return false, err 135 | } 136 | ndata := make([]byte, 0, len(odata)) 137 | 138 | for buf := odata; len(buf) > 0; { 139 | blk, rem := pem.Decode(buf) 140 | if blk == nil { 141 | break 142 | } 143 | 144 | data := buf[:len(buf)-len(rem)] 145 | buf = rem 146 | 147 | cert, err := x509.ParseCertificate(blk.Bytes) 148 | if err != nil { 149 | return false, err 150 | } 151 | if bytes.Equal(cert.Raw, ca.Raw) { 152 | continue 153 | } 154 | 155 | ndata = append(ndata, data...) 156 | } 157 | 158 | if err := s.DataFS.AppendToFile(tmpf, ndata); err != nil { 159 | _ = s.DataFS.Remove(tmpf) 160 | return false, err 161 | } 162 | if err := s.DataFS.Rename(tmpf, s.certPath); err != nil { 163 | return false, err 164 | } 165 | return true, nil 166 | } 167 | -------------------------------------------------------------------------------- /truststore/brew_test.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package truststore 5 | 6 | import ( 7 | "testing" 8 | ) 9 | 10 | func TestBrew(t *testing.T) { 11 | testFS := make(TestFS, 1) 12 | if err := testFS.AppendToFile("cert.pem", nil); err != nil { 13 | t.Fatal(err) 14 | } 15 | 16 | store := &Brew{ 17 | RootDir: "/", 18 | DataFS: testFS, 19 | SysFS: RootFS(), 20 | 21 | certPath: "cert.pem", 22 | } 23 | 24 | testStore(t, store) 25 | } 26 | -------------------------------------------------------------------------------- /truststore/doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Anchor Security, Inc. & The mkcert Authors. All rights reserved. 2 | // Use of this source code is governed by a MIT 3 | // license that can be found in the LICENSE file. 4 | // 5 | // Implementation was based upon mkcert and the work 6 | // of the mkcert authors at https://github.com/FiloSottile/mkcert 7 | 8 | /* 9 | Package truststore manages local trust stores and certificates. 10 | */ 11 | package truststore 12 | -------------------------------------------------------------------------------- /truststore/errors.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNoSudo = errors.New(`"sudo" is not available`) 7 | 8 | ErrNoKeytool = errors.New("no java keytool") 9 | 10 | ErrNoCertutil = errors.New("no certutil tooling") 11 | ErrNoNSS = errors.New("no NSS browser") 12 | ErrNoNSSDB = errors.New("no NSS database") 13 | ErrUnknownNSS = errors.New("unknown NSS install") // untested 14 | 15 | ErrUnsupportedDistro = errors.New("unsupported Linux distrobution") 16 | ) 17 | 18 | type Op string 19 | 20 | const ( 21 | OpCheck Op = "check" 22 | OpInstall Op = "install" 23 | OpList Op = "list" 24 | OpSudo Op = "sudo" 25 | OpUninstall Op = "uninstall" 26 | ) 27 | 28 | type Error struct { 29 | Op 30 | 31 | Fatal error 32 | Warning error 33 | } 34 | 35 | func (e Error) Error() string { 36 | if e.Fatal != nil { 37 | return e.Fatal.Error() 38 | } 39 | return e.Warning.Error() 40 | } 41 | 42 | type NSSError struct { 43 | Err error 44 | 45 | CertutilInstallHelp string 46 | NSSBrowsers string 47 | } 48 | 49 | func (e NSSError) Error() string { return e.Err.Error() } 50 | 51 | type PlatformError struct { 52 | Err error 53 | 54 | NSSBrowsers string 55 | RootCA string 56 | } 57 | 58 | func (e PlatformError) Error() string { return e.Err.Error() } 59 | -------------------------------------------------------------------------------- /truststore/fs.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "os/exec" 9 | "os/user" 10 | "path/filepath" 11 | "reflect" 12 | "strings" 13 | "sync" 14 | ) 15 | 16 | type CmdFS interface { 17 | fs.StatFS 18 | 19 | Command(name string, arg ...string) *exec.Cmd 20 | Exec(cmd *exec.Cmd) ([]byte, error) 21 | SudoExec(cmd *exec.Cmd) ([]byte, error) 22 | LookPath(cmd string) (string, error) 23 | } 24 | 25 | type DataFS interface { 26 | fs.StatFS 27 | fs.ReadFileFS 28 | 29 | AppendToFile(name string, p []byte) error 30 | Rename(oldpath, newpath string) error 31 | Remove(name string) error 32 | } 33 | 34 | type FS interface { 35 | CmdFS 36 | DataFS 37 | } 38 | 39 | func RootFS() FS { 40 | return &rootFS{ 41 | StatFS: os.DirFS("/").(fs.StatFS), 42 | rootPath: "/", 43 | } 44 | } 45 | 46 | type rootFS struct { 47 | fs.StatFS 48 | 49 | rootPath string 50 | 51 | sudoWarningOnce sync.Once 52 | } 53 | 54 | func (r *rootFS) Command(name string, arg ...string) *exec.Cmd { 55 | path, _ := r.LookPath(name) 56 | return exec.Command(path, arg...) 57 | } 58 | 59 | func (r *rootFS) Exec(cmd *exec.Cmd) ([]byte, error) { 60 | return cmd.CombinedOutput() 61 | } 62 | 63 | func (r *rootFS) SudoExec(cmd *exec.Cmd) (out []byte, err error) { 64 | if u, err := user.Current(); err == nil && u.Uid == "0" { 65 | return r.Exec(cmd) 66 | } 67 | if _, serr := r.LookPath("sudo"); serr != nil { 68 | defer func() { 69 | r.sudoWarningOnce.Do(func() { 70 | err = Error{ 71 | Op: OpSudo, 72 | 73 | Fatal: err, 74 | Warning: ErrNoSudo, 75 | } 76 | }) 77 | }() 78 | 79 | return r.Exec(cmd) 80 | } 81 | 82 | sudo := r.Command("sudo", append([]string{"--prompt=Sudo password:", "--"}, cmd.Args...)...) 83 | sudo.Env = cmd.Env 84 | sudo.Dir = cmd.Dir 85 | sudo.Stdin = cmd.Stdin 86 | 87 | return r.Exec(sudo) 88 | } 89 | 90 | func (r *rootFS) LookPath(cmd string) (string, error) { 91 | return exec.LookPath(cmd) 92 | } 93 | 94 | func (r *rootFS) AppendToFile(name string, p []byte) error { 95 | f, err := os.OpenFile(filepath.Join(r.rootPath, name), os.O_APPEND|os.O_WRONLY, 0644) 96 | if err != nil { 97 | if !os.IsNotExist(err) { 98 | return err 99 | } 100 | 101 | if f, err = os.Create(filepath.Join(r.rootPath, name)); err != nil { 102 | return err 103 | } 104 | } 105 | defer f.Close() 106 | 107 | if _, err := f.Write(p); err != nil { 108 | return err 109 | } 110 | return f.Close() 111 | } 112 | 113 | func (r *rootFS) Rename(oldpath, newpath string) error { 114 | src, dst := filepath.Join(r.rootPath, oldpath), filepath.Join(r.rootPath, newpath) 115 | return os.Rename(src, dst) 116 | } 117 | 118 | func (r *rootFS) Remove(name string) error { 119 | return os.Remove(filepath.Join(r.rootPath, name)) 120 | } 121 | 122 | func (r *rootFS) ReadFile(name string) ([]byte, error) { 123 | if err := r.checkFile(name); err != nil { 124 | return nil, err 125 | } 126 | 127 | f, err := os.OpenFile(filepath.Join(r.rootPath, name), os.O_RDONLY, 0444) 128 | if err != nil { 129 | return nil, err 130 | } 131 | defer f.Close() 132 | 133 | return io.ReadAll(f) 134 | } 135 | 136 | func (r *rootFS) checkFile(name string) error { 137 | infoFS, err := r.StatFS.Stat(strings.Trim(name, string(os.PathSeparator))) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | infoOS, err := os.Stat(filepath.Join(r.rootPath, name)) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | if infoFS.Name() == infoOS.Name() && infoFS.Size() == infoOS.Size() && 148 | infoFS.Mode() == infoOS.Mode() && infoFS.ModTime().Equal(infoOS.ModTime()) && 149 | reflect.DeepEqual(infoFS.Sys(), infoOS.Sys()) { 150 | return nil 151 | } 152 | 153 | return errors.New("file system setup is misconfigured or corrupt") 154 | } 155 | -------------------------------------------------------------------------------- /truststore/mock.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import "slices" 4 | 5 | var MockCAs []*CA 6 | 7 | func ResetMockCAs() { MockCAs = []*CA{} } 8 | 9 | type Mock struct{} 10 | 11 | func (Mock) Check() (bool, error) { return true, nil } 12 | 13 | func (Mock) CheckCA(ca *CA) (bool, error) { 14 | for _, ca2 := range MockCAs { 15 | if ca.UniqueName == ca2.UniqueName { 16 | return true, nil 17 | } 18 | } 19 | 20 | return false, nil 21 | } 22 | 23 | func (Mock) Description() string { return "Mock" } 24 | 25 | func (Mock) InstallCA(ca *CA) (bool, error) { 26 | MockCAs = append(MockCAs, ca) 27 | return true, nil 28 | } 29 | 30 | func (Mock) ListCAs() ([]*CA, error) { 31 | return MockCAs, nil 32 | } 33 | 34 | func (Mock) UninstallCA(ca *CA) (bool, error) { 35 | MockCAs = slices.DeleteFunc(MockCAs, func(ca2 *CA) bool { return ca.Equal(ca2) }) 36 | return true, nil 37 | } 38 | -------------------------------------------------------------------------------- /truststore/mock_test.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import "testing" 4 | 5 | func TestMock(t *testing.T) { testStore(t, new(Mock)) } 6 | -------------------------------------------------------------------------------- /truststore/models/audit.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/truststore" 8 | "github.com/anchordotdev/cli/ui" 9 | "github.com/charmbracelet/bubbles/spinner" 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | type TrustStoreAudit struct { 14 | auditInfo *truststore.AuditInfo 15 | 16 | spinner spinner.Model 17 | } 18 | 19 | func (m *TrustStoreAudit) Init() tea.Cmd { 20 | m.spinner = ui.WaitingSpinner() 21 | 22 | return m.spinner.Tick 23 | } 24 | 25 | type AuditInfoMsg *truststore.AuditInfo 26 | 27 | func (m *TrustStoreAudit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 28 | switch msg := msg.(type) { 29 | case AuditInfoMsg: 30 | m.auditInfo = msg 31 | return m, nil 32 | default: 33 | var cmd tea.Cmd 34 | m.spinner, cmd = m.spinner.Update(msg) 35 | return m, cmd 36 | } 37 | } 38 | 39 | func (m *TrustStoreAudit) View() string { 40 | var b strings.Builder 41 | 42 | if m.auditInfo == nil { 43 | fmt.Fprintln(&b, ui.StepInProgress(fmt.Sprintf("Comparing local and expected CA certificates…%s", m.spinner.View()))) 44 | return b.String() 45 | } 46 | 47 | if len(m.auditInfo.Missing) > 0 { 48 | fmt.Fprintln(&b, ui.StepDone(fmt.Sprintf("Compared local and expected CA certificates: need to install %d missing certificates.", len(m.auditInfo.Missing)))) 49 | return b.String() 50 | } 51 | 52 | fmt.Fprintln(&b, ui.StepDone("Compared local and expected CA certificates: no updates needed.")) 53 | return b.String() 54 | } 55 | -------------------------------------------------------------------------------- /truststore/nss_test.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseCertNick(t *testing.T) { 8 | var tests = []struct { 9 | name string 10 | 11 | line string 12 | 13 | nick string 14 | }{ 15 | { 16 | name: "normal", 17 | 18 | line: "f016d6d279570cd2ac25debd C,, ", 19 | 20 | nick: "f016d6d279570cd2ac25debd", 21 | }, 22 | { 23 | name: "long", 24 | 25 | line: "docert development CA FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF C,, ", 26 | 27 | nick: "docert development CA FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 28 | }, 29 | { 30 | name: "very-long", 31 | 32 | line: "Baddy Local Authority - 2024 ECC Root 000000000000000000000000000000000000000 C,, ", 33 | 34 | nick: "Baddy Local Authority - 2024 ECC Root 000000000000000000000000000000000000000", 35 | }, 36 | } 37 | 38 | for _, test := range tests { 39 | t.Run(test.name, func(t *testing.T) { 40 | if want, got := test.nick, parseCertNick(test.line); want != got { 41 | t.Errorf("want parsed cert nick %q, got %q", want, got) 42 | } 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /truststore/platform.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | func (s *Platform) Check() (bool, error) { 4 | ok, err := s.check() 5 | if err != nil { 6 | err = Error{ 7 | Op: OpCheck, 8 | 9 | Warning: PlatformError{ 10 | Err: err, 11 | 12 | NSSBrowsers: nssBrowsers, 13 | }, 14 | } 15 | } 16 | return ok, err 17 | } 18 | 19 | func (s *Platform) CheckCA(ca *CA) (installed bool, err error) { 20 | if _, cerr := s.check(); cerr != nil { 21 | defer func() { 22 | err = Error{ 23 | Op: OpCheck, 24 | 25 | Warning: PlatformError{ 26 | Err: cerr, 27 | 28 | NSSBrowsers: nssBrowsers, 29 | RootCA: ca.FilePath, 30 | }, 31 | } 32 | }() 33 | } 34 | 35 | return s.checkCA(ca) 36 | } 37 | 38 | func (s *Platform) InstallCA(ca *CA) (installed bool, err error) { 39 | if _, cerr := s.check(); cerr != nil { 40 | defer func() { 41 | err = Error{ 42 | Op: OpInstall, 43 | 44 | Warning: PlatformError{ 45 | Err: cerr, 46 | 47 | NSSBrowsers: nssBrowsers, 48 | RootCA: ca.FilePath, 49 | }, 50 | } 51 | }() 52 | } 53 | 54 | return s.installCA(ca) 55 | } 56 | 57 | func (s *Platform) ListCAs() (cas []*CA, err error) { 58 | if _, cerr := s.check(); cerr != nil { 59 | defer func() { 60 | err = Error{ 61 | Op: OpList, 62 | 63 | Warning: PlatformError{ 64 | Err: cerr, 65 | 66 | NSSBrowsers: nssBrowsers, 67 | }, 68 | } 69 | }() 70 | } 71 | 72 | return s.listCAs() 73 | } 74 | 75 | func (s *Platform) UninstallCA(ca *CA) (uninstalled bool, err error) { 76 | if _, cerr := s.check(); cerr != nil { 77 | defer func() { 78 | err = Error{ 79 | Op: OpUninstall, 80 | 81 | Warning: PlatformError{ 82 | Err: cerr, 83 | 84 | NSSBrowsers: nssBrowsers, 85 | RootCA: ca.FilePath, 86 | }, 87 | } 88 | }() 89 | } 90 | 91 | return s.uninstallCA(ca) 92 | } 93 | -------------------------------------------------------------------------------- /truststore/platform_test.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "errors" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | func TestParseInvalidCerts(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | 15 | data string 16 | 17 | cert *x509.Certificate 18 | err error 19 | }{ 20 | { 21 | name: "duplicate-extension", 22 | 23 | data: dupExtensionData, 24 | }, 25 | { 26 | name: "inner-outer-signature-mismatch", 27 | 28 | data: signatureMismatchData, 29 | }, 30 | 31 | { 32 | name: "negative-serial-number", 33 | 34 | data: negativeSerialNumberData, 35 | }, 36 | } 37 | 38 | for _, test := range tests { 39 | t.Run(test.name, func(t *testing.T) { 40 | blk, _ := pem.Decode([]byte(test.data)) 41 | cert, err := parseCertificate(blk.Bytes) 42 | if want, got := test.cert, cert; !reflect.DeepEqual(want, got) { 43 | t.Errorf("expect parsed certificate %+v, got %+v", want, got) 44 | } 45 | if want, got := test.err, err; !errors.Is(want, got) { 46 | t.Errorf("expect err %s, got %s", want, got) 47 | } 48 | }) 49 | } 50 | } 51 | 52 | var ( 53 | dupExtensionData = `-----BEGIN CERTIFICATE----- 54 | MIIDLDCCAhSgAwIBAgIESJZhgDANBgkqhkiG9w0BAQsFADA7MR8wHQYDVQQDDBZj 55 | b20uYXBwbGUua2VyYmVyb3Mua2RjMRgwFgYDVQQKDA9TeXN0ZW0gSWRlbnRpdHkw 56 | HhcNMTYxMjIxMjIzODE5WhcNMzYxMjE2MjIzODE5WjA7MR8wHQYDVQQDDBZjb20u 57 | YXBwbGUua2VyYmVyb3Mua2RjMRgwFgYDVQQKDA9TeXN0ZW0gSWRlbnRpdHkwggEi 58 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCw+KArez/ODHC5bcIjIhkLMe4d 59 | 06gLxOmc9NN1yG3bPSp6I/7kOc26f70YttvK6loPpEyGRTxyyC7RepALi+TtC6Ia 60 | BcwrTQTLVfHND0a0yjZLfTTTFTl76BYVdBoP1Ta6Kh0/+ufAjpUvFOGo3fES0JRt 61 | aFyluO/nFH2GGuwwOl5r3+CRECv2ipJcbHyYllpP6oAS1LK59m2UYj/rlmzD4Kx9 62 | NENwSuoaOBXsg74lQsX5JYgPA/UTv2TfjVLOw6yyncgfwie+nTd4XphxnKpedFWf 63 | iYdZeMJQPzFw47GbtjBrfW8FwKTQbCoosAEFpQ6cwoazKSzt7ICS9J8zur5FAgMB 64 | AAGjODA2MAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATASBgNVHSUE 65 | CzAJBgcrBgEFAgMFMA0GCSqGSIb3DQEBCwUAA4IBAQB57yZrn3PrWMZucdcfOcy2 66 | lzth2iYGZvNQ2YNnmfU3W9Umi8Ku7PVlVAR3DaFQIs99uejlvrjvLrYLyIhklA1f 67 | hyGljDJFHZVceAO0qHA5gjd0p95Z4l+04NqKPcdV4PjSM3RSX9LiWieizJHezloe 68 | gHtW2OUPVe138Ic2OAQNB0e0/0FK6h/B96sYcskvwZF2xnOkjOFJimh5iUPIemtT 69 | Oi3a6RdwSBzfJTtO9bSQ+lGdkmJAQ0XB3REJPIcLDz7QIG8cRXX4yFnjaHw0kM12 70 | ZyvlXrsgZkrum/0zNBWAnp/MEeTPJzsl75Fu2C+qO7IRMeirP4/Jf6+SWy3BxXNz 71 | -----END CERTIFICATE-----` 72 | negativeSerialNumberData = `-----BEGIN CERTIFICATE----- 73 | MIIBBTCBraADAgECAgH/MAoGCCqGSM49BAMCMA0xCzAJBgNVBAMTAjopMB4XDTIy 74 | MDQxNDIzNTYwNFoXDTIyMDQxNTAxNTYwNFowDTELMAkGA1UEAxMCOikwWTATBgcq 75 | hkjOPQIBBggqhkjOPQMBBwNCAAQ9ezsIsj+q17K87z/PXE/rfGRN72P/Wyn5d6oo 76 | 5M0ZbSatuntMvfKdX79CQxXAxN4oXk3Aov4jVSG12AcDI8ShMAoGCCqGSM49BAMC 77 | A0cAMEQCIBzfBU5eMPT6m5lsR6cXaJILpAaiD9YxOl4v6dT3rzEjAiBHmjnHmAss 78 | RqUAyJKFzqZxOlK2q4j2IYnuj5+LrLGbQA== 79 | -----END CERTIFICATE-----` 80 | signatureMismatchData = `-----BEGIN CERTIFICATE----- 81 | MIICFDCCAX2gAwIBAgIECZcijjALBgkqhkiG9w0BAQUwPDEgMB4GA1UEAwwXY29t 82 | LmFwcGxlLnN5c3RlbWRlZmF1bHQxGDAWBgNVBAoMD1N5c3RlbSBJZGVudGl0eTAe 83 | Fw0xNTAzMjUyMTEwMjZaFw0zNTAzMjAyMTEwMjZaMDwxIDAeBgNVBAMMF2NvbS5h 84 | cHBsZS5zeXN0ZW1kZWZhdWx0MRgwFgYDVQQKDA9TeXN0ZW0gSWRlbnRpdHkwgZ8w 85 | DQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBALonEb9P9qvj1BiLCQp86oLkVC+riuqd 86 | llz1JJEtbr2BFEAa/j0CH+FptthLizHSWsVdHN+CrPZUa1rCgVlkuz14huzSPrQG 87 | UjNjpJVDBmlr/T+ALmHewmykM9Sa3yOETVr8q49odjisfcIHLwS+ivymLDgU3mPJ 88 | Qss2jW+Av6lRAgMBAAGjJTAjMAsGA1UdDwQEAwIEsDAUBgNVHSUEDTALBgkqhkiG 89 | 92NkBAQwDQYJKoZIhvcNAQEFBQADgYEAroEWpwSHikgb1zjueWPdXwY4o+W+zFqY 90 | uVbrTzd+Tv8SIfgw8+D4Hf9iLLY33yy6CIMZY2xgfGgBh0suSidoLJt3Pr0fiQGK 91 | d5IUuavJmM5HeYXlPfg/WxvtcwaB1DlPxGpe3ZsRi2GPBZpxVS1AdwKUk5GmoH4G 92 | J1hlJQKJ8yY= 93 | -----END CERTIFICATE-----` 94 | ) 95 | -------------------------------------------------------------------------------- /truststore/truststore.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import ( 4 | "crypto/sha1" 5 | "crypto/subtle" 6 | "crypto/x509" 7 | "encoding/hex" 8 | "fmt" 9 | "io/fs" 10 | "os" 11 | "regexp" 12 | "strings" 13 | ) 14 | 15 | type Store interface { 16 | Check() (bool, error) 17 | Description() string 18 | 19 | CheckCA(*CA) (bool, error) 20 | InstallCA(*CA) (bool, error) 21 | ListCAs() ([]*CA, error) 22 | UninstallCA(*CA) (bool, error) 23 | } 24 | 25 | type CA struct { 26 | *x509.Certificate 27 | 28 | FilePath string 29 | NickName string // only used by nss 30 | UniqueName string 31 | } 32 | 33 | func (c *CA) Equal(ca *CA) bool { 34 | return c.UniqueName == ca.UniqueName && subtle.ConstantTimeCompare(c.Raw, ca.Raw) == 1 35 | } 36 | 37 | var reWindowsThumbprintSpacer = regexp.MustCompile(".{8}") 38 | 39 | func (c *CA) WindowsThumbprint() string { 40 | certSha := sha1.Sum(c.Raw) 41 | certHex := strings.ToUpper(hex.EncodeToString(certSha[:])) 42 | thumbprint := strings.TrimRight(reWindowsThumbprintSpacer.ReplaceAllString(certHex, "$0 "), " ") 43 | return thumbprint 44 | } 45 | 46 | func fatalErr(err error, msg string) error { 47 | return fmt.Errorf("%s: %w", msg, err) 48 | } 49 | 50 | func fatalCmdErr(err error, cmd string, out []byte) error { 51 | return fmt.Errorf("failed to execute \"%s\": %w\n\n%s\n", cmd, err, out) 52 | } 53 | 54 | func binaryExists(fs CmdFS, name string) bool { 55 | _, err := fs.LookPath(name) 56 | return err == nil 57 | } 58 | 59 | func pathExists(sfs fs.StatFS, path string) bool { 60 | _, err := sfs.Stat(strings.Trim(path, string(os.PathSeparator))) 61 | return err == nil 62 | } 63 | 64 | func parseCertificate(der []byte) (*x509.Certificate, error) { 65 | cert, err := x509.ParseCertificate(der) 66 | if err != nil { 67 | if strings.HasPrefix(err.Error(), "x509: certificate contains duplicate extension") { 68 | return nil, nil 69 | } 70 | if strings.HasPrefix(err.Error(), "x509: inner and outer signature algorithm identifiers don't match") { 71 | return nil, nil 72 | } 73 | if strings.HasPrefix(err.Error(), "x509: negative serial number") { 74 | return nil, nil 75 | } 76 | return nil, err 77 | } 78 | return cert, nil 79 | } 80 | -------------------------------------------------------------------------------- /truststore/truststore_test.go: -------------------------------------------------------------------------------- 1 | package truststore 2 | 3 | import ( 4 | "crypto/x509" 5 | "crypto/x509/pkix" 6 | "io/fs" 7 | "os" 8 | "path" 9 | "slices" 10 | "testing" 11 | "testing/fstest" 12 | "time" 13 | 14 | "github.com/anchordotdev/cli/internal/must" 15 | _ "github.com/anchordotdev/cli/testflags" 16 | ) 17 | 18 | func TestParseCertificate(t *testing.T) { 19 | cert, err := parseCertificate(validCA.Raw) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | if cert == nil { 24 | t.Fatal("expect parse certificate with valid certificate to return certificate") 25 | } 26 | } 27 | 28 | func testStore(t *testing.T, store Store) { 29 | if ok, err := store.Check(); err != nil { 30 | t.Fatal(err) 31 | } else if !ok { 32 | t.Fatalf("%q: initial check failed", store.Description()) 33 | } 34 | 35 | if initialCAs, err := store.ListCAs(); err != nil { 36 | t.Fatal(err) 37 | } else if slices.ContainsFunc(initialCAs, ca.Equal) { 38 | t.Fatalf("%q: initial ca list already contains %+v ca", store, ca) 39 | } 40 | 41 | ok, err := store.CheckCA(ca) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | if ok { 46 | t.Fatalf("%q: check ca with %+v unexpectedly passed", store.Description(), ca) 47 | } 48 | 49 | if ok, err = store.InstallCA(ca); err != nil { 50 | t.Fatal(err) 51 | } 52 | if !ok { 53 | t.Fatalf("%q: install ca with %+v failed", store.Description(), ca) 54 | } 55 | 56 | if ok, err = store.CheckCA(ca); err != nil { 57 | t.Fatal(err) 58 | } 59 | if !ok { 60 | t.Fatalf("%q: check ca with %+v failed", store.Description(), ca) 61 | } 62 | 63 | if allCAs, err := store.ListCAs(); err != nil { 64 | t.Fatal(err) 65 | } else if !slices.ContainsFunc(allCAs, ca.Equal) { 66 | t.Fatalf("%q: ca list does not contain %+v ca", store.Description(), ca) 67 | } 68 | 69 | if ok, err := store.UninstallCA(ca); err != nil { 70 | t.Fatal(err) 71 | } else if !ok { 72 | t.Fatalf("%q: uninstall ca with %+v failed", store.Description(), ca) 73 | } 74 | 75 | if allCAs, err := store.ListCAs(); err != nil { 76 | t.Fatal(err) 77 | } else if slices.ContainsFunc(allCAs, ca.Equal) { 78 | t.Fatalf("%q: ca list still contains %+v ca", store.Description(), ca) 79 | } 80 | } 81 | 82 | var ca = mustCA(must.CA(&x509.Certificate{ 83 | Subject: pkix.Name{ 84 | CommonName: "Example CA", 85 | Organization: []string{"Example, Inc"}, 86 | }, 87 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, 88 | 89 | ExtraExtensions: []pkix.Extension{}, 90 | })) 91 | 92 | func mustCA(cert *must.Certificate) *CA { 93 | uniqueName := cert.Leaf.SerialNumber.Text(16) 94 | 95 | return &CA{ 96 | Certificate: cert.Leaf, 97 | FilePath: "example-ca-" + uniqueName + ".pem", 98 | UniqueName: uniqueName, 99 | } 100 | } 101 | 102 | type TestFS fstest.MapFS 103 | 104 | func (fsys TestFS) Open(name string) (fs.File, error) { return fstest.MapFS(fsys).Open(name) } 105 | func (fsys TestFS) ReadFile(name string) ([]byte, error) { return fstest.MapFS(fsys).ReadFile(name) } 106 | func (fsys TestFS) Stat(name string) (fs.FileInfo, error) { return fstest.MapFS(fsys).Stat(name) } 107 | 108 | func (fsys TestFS) AppendToFile(name string, p []byte) error { 109 | f, ok := fsys[name] 110 | if !ok { 111 | f = new(fstest.MapFile) 112 | fsys[name] = f 113 | } 114 | 115 | f.Data = append(f.Data, p...) 116 | f.Sys = mapFI{ 117 | name: name, 118 | size: len(f.Data), 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (fsys TestFS) Remove(name string) error { 125 | delete(fsys, name) 126 | return nil 127 | } 128 | 129 | func (fsys TestFS) Rename(oldpath, newpath string) error { 130 | fsys[newpath] = fsys[oldpath] 131 | return fsys.Remove(oldpath) 132 | } 133 | 134 | // golang.org/x/tools/godoc/vfs/mapfs 135 | 136 | type mapFI struct { 137 | name string 138 | size int 139 | dir bool 140 | } 141 | 142 | func (fi mapFI) IsDir() bool { return fi.dir } 143 | func (fi mapFI) ModTime() time.Time { return time.Time{} } 144 | func (fi mapFI) Mode() os.FileMode { 145 | if fi.IsDir() { 146 | return 0755 | os.ModeDir 147 | } 148 | return 0444 149 | } 150 | func (fi mapFI) Name() string { return path.Base(fi.name) } 151 | func (fi mapFI) Size() int64 { return int64(fi.size) } 152 | func (fi mapFI) Sys() interface{} { return nil } 153 | -------------------------------------------------------------------------------- /ui/models.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | type Error struct { 11 | tea.Model 12 | 13 | Err error // reported error 14 | } 15 | 16 | func (e Error) Error() string { 17 | if e.Err == nil { 18 | return "" 19 | } 20 | return e.Err.Error() 21 | } 22 | 23 | func (e Error) Unwrap() error { return e.Err } 24 | 25 | type MessageLines []string 26 | 27 | func (MessageLines) Init() tea.Cmd { return nil } 28 | 29 | func (m MessageLines) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 30 | 31 | func (m MessageLines) View() string { 32 | var b strings.Builder 33 | for _, line := range m { 34 | fmt.Fprintln(&b, line) 35 | } 36 | return b.String() 37 | } 38 | 39 | type MessageFunc func(*strings.Builder) 40 | 41 | func (MessageFunc) Init() tea.Cmd { return nil } 42 | 43 | func (m MessageFunc) Update(tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 44 | 45 | func (m MessageFunc) View() string { 46 | var b strings.Builder 47 | m(&b) 48 | return b.String() 49 | } 50 | 51 | type Section struct { 52 | Name string 53 | 54 | tea.Model 55 | } 56 | 57 | func (s Section) Section() string { return s.Name } 58 | -------------------------------------------------------------------------------- /version/command.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/anchordotdev/cli" 9 | "github.com/anchordotdev/cli/ui" 10 | "github.com/anchordotdev/cli/version/models" 11 | ) 12 | 13 | var CmdVersion = cli.NewCmd[Command](cli.CmdRoot, "version", func(cmd *cobra.Command) {}) 14 | 15 | type Command struct{} 16 | 17 | func (c Command) UI() cli.UI { 18 | return cli.UI{ 19 | RunTUI: c.runTUI, 20 | } 21 | } 22 | 23 | func (c Command) runTUI(ctx context.Context, drv *ui.Driver) error { 24 | drv.Activate(ctx, models.VersionHeader) 25 | 26 | drv.Activate(ctx, &models.Version{ 27 | Arch: cli.Version.Arch, 28 | Commit: cli.Version.Commit, 29 | Date: cli.Version.Date, 30 | OS: cli.Version.Os, 31 | Version: cli.Version.Version, 32 | }) 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /version/command_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/anchordotdev/cli" 10 | "github.com/anchordotdev/cli/cmdtest" 11 | _ "github.com/anchordotdev/cli/testflags" 12 | "github.com/anchordotdev/cli/ui/uitest" 13 | ) 14 | 15 | func TestCmdVersion(t *testing.T) { 16 | t.Run("--help", func(t *testing.T) { 17 | cmdtest.TestHelp(t, CmdVersion, "version", "--help") 18 | }) 19 | } 20 | 21 | func TestCommand(t *testing.T) { 22 | t.Run(fmt.Sprintf("golden-%s_%s", runtime.GOOS, runtime.GOARCH), func(t *testing.T) { 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | defer cancel() 25 | 26 | ctx = cli.ContextWithConfig(ctx, cmdtest.Config(ctx)) 27 | 28 | cmd := Command{} 29 | 30 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /version/models/upgrade.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | var ( 12 | VersionUpgradeHeader = ui.Section{ 13 | Name: "VersionUpgradeHeader", 14 | Model: ui.MessageLines{ 15 | ui.Header(fmt.Sprintf("Check for Upgrade %s", ui.Whisper("`anchor version upgrade`"))), 16 | }, 17 | } 18 | 19 | VersionUpgradeUnavailable = ui.Section{ 20 | Name: "VersionUpgradeUnavailable", 21 | Model: ui.MessageLines{ 22 | ui.StepAlert("Already up to date!"), 23 | ui.StepHint("Your anchor CLI is already up to date, check back soon for updates."), 24 | }, 25 | } 26 | ) 27 | 28 | type VersionUpgrade struct { 29 | Command string 30 | InClipboard bool 31 | } 32 | 33 | func (m *VersionUpgrade) Init() tea.Cmd { return nil } 34 | 35 | func (m *VersionUpgrade) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 36 | 37 | func (m *VersionUpgrade) View() string { 38 | var b strings.Builder 39 | 40 | if m.InClipboard { 41 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("Copied %s to your clipboard.", ui.Announce(m.Command)))) 42 | } 43 | 44 | fmt.Fprintln(&b, ui.StepAlert(fmt.Sprintf("%s `%s` to update to the latest version.", ui.Action("Run"), ui.Emphasize(m.Command)))) 45 | fmt.Fprintln(&b, ui.StepHint(fmt.Sprintf("Not using homebrew? Explore other options here: %s", ui.URL("https://github.com/anchordotdev/cli")))) 46 | 47 | return b.String() 48 | } 49 | -------------------------------------------------------------------------------- /version/models/version.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchordotdev/cli/ui" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | var ( 12 | VersionHeader = ui.Section{ 13 | Name: "VersionHeader", 14 | Model: ui.MessageLines{ 15 | ui.Header(fmt.Sprintf("Show Version Info %s", ui.Whisper("`anchor version'"))), 16 | }, 17 | } 18 | ) 19 | 20 | type Version struct { 21 | Arch, Commit, Date, OS, Version string 22 | } 23 | 24 | func (m *Version) Init() tea.Cmd { return nil } 25 | 26 | func (m *Version) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } 27 | 28 | func (m *Version) View() string { 29 | var b strings.Builder 30 | fmt.Fprintf(&b, "%s (%s/%s) Commit: %s BuildDate: %s\n", m.Version, m.OS, m.Arch, m.Commit, m.Date) 31 | return b.String() 32 | } 33 | -------------------------------------------------------------------------------- /version/testdata/TestCmdVersion/--help.golden: -------------------------------------------------------------------------------- 1 | Show Version Info 2 | 3 | Usage: 4 | anchor version [flags] 5 | anchor version [command] 6 | 7 | Available Commands: 8 | upgrade Check for Upgrade 9 | 10 | Flags: 11 | -h, --help help for version 12 | 13 | Global Flags: 14 | --api-token string Anchor API personal access token (PAT). 15 | --config string Service configuration file. (default "anchor.toml") 16 | --skip-config Skip loading configuration file. 17 | 18 | Use "anchor version [command] --help" for more information about a command. 19 | -------------------------------------------------------------------------------- /version/testdata/TestCommand/golden-darwin_arm64.golden: -------------------------------------------------------------------------------- 1 | ─── VersionHeader ────────────────────────────────────────────────────────────── 2 | 3 | # Show Version Info `anchor version' 4 | ─── Version ──────────────────────────────────────────────────────────────────── 5 | 6 | # Show Version Info `anchor version' 7 | dev (darwin/arm64) Commit: none BuildDate: unknown 8 | -------------------------------------------------------------------------------- /version/testdata/TestCommand/golden-linux_amd64.golden: -------------------------------------------------------------------------------- 1 | ─── VersionHeader ────────────────────────────────────────────────────────────── 2 | 3 | # Show Version Info `anchor version' 4 | ─── Version ──────────────────────────────────────────────────────────────────── 5 | 6 | # Show Version Info `anchor version' 7 | dev (linux/amd64) Commit: none BuildDate: unknown 8 | -------------------------------------------------------------------------------- /version/testdata/TestCommand/golden-windows_amd64.golden: -------------------------------------------------------------------------------- 1 | ─── VersionHeader ────────────────────────────────────────────────────────────── 2 | 3 | # Show Version Info `anchor version' 4 | ─── Version ──────────────────────────────────────────────────────────────────── 5 | 6 | # Show Version Info `anchor version' 7 | dev (windows/amd64) Commit: none BuildDate: unknown 8 | -------------------------------------------------------------------------------- /version/testdata/TestUpgrade/upgrade-available-unix.golden: -------------------------------------------------------------------------------- 1 | ─── VersionUpgradeHeader ─────────────────────────────────────────────────────── 2 | 3 | # Check for Upgrade `anchor version upgrade` 4 | ─── VersionUpgrade ───────────────────────────────────────────────────────────── 5 | 6 | # Check for Upgrade `anchor version upgrade` 7 | ! Copied brew update && brew upgrade anchor to your clipboard. 8 | ! Run `brew update && brew upgrade anchor` to update to the latest version. 9 | | Not using homebrew? Explore other options here: https://github.com/anchordotdev/cli 10 | -------------------------------------------------------------------------------- /version/testdata/TestUpgrade/upgrade-available-windows.golden: -------------------------------------------------------------------------------- 1 | ─── VersionUpgradeHeader ─────────────────────────────────────────────────────── 2 | 3 | # Check for Upgrade `anchor version upgrade` 4 | ─── VersionUpgrade ───────────────────────────────────────────────────────────── 5 | 6 | # Check for Upgrade `anchor version upgrade` 7 | ! Copied winget update Anchor.cli to your clipboard. 8 | ! Run `winget update Anchor.cli` to update to the latest version. 9 | | Not using homebrew? Explore other options here: https://github.com/anchordotdev/cli 10 | -------------------------------------------------------------------------------- /version/testdata/TestUpgrade/upgrade-unavailable.golden: -------------------------------------------------------------------------------- 1 | ─── VersionUpgradeHeader ─────────────────────────────────────────────────────── 2 | 3 | # Check for Upgrade `anchor version upgrade` 4 | ─── VersionUpgradeUnavailable ────────────────────────────────────────────────── 5 | 6 | # Check for Upgrade `anchor version upgrade` 7 | ! Already up to date! 8 | | Your anchor CLI is already up to date, check back soon for updates. 9 | -------------------------------------------------------------------------------- /version/upgrade.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/anchordotdev/cli" 9 | "github.com/anchordotdev/cli/clipboard" 10 | "github.com/anchordotdev/cli/ui" 11 | "github.com/anchordotdev/cli/version/models" 12 | ) 13 | 14 | var CmdVersionUpgrade = cli.NewCmd[Upgrade](CmdVersion, "upgrade", func(cmd *cobra.Command) {}) 15 | 16 | type Upgrade struct { 17 | Clipboard cli.Clipboard 18 | } 19 | 20 | func (c Upgrade) UI() cli.UI { 21 | return cli.UI{ 22 | RunTUI: c.runTUI, 23 | } 24 | } 25 | 26 | func (c Upgrade) runTUI(ctx context.Context, drv *ui.Driver) error { 27 | cli.SkipReleaseCheck = true 28 | 29 | drv.Activate(ctx, models.VersionUpgradeHeader) 30 | 31 | if c.Clipboard == nil { 32 | c.Clipboard = clipboard.System 33 | } 34 | 35 | command := "brew update && brew upgrade anchor" 36 | if isWindowsRuntime(cli.ConfigFromContext(ctx)) { 37 | command = "winget update Anchor.cli" 38 | } 39 | 40 | isUpgradeable, err := cli.IsUpgradeable(ctx) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | clipboardErr := c.Clipboard.WriteAll(command) 46 | 47 | if isUpgradeable { 48 | drv.Activate(ctx, &models.VersionUpgrade{ 49 | InClipboard: (clipboardErr == nil), 50 | Command: command, 51 | }) 52 | 53 | return nil 54 | } 55 | 56 | drv.Activate(ctx, &models.VersionUpgradeUnavailable) 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /version/upgrade_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/anchordotdev/cli" 9 | "github.com/anchordotdev/cli/clipboard" 10 | "github.com/anchordotdev/cli/cmdtest" 11 | "github.com/anchordotdev/cli/ui/uitest" 12 | ) 13 | 14 | func TestUpgrade(t *testing.T) { 15 | ctx, cancel := context.WithCancel(context.Background()) 16 | defer cancel() 17 | 18 | cfg := cmdtest.Config(ctx) 19 | ctx = cli.ContextWithConfig(ctx, cfg) 20 | 21 | t.Run(fmt.Sprintf("upgrade-available-%s", uitest.TestTagOS()), func(t *testing.T) { 22 | tagName := "vupgrade" 23 | cli.LatestRelease = &cli.Release{ 24 | TagName: &tagName, 25 | } 26 | cmd := Upgrade{ 27 | Clipboard: new(clipboard.Mock), 28 | } 29 | 30 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 31 | 32 | command, err := cmd.Clipboard.ReadAll() 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | var want string 38 | if cfg.GOOS() == "windows" { 39 | want = "winget update Anchor.cli" 40 | } else { 41 | want = "brew update && brew upgrade anchor" 42 | } 43 | if got := command; want != got { 44 | t.Errorf("Want command:\n\n%q,\n\nGot:\n\n%q\n\n", want, got) 45 | } 46 | }) 47 | 48 | t.Run("upgrade-unavailable", func(t *testing.T) { 49 | tagName := "vdev" 50 | cli.LatestRelease = &cli.Release{ 51 | TagName: &tagName, 52 | } 53 | cmd := Upgrade{} 54 | 55 | uitest.TestTUIOutput(ctx, t, cmd.UI()) 56 | }) 57 | 58 | } 59 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Masterminds/semver" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/anchordotdev/cli" 10 | "github.com/anchordotdev/cli/ui" 11 | ) 12 | 13 | func ReleaseCheck(cmd *cobra.Command, args []string) error { 14 | if cli.SkipReleaseCheck || cli.IsDevVersion() { 15 | return nil 16 | } 17 | 18 | ctx := cmd.Context() 19 | 20 | isFresh, err := cli.IsFreshLatestRelease(ctx) 21 | if err != nil { 22 | return err 23 | } 24 | if isFresh { 25 | return nil 26 | } 27 | 28 | isUpgradeable, err := cli.IsUpgradeable(ctx) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if isUpgradeable { 34 | fmt.Println(ui.Header("New CLI Release Available")) 35 | fmt.Println(ui.StepAlert("Run `anchor version upgrade` to upgrade.")) 36 | } 37 | return nil 38 | } 39 | 40 | func isWindowsRuntime(cfg *cli.Config) bool { 41 | return cfg.GOOS() == "windows" 42 | } 43 | 44 | func MinimumVersionCheck(minimumVersion string) error { 45 | if cli.IsDevVersion() { 46 | return nil 47 | } 48 | 49 | minVersion, err := semver.NewVersion(minimumVersion) 50 | if err != nil { 51 | return nil // unexpected version string from the server 52 | } 53 | 54 | cliVersion, err := semver.NewVersion(cli.Version.Version) 55 | if err != nil { 56 | return nil 57 | } 58 | 59 | if cliVersion.LessThan(minVersion) { 60 | return ui.Error{ 61 | Model: ui.Section{ 62 | Name: "MinimumVersionCheck", 63 | Model: ui.MessageLines{ 64 | ui.Header( 65 | fmt.Sprintf("%s This version of the Anchor CLI is out-of-date, please update.", 66 | ui.Danger("Error!"), 67 | ), 68 | ), 69 | }, 70 | }, 71 | } 72 | } 73 | return nil 74 | } 75 | --------------------------------------------------------------------------------