Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ linters:
# the terminal UI shows what it holds back of the log
- github.com/meshcloud/meshstack-cli/internal/logs
- github.com/meshcloud/meshstack-cli/cmd/internal
# the terminal UI runs meshstack login for the profile it highlights
- github.com/meshcloud/meshstack-cli/cmd/auth
- github.com/meshcloud/meshstack-cli/client
- github.com/spf13/cobra
# the terminal UI: a table of the profiles, a form to add or edit one, and a dialog
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func New() *cobra.Command {
},
}

cmd.AddCommand(newLogin())
cmd.AddCommand(newLogin(nil))
cmd.AddCommand(newLogout())
cmd.AddCommand(newStatus())
cmd.AddCommand(newToken())
Expand Down
13 changes: 11 additions & 2 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package auth

import (
"context"
"errors"
"fmt"
"slices"
"uuid"

"github.com/spf13/cobra"
Expand All @@ -17,12 +19,18 @@ import (
)

func NewLoginShortcut() *cobra.Command {
cmd := newLogin()
cmd := newLogin(nil)
cmd.Short += " (same as auth login)"
return cmd
}

func newLogin() *cobra.Command {
// NewLoginTo logs in to the profile name, whichever profile the flags and the environment name.
func NewLoginTo(name profile.Name) *cobra.Command {
return newLogin(setting.Sources{setting.LookupSource(setting.Profile.EnvKey(), "the profile highlighted in meshstack profile",
func(context.Context) (string, error) { return string(name), nil })})
}

func newLogin(profileSources setting.Sources) *cobra.Command {
var (
openStdinFlag = newStdinFlag()
apiKeyFlag = internal.NewFlagForSetting[uuid.UUID]("apikey", setting.ApiKeyClientId)
Expand Down Expand Up @@ -53,6 +61,7 @@ It ends with what meshstack auth status shows for the new login.`,
RunE: func(cmd *cobra.Command, _ []string) (err error) {
ctx := cmd.Context()
opts := internal.ResolveClientOptions()
opts.SettingSources = slices.Concat(profileSources, opts.SettingSources)
promptedFrom := prompt.New(cmd.InOrStdin(), cmd.ErrOrStderr())
var authWith credential.Name
switch {
Expand Down
2 changes: 1 addition & 1 deletion cmd/internal/markdown/credential.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
| | {{template "access" .}} |
{{- end}}
{{- with .OidcLogin}}
| | {{if .SessionEndsAt.IsZero}}Session with no end known{{else if .SessionEnded}}Session ended {{at .SessionEndsAt}}, run `meshstack login -p {{$.Profile}}`{{else}}Session ends at the latest {{at .SessionEndsAt}}, then run `meshstack login -p {{$.Profile}}`{{end}} |
| | {{if .SessionEndsAt.IsZero}}Session with no end known{{else if .SessionEnded}}💤 Session ended {{at .SessionEndsAt}}, run `meshstack login -p {{$.Profile}}`{{else}}Session ends at the latest {{at .SessionEndsAt}}, then run `meshstack login -p {{$.Profile}}`{{end}} |
{{- end}}
| | {{with $token}}{{if .Expired}}Token expired {{at .ExpiresAt}}, {{if eq $.Kind "manual"}}cannot be renewed, run `meshstack login -p {{$.Profile}} --apitoken`{{else if and $.OidcLogin $.OidcLogin.SessionEnded}}cannot be renewed{{else}}renewed on next use{{end}}
{{- else}}Token expires {{at .ExpiresAt}}{{end}}{{with .Workspace}}, for workspace {{cell .}}{{end}}
Expand Down
66 changes: 60 additions & 6 deletions cmd/profile/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strings"

Expand All @@ -14,19 +15,25 @@ import (
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/spf13/cobra"

cmdauth "github.com/meshcloud/meshstack-cli/cmd/auth"
"github.com/meshcloud/meshstack-cli/cmd/internal/markdown"
"github.com/meshcloud/meshstack-cli/cmd/internal/prompt"
"github.com/meshcloud/meshstack-cli/internal/auth"
"github.com/meshcloud/meshstack-cli/internal/logs"
"github.com/meshcloud/meshstack-cli/internal/profile"
)

const currentMarker = "🏠"
const (
currentMarker = "🏠"
loginEndedMarker = "💤"
)

type keyMap struct{ move, edit, add, remove, use, quit key.Binding }
type keyMap struct{ move, edit, add, remove, use, login, quit key.Binding }

func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.move, k.edit, k.add, k.remove, k.use, k.quit}
return []key.Binding{k.move, k.edit, k.add, k.remove, k.use, k.login, k.quit}
}

func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{k.ShortHelp()} }
Expand All @@ -38,6 +45,7 @@ var keys = keyMap{
add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add ➕")),
remove: key.NewBinding(key.WithKeys("d", "delete"), key.WithHelp("d", "delete ❌")),
use: key.NewBinding(key.WithKeys("u", "space"), key.WithHelp("u", "set "+currentMarker)),
login: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "log in 🔑")),
quit: key.NewBinding(key.WithKeys("q", "esc"), key.WithHelp("q", "quit")),
}

Expand Down Expand Up @@ -118,7 +126,7 @@ func (m model) tableView() string {
body, shownKeys := m.tableBody(), keys
if len(m.shown) == 0 {
body = m.styles.Focused.Base.Render(m.styles.Focused.Description.Render("There is no profile yet."))
for _, binding := range []*key.Binding{&shownKeys.move, &shownKeys.edit, &shownKeys.remove, &shownKeys.use} {
for _, binding := range []*key.Binding{&shownKeys.move, &shownKeys.edit, &shownKeys.remove, &shownKeys.use, &shownKeys.login} {
binding.SetEnabled(false)
}
}
Expand Down Expand Up @@ -193,6 +201,9 @@ func (m model) withRows(highlight profile.Name) model {
if p.Name == m.CurrentProfile {
r[0] = currentMarker
}
if auth.BrowserLoginEnded(m.ctx, p) {
r[0] += loginEndedMarker
}
if p.Endpoint.URL != nil {
r[2] = p.Endpoint.String()
}
Expand Down Expand Up @@ -273,6 +284,18 @@ func (m model) update(msg tea.Msg) (model, tea.Cmd) {
return m, nil
}
return m, m.form.lookUpWorkspaces(m.ctx, m.Profiles)
case loggedIn:
if msg.err != nil {
m.err = msg.err
return m, tea.Quit
}
profiles, err := loadLockedProfiles(m.ctx)
if err != nil {
m.err = err
return m, tea.Quit
}
m.Profiles = profiles
return m.finish(msg.profile, fmt.Sprintf("Logged in to profile '%s'.", msg.profile), nil)
case detailsLoaded:
if m.form != nil && m.form.original != nil && m.form.original.Name == msg.profile {
if msg.err != nil {
Expand Down Expand Up @@ -312,14 +335,43 @@ func (m model) onKey(msg tea.KeyPressMsg) (model, tea.Cmd) {
case key.Matches(msg, keys.use) && highlighted != nil:
return m.finish(highlighted.Name, fmt.Sprintf("Profile '%s' is the current one.", highlighted.Name),
m.SetCurrent(m.ctx, highlighted.Name))
case key.Matches(msg, keys.remove, keys.use):
case key.Matches(msg, keys.login) && highlighted != nil:
return m.withLogin(highlighted.Name)
case key.Matches(msg, keys.remove, keys.use, keys.login):
return m, nil
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}

// withLogin releases the lock on the profiles, as the login takes it itself.
func (m model) withLogin(name profile.Name) (model, tea.Cmd) {
if err := m.Unlock(); err != nil {
m.err = err
return m, tea.Quit
}
loginCmd := cmdauth.NewLoginTo(name)
loginCmd.SetContext(m.ctx)
return m, tea.Exec(login{loginCmd}, func(err error) tea.Msg {
return loggedIn{profile: name, err: err}
})
}

type loggedIn struct {
profile profile.Name
err error
}

// login calls RunE rather than Execute, which would print the error that the list prints once it
// has ended.
type login struct{ *cobra.Command }

func (l login) SetStdin(in io.Reader) { l.SetIn(in) }
func (l login) SetStdout(out io.Writer) { l.SetOut(out) }
func (l login) SetStderr(out io.Writer) { l.SetErr(out) }
func (l login) Run() error { return l.RunE(l.Command, nil) }

// withDeletion keeps the profile unless Delete is chosen: Enter takes Keep, the button huh focuses
// first.
func (m model) withDeletion(p *profile.Profile) (model, tea.Cmd) {
Expand Down Expand Up @@ -385,5 +437,7 @@ func run(ctx context.Context, p prompt.Prompt, m model) error {
if err != nil {
return err
}
return result.err
// A login in the list replaces the profiles and their lock, and withLockedProfiles unlocks only
// the ones it gave run.
return errors.Join(result.err, result.Unlock())
}
9 changes: 7 additions & 2 deletions cmd/profile/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/meshcloud/meshstack-cli/cmd/internal"
"github.com/meshcloud/meshstack-cli/cmd/internal/prompt"
"github.com/meshcloud/meshstack-cli/internal/auth/credential"
"github.com/meshcloud/meshstack-cli/internal/logs"
"github.com/meshcloud/meshstack-cli/internal/profile"
)
Expand Down Expand Up @@ -105,7 +106,10 @@ func TestListKeysChangeTheHighlightedProfileAndStoreItAtOnce(t *testing.T) {
}

func TestTheTableMarksTheCurrentProfileAndADialogAsksBeforeItDeletes(t *testing.T) {
m := newModel(t.Context(), twoProfiles(t))
m := newModel(t.Context(), storedProfiles(t,
profile.Profile{Name: "dev", Endpoint: endpointA},
profile.Profile{Name: "prod", Endpoint: endpointB, DefaultWorkspace: "ops", Credential: credential.OidcLoginName},
))
m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 20})

view := m.View()
Expand All @@ -114,8 +118,9 @@ func TestTheTableMarksTheCurrentProfileAndADialogAsksBeforeItDeletes(t *testing.
content := ansi.Strip(view.Content)
assert.Regexp(t, `Name +Endpoint +Default workspace`, content)
assert.Regexp(t, `🏠 +dev +https://a.example.io`, content)
assert.Regexp(t, `prod +https://b.example.io +ops`, content)
assert.Regexp(t, `💤 +prod +https://b.example.io +ops`, content, "a browser login with no session stored")
assert.Contains(t, content, "u set 🏠")
assert.Contains(t, content, "l log in 🔑")
assert.NotContains(t, content, "…", "the help fits on 80 columns")

m, _ = m.update(press('d'))
Expand Down
15 changes: 10 additions & 5 deletions cmd/profile/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ import (
// withLockedProfiles holds the profiles it loads until fn returns, so that a login does not store
// over what fn changes.
func withLockedProfiles(ctx context.Context, fn func(profile.Profiles) error) (err error) {
profiles, err := profile.LoadProfiles(ctx, profile.LoadProfilesOptions{SettingSources: internal.SettingSources(), ExclusiveLock: true})
if errors.Is(err, profile.ErrInUse) {
// Only the cause, as the setting a lookup failed for adds nothing to do about it.
return fmt.Errorf("%w, such as a login or another meshstack profile; let it finish and try again", profile.ErrInUse)
}
profiles, err := loadLockedProfiles(ctx)
if err != nil {
return err
}
Expand All @@ -27,6 +23,15 @@ func withLockedProfiles(ctx context.Context, fn func(profile.Profiles) error) (e
return fn(profiles)
}

func loadLockedProfiles(ctx context.Context) (profile.Profiles, error) {
profiles, err := profile.LoadProfiles(ctx, profile.LoadProfilesOptions{SettingSources: internal.SettingSources(), ExclusiveLock: true})
if errors.Is(err, profile.ErrInUse) {
// Only the cause, as the setting a lookup failed for adds nothing to do about it.
return profiles, fmt.Errorf("%w, such as a login or another meshstack profile; let it finish and try again", profile.ErrInUse)
}
return profiles, err
}

func remove(ctx context.Context, profiles *profile.Profiles, name profile.Name) error {
currentChanged, err := profiles.Remove(ctx, name)
if err != nil || !currentChanged {
Expand Down
16 changes: 16 additions & 0 deletions internal/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ func (o OidcLoginStatus) SessionEnded() bool {
return !o.SessionEndsAt.IsZero() && !time.Now().Before(o.SessionEndsAt)
}

// BrowserLoginEnded is also true for a browser login that a logout removed. It reads only the
// stored files of p, and calls no meshStack.
func BrowserLoginEnded(ctx context.Context, p *profile.Profile) bool {
if p.Credential != credential.OidcLoginName {
return false
}
creds, err := p.Credentials(ctx)
if err != nil || creds.OidcLogin == nil {
return true
}
if err := CacheFor(p, creds.OidcLogin).Load(ctx); err != nil || creds.OidcLogin.Cache == nil {
return true
}
return OidcLoginStatus{SessionEndsAt: creds.OidcLogin.Cache.RefreshExpiresAt}.SessionEnded()
}

type ApiKeyStatus struct {
ClientId uuid.UUID `json:"clientId"`
Details *ApiKeyDetails `json:"details,omitzero"`
Expand Down
Loading