├── .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 | 
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 |
--------------------------------------------------------------------------------