diff --git a/.golangci.yml b/.golangci.yml index ec0523e..4dd67c0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index df23375..ab45f93 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -14,7 +14,7 @@ func New() *cobra.Command { }, } - cmd.AddCommand(newLogin()) + cmd.AddCommand(newLogin(nil)) cmd.AddCommand(newLogout()) cmd.AddCommand(newStatus()) cmd.AddCommand(newToken()) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f1ea13f..832e134 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -1,8 +1,10 @@ package auth import ( + "context" "errors" "fmt" + "slices" "uuid" "github.com/spf13/cobra" @@ -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) @@ -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 { diff --git a/cmd/internal/markdown/credential.md.tmpl b/cmd/internal/markdown/credential.md.tmpl index 7aab42a..2fd5dfd 100644 --- a/cmd/internal/markdown/credential.md.tmpl +++ b/cmd/internal/markdown/credential.md.tmpl @@ -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}} diff --git a/cmd/profile/model.go b/cmd/profile/model.go index 1175b89..cb9aaf5 100644 --- a/cmd/profile/model.go +++ b/cmd/profile/model.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "log/slog" "strings" @@ -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()} } @@ -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")), } @@ -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) } } @@ -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() } @@ -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 { @@ -312,7 +335,9 @@ 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 @@ -320,6 +345,33 @@ func (m model) onKey(msg tea.KeyPressMsg) (model, tea.Cmd) { 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) { @@ -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()) } diff --git a/cmd/profile/model_test.go b/cmd/profile/model_test.go index 2a5713b..950f9e7 100644 --- a/cmd/profile/model_test.go +++ b/cmd/profile/model_test.go @@ -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" ) @@ -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() @@ -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')) diff --git a/cmd/profile/store.go b/cmd/profile/store.go index 242ca89..7f7fe1a 100644 --- a/cmd/profile/store.go +++ b/cmd/profile/store.go @@ -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 } @@ -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 { diff --git a/internal/auth/status.go b/internal/auth/status.go index 67b1b1c..539a356 100644 --- a/internal/auth/status.go +++ b/internal/auth/status.go @@ -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"`