From faf5bb2c7f9ddb38599b0f04609daaa7312e0cc5 Mon Sep 17 00:00:00 2001 From: Elliot Waddington Date: Thu, 4 Apr 2024 12:10:25 +0100 Subject: [PATCH 1/3] Cleanup air config for better debugger experience --- .air.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.air.toml b/.air.toml index a501c1f4..0f5f5737 100644 --- a/.air.toml +++ b/.air.toml @@ -5,7 +5,7 @@ tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" -cmd = "go generate ./... && go build -o ./tmp/main main.go" +cmd = "go generate ./... && go build -gcflags='all=-N -l' -o ./tmp/main main.go" delay = 1000 exclude_dir = ["assets", "build", "tmp", "vendor", "test", "testdata"] exclude_file = ["server/admin/assets/dist.css"] @@ -19,7 +19,7 @@ exclude_regex = [ exclude_unchanged = false follow_symlink = false # contrary to other repos, this does wait for the debugger to attach, as cli processes are very shortlived -full_bin = "dlv exec --accept-multiclient --log --headless --listen :9087 --api-version 2 ./tmp/main --" +full_bin = "dlv exec --accept-multiclient --headless --listen :9087 --api-version 2 ./tmp/main --" include_dir = [] include_ext = ["go", "tpl", "tmpl", "templ", "html", "sql", "css", "md"] include_file = ["sqlc.yaml"] From 9cc4828e234f3947d621ff512a8ca79e09f9a6a8 Mon Sep 17 00:00:00 2001 From: Elliot Waddington Date: Thu, 4 Apr 2024 17:20:00 +0100 Subject: [PATCH 2/3] Add prototype tea app for ensureToken workflow --- cmd/root.go | 186 +++++++++++++++++++++++++++++++++++++------------ cmd/spinner.go | 36 ++++++++++ cmd/theme.go | 125 +++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 43 deletions(-) create mode 100644 cmd/spinner.go create mode 100644 cmd/theme.go diff --git a/cmd/root.go b/cmd/root.go index 7bded3f8..d44eef4c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,8 +15,7 @@ import ( "strings" "connectrpc.com/connect" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/huh/spinner" + tea "github.com/charmbracelet/bubbletea" "github.com/google/uuid" "github.com/overmindtech/cli/tracing" "github.com/overmindtech/sdp-go" @@ -274,6 +273,135 @@ func getAPIKeyToken(ctx context.Context, oi OvermindInstance, apiKey string) (*o return token, nil } +type statusMsg int + +const ( + PromptUser statusMsg = 0 + WaitingForConfirmation statusMsg = 1 + Authenticated statusMsg = 2 + ErrorAuthenticating statusMsg = 3 +) + +type authenticateModel struct { + status statusMsg + err error + deviceCode *oauth2.DeviceAuthResponse + config oauth2.Config + ctx context.Context + token *oauth2.Token +} + +func (m authenticateModel) Init() tea.Cmd { + return openBrowser(m.deviceCode.VerificationURI) +} + +func (m authenticateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + switch msg.String() { + default: + { + if m.status == Authenticated { + return m, tea.Quit + } + } + case "ctrl+c", "q": + return m, tea.Quit + } + + case *oauth2.Token: + { + m.status = Authenticated + m.token = msg + return m, nil + } + + case statusMsg: + switch msg { + case PromptUser: + return m, openBrowser(m.deviceCode.VerificationURI) + case WaitingForConfirmation: + m.status = WaitingForConfirmation + return m, awaitToken(m.config, m.ctx, m.deviceCode) + case Authenticated: + case ErrorAuthenticating: { + return m, nil + } + } + + case browserOpenErrorMsg: + m.status = WaitingForConfirmation + return m, awaitToken(m.config, m.ctx, m.deviceCode) + + case failedToAuthenticateErrorMsg: + m.err = msg.err + m.status = ErrorAuthenticating + return m, tea.Quit + + case errMsg: + m.err = msg.err + return m, tea.Quit + } + + return m, nil +} + +func (m authenticateModel) View() string { + var output string + beginAuthMessage := `# Authenticate with a browser + +Attempting to automatically open the SSO authorization page in your default browser. +If the browser does not open or you wish to use a different device to authorize this request, open the following URL: + + %v + +Then enter the code: + + %v +` + prompt := fmt.Sprintf(beginAuthMessage, m.deviceCode.VerificationURI, m.deviceCode.UserCode) + output += markdownToString(prompt) + switch m.status { + case PromptUser: + // nothing here as PromptUser is the default + case WaitingForConfirmation: + sp := createSpinner() + output += sp.View() + " Waiting for confirmation..." + case Authenticated: + output = "✅ Authenticated successfully. Press any key to continue." + case ErrorAuthenticating: + output = "⛔️ Unable to authenticate. Try again." + } + + return containerStyle.Render(output) +} + +type errMsg struct{ err error } +type browserOpenErrorMsg struct{ err error } +type failedToAuthenticateErrorMsg struct{ err error } + +func openBrowser(url string) tea.Cmd { + return func() tea.Msg { + err := browser.OpenURL(url) + if err != nil { + return browserOpenErrorMsg{err} + } + return WaitingForConfirmation + } +} + +func awaitToken(config oauth2.Config, ctx context.Context, deviceCode *oauth2.DeviceAuthResponse) tea.Cmd { + return func() tea.Msg { + token, err := config.DeviceAccessToken(ctx, deviceCode) + if err != nil { + return failedToAuthenticateErrorMsg{err} + } + + return token + } +} + // Gets a token from Oauth with the required scopes. This method will also cache // that token locally for use later, and will use the cached token if possible func getOauthToken(ctx context.Context, oi OvermindInstance, requiredScopes []string) (*oauth2.Token, error) { @@ -332,49 +460,21 @@ func getOauthToken(ctx context.Context, oi OvermindInstance, requiredScopes []st return nil, fmt.Errorf("error getting device code: %w", err) } - r := NewTermRenderer() - prompt := `# Authenticate with a browser - -Attempting to automatically open the SSO authorization page in your default browser. -If the browser does not open or you wish to use a different device to authorize this request, open the following URL: - - %v - -Then enter the code: + m := authenticateModel{status: PromptUser, deviceCode: deviceCode, config: config, ctx: ctx} + authenticateProgram := tea.NewProgram(m) - %v -` - prompt = fmt.Sprintf(prompt, deviceCode.VerificationURI, deviceCode.UserCode) - out, err := glamour.Render(prompt, "dark") - if err != nil { - panic(err) - } - fmt.Print(out) - - err = browser.OpenURL(deviceCode.VerificationURIComplete) - if err != nil { - log.WithContext(ctx).WithError(err).Error("failed to execute local browser") - } - - var token *oauth2.Token - _ = spinner.New().Title("Waiting for confirmation...").Action(func() { - token, err = config.DeviceAccessToken(ctx, deviceCode) - if err != nil { - log.WithContext(ctx).WithError(err).Errorf("Error exchanging Device Code for for access token") - err = fmt.Errorf("Error exchanging Device Code for for access token: %w", err) - return + if result, err := authenticateProgram.Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } else { + updatedModel, ok := result.(authenticateModel) + if !ok { + fmt.Println("Error running program: result is not authenticateModel") + os.Exit(1) } - }).Run() - if err != nil { - return nil, err + m = updatedModel } - out, err = r.Render("✅ Authenticated successfully") - if err != nil { - panic(err) - } - fmt.Println(out) - span := trace.SpanFromContext(ctx) span.SetAttributes(attribute.Bool("ovm.cli.authenticated", true)) @@ -394,7 +494,7 @@ Then enter the code: } // Encode the token - err = json.NewEncoder(file).Encode(token) + err = json.NewEncoder(file).Encode(m.token) if err != nil { log.WithContext(ctx).WithError(err).Errorf("Failed to encode token file at %v", path) } @@ -402,7 +502,7 @@ Then enter the code: log.WithContext(ctx).Debugf("Saved token to %v", path) } - return token, nil + return m.token, nil } // ensureToken diff --git a/cmd/spinner.go b/cmd/spinner.go new file mode 100644 index 00000000..8dd35247 --- /dev/null +++ b/cmd/spinner.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type spinnerModel struct { + spinner spinner.Model +} + +func createSpinner() spinnerModel { + s := spinner.New() + s.Spinner = spinner.Pulse + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPalette.Light.BgMain)) + return spinnerModel{spinner: s} +} + +func (m spinnerModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + default: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } +} + +func (m spinnerModel) View() string { + return m.spinner.View() +} diff --git a/cmd/theme.go b/cmd/theme.go new file mode 100644 index 00000000..4d5ca1d8 --- /dev/null +++ b/cmd/theme.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +type LogoPalette struct { + a string + b string + c string + d string + e string + f string +} + +type HexPalette struct { + Light Palette + Dark Palette + Logo LogoPalette +} + +type Palette struct { + BgBase string + BgBaseHover string + BgShade string + BgSub string + BgBorder string + BgBorderHover string + BgDivider string + BgMain string + BgMainHover string + BgDanger string + BgDangerHover string + BgSuccess string + BgSuccessHover string + BgContrast string + BgContrastHover string + BgWarning string + BgWarningHover string + LabelControl string + LabelFaint string + LabelMuted string + LabelBase string + LabelTitle string + LabelLink string + LabelContrast string +} + +var ColorPalette = HexPalette{ + Light: Palette{ + BgBase: "#ffffff", + BgBaseHover: "#ebebeb", + BgShade: "#fafafa", + BgSub: "#ffffff", + BgBorder: "#e3e3e3", + BgBorderHover: "#d4d4d4", + BgDivider: "#f0f0f0", + BgMain: "#655add", + BgMainHover: "#4840a0", + BgDanger: "#d74249", + BgDangerHover: "#c8373e", + BgSuccess: "#5bb856", + BgSuccessHover: "#4da848", + BgContrast: "#141414", + BgContrastHover: "#2b2b2b", + BgWarning: "#e59c57", + BgWarningHover: "#d9873a", + LabelControl: "#ffffff", + LabelFaint: "#adadad", + LabelMuted: "#616161", + LabelBase: "#383838", + LabelTitle: "#141414", + LabelLink: "#4f81ee", + LabelContrast: "#ffffff", + }, + Dark: Palette{ + BgBase: "#242428", + BgBaseHover: "#2d2d34", + BgShade: "#27272b", + BgSub: "#1a1a1f", + BgBorder: "#37373f", + BgBorderHover: "#434351", + BgDivider: "#29292e", + BgMain: "#7a70eb", + BgMainHover: "#938af5", + BgDanger: "#be5056", + BgDangerHover: "#d0494f", + BgSuccess: "#61ac5d", + BgSuccessHover: "#6ac865", + BgContrast: "#fafafa", + BgContrastHover: "#ffffff", + BgWarning: "#ca8d53", + BgWarningHover: "#f0a660", + LabelControl: "#ffffff", + LabelFaint: "#616161", + LabelMuted: "#8c8c8c", + LabelBase: "#bababa", + LabelTitle: "#ededed", + LabelLink: "#688ede", + LabelContrast: "#1e1e24", + }, + Logo: LogoPalette{ + a: "#1badf2", + b: "#4b6ddf", + c: "#5f51d5", + d: "#c640ad", + e: "#ef4971", + f: "#fd6e43", + }, +} + +var titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPalette.Light.BgMain)).Bold(true) +var textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPalette.Light.LabelBase)) +var addedLineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPalette.Light.LabelControl)).Background(lipgloss.Color(ColorPalette.Light.BgSuccess)) +var deletedLineStyle = lipgloss.NewStyle().Background(lipgloss.Color(ColorPalette.Light.BgDanger)).Foreground(lipgloss.Color(ColorPalette.Light.LabelControl)) +var containerStyle = lipgloss.NewStyle().PaddingLeft(2).PaddingTop(2) + +func markdownToString(markdown string) string { + r, _ := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + ) + out, _ := r.Render(markdown) + return out +} From 7c5d41151e4592664191227788863800f31e95c5 Mon Sep 17 00:00:00 2001 From: David Schmitt Date: Fri, 5 Apr 2024 11:59:06 +0200 Subject: [PATCH 3/3] Minor cleanups * ctx as first arg * apply code formatter * refactor error/result at Run() --- cmd/root.go | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index d44eef4c..cd2f487e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -283,11 +283,12 @@ const ( ) type authenticateModel struct { + ctx context.Context + status statusMsg err error deviceCode *oauth2.DeviceAuthResponse config oauth2.Config - ctx context.Context token *oauth2.Token } @@ -323,16 +324,17 @@ func (m authenticateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, openBrowser(m.deviceCode.VerificationURI) case WaitingForConfirmation: m.status = WaitingForConfirmation - return m, awaitToken(m.config, m.ctx, m.deviceCode) + return m, awaitToken(m.ctx, m.config, m.deviceCode) case Authenticated: - case ErrorAuthenticating: { - return m, nil + case ErrorAuthenticating: + { + return m, nil + } } - } case browserOpenErrorMsg: m.status = WaitingForConfirmation - return m, awaitToken(m.config, m.ctx, m.deviceCode) + return m, awaitToken(m.ctx, m.config, m.deviceCode) case failedToAuthenticateErrorMsg: m.err = msg.err @@ -355,7 +357,7 @@ Attempting to automatically open the SSO authorization page in your default brow If the browser does not open or you wish to use a different device to authorize this request, open the following URL: %v - + Then enter the code: %v @@ -391,7 +393,7 @@ func openBrowser(url string) tea.Cmd { } } -func awaitToken(config oauth2.Config, ctx context.Context, deviceCode *oauth2.DeviceAuthResponse) tea.Cmd { +func awaitToken(ctx context.Context, config oauth2.Config, deviceCode *oauth2.DeviceAuthResponse) tea.Cmd { return func() tea.Msg { token, err := config.DeviceAccessToken(ctx, deviceCode) if err != nil { @@ -460,19 +462,19 @@ func getOauthToken(ctx context.Context, oi OvermindInstance, requiredScopes []st return nil, fmt.Errorf("error getting device code: %w", err) } - m := authenticateModel{status: PromptUser, deviceCode: deviceCode, config: config, ctx: ctx} + m := authenticateModel{ctx: ctx, status: PromptUser, deviceCode: deviceCode, config: config} authenticateProgram := tea.NewProgram(m) - if result, err := authenticateProgram.Run(); err != nil { + result, err := authenticateProgram.Run() + if err != nil { fmt.Println("Error running program:", err) os.Exit(1) - } else { - updatedModel, ok := result.(authenticateModel) - if !ok { - fmt.Println("Error running program: result is not authenticateModel") - os.Exit(1) - } - m = updatedModel + } + + m, ok := result.(authenticateModel) + if !ok { + fmt.Println("Error running program: result is not authenticateModel") + os.Exit(1) } span := trace.SpanFromContext(ctx)