From adb99944aad345a0bf8737bc8b7d3ed292e4b4bb Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 20 May 2026 17:10:09 +0000 Subject: [PATCH] appview/pipelines/ssh: add ssh server to appview to stream logs this change introduces an ssh server to the appview. this server is backed by a charmbracelet/wish app. it is invoked like so: ssh -t appview.host -p 3333 at://.../sh.tangled.pipeline/... the wish app then opens up a TUI for that pipeline job, with live streaming of logs and statuses for all workflows in that pipeline (available a separate tabs). at startup, the appview needs to currently be run with TANGLED_SSH_ENABLED=true, which starts the ssh server on 0.0.0.0:3333 by default. Signed-off-by: oppiliappan --- appview/config/config.go | 7 +++++++ appview/pages/templates/repo/pipelines/fragments/workflowSymbolOOB.html | 6 +++--- appview/pipelines/ssh/error.go | 31 +++++++++++++++++++++++++++++++ appview/pipelines/ssh/logstream.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ appview/pipelines/ssh/server.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/pipelines/ssh/session.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/pipelines/ssh/tui.go | 447 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/state/state.go | 4 ++++ cmd/appview/main.go | 9 +++++++++ go.mod | 36 +++++++++++++++++++++++++++--------- go.sum | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++--------------- 11 file(s) changed, 756 insertion(s)(+), 27 deletion(s)(-) diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -150,6 +150,12 @@ type OgreConfig struct { Host string `env:"HOST, default=https://ogre.tangled.network"` } +type SSHConfig struct { + Enabled bool `env:"ENABLED, default=false"` + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3333"` + HostKeyPath string `env:"HOST_KEY_PATH"` +} + func (cfg RedisConfig) ToURL() string { u := &url.URL{ Scheme: "redis", @@ -183,6 +189,7 @@ Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` Sites SitesConfig `env:",prefix=TANGLED_SITES_"` KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` Ogre OgreConfig `env:",prefix=TANGLED_OGRE_"` + SSH SSHConfig `env:",prefix=TANGLED_SSH_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/pages/templates/repo/pipelines/fragments/workflowSymbolOOB.html b/appview/pages/templates/repo/pipelines/fragments/workflowSymbolOOB.html --- a/appview/pages/templates/repo/pipelines/fragments/workflowSymbolOOB.html +++ b/appview/pages/templates/repo/pipelines/fragments/workflowSymbolOOB.html @@ -1,5 +1,5 @@ {{ define "repo/pipelines/fragments/workflowSymbolOOB" }} -
- {{ template "repo/pipelines/fragments/workflowSymbol" .Statuses }} -
+
+ {{ template "repo/pipelines/fragments/workflowSymbol" .Statuses }} +
{{ end }} diff --git a/appview/pipelines/ssh/error.go b/appview/pipelines/ssh/error.go new file mode 100644 --- /dev/null +++ b/appview/pipelines/ssh/error.go @@ -0,0 +1,31 @@ +package ssh + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + colorBrightRed lipgloss.ANSIColor = 9 +) + +type errorModel struct { + renderer *lipgloss.Renderer + message string +} + +func newErrorModel(renderer *lipgloss.Renderer, message string) *errorModel { + return &errorModel{renderer: renderer, message: message} +} + +func (m *errorModel) Init() tea.Cmd { + return tea.Quit +} + +func (m *errorModel) Update(_ tea.Msg) (tea.Model, tea.Cmd) { + return m, tea.Quit +} + +func (m *errorModel) View() string { + return m.renderer.NewStyle().Foreground(colorBrightRed).Render("error: "+m.message) + "\n" +} diff --git a/appview/pipelines/ssh/logstream.go b/appview/pipelines/ssh/logstream.go new file mode 100644 --- /dev/null +++ b/appview/pipelines/ssh/logstream.go @@ -0,0 +1,47 @@ +package ssh + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gorilla/websocket" + "tangled.org/core/appview/pipelines" + spindlemodel "tangled.org/core/spindle/models" +) + +type step struct { + id int + name string + command string + kind spindlemodel.StepKind + lines []string + startTime time.Time + endTime time.Time + finished bool +} + +type logDoneMsg struct { + workflow string + err error +} + +type logEventMsg struct { + workflow string + ev pipelines.LogEvent + conn *websocket.Conn + ch chan pipelines.LogEvent +} + +func readNextCmd(workflow string, conn *websocket.Conn, ch chan pipelines.LogEvent) tea.Cmd { + return func() tea.Msg { + return readNextLogEvent(workflow, conn, ch) + } +} + +func readNextLogEvent(workflow string, conn *websocket.Conn, ch chan pipelines.LogEvent) tea.Msg { + ev, ok := <-ch + if !ok { + return logDoneMsg{workflow: workflow} + } + return logEventMsg{workflow: workflow, ev: ev, conn: conn, ch: ch} +} diff --git a/appview/pipelines/ssh/server.go b/appview/pipelines/ssh/server.go new file mode 100644 --- /dev/null +++ b/appview/pipelines/ssh/server.go @@ -0,0 +1,70 @@ +package ssh + +import ( + "context" + "fmt" + "log/slog" + + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + tea "github.com/charmbracelet/wish/bubbletea" + "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/appview/pipelines" +) + +type Server struct { + db *db.DB + config *config.Config + pipelineNotifier *pipelines.StatusNotifier + logger *slog.Logger +} + +func New(db *db.DB, cfg *config.Config, pn *pipelines.StatusNotifier, logger *slog.Logger) *Server { + return &Server{db: db, config: cfg, pipelineNotifier: pn, logger: logger} +} + +func (s *Server) ListenAndServe(ctx context.Context) error { + opts := []ssh.Option{ + wish.WithAddress(s.config.SSH.ListenAddr), + wish.WithMiddleware( + tea.Middleware(s.teaHandler), + requirePty, + ), + } + + if s.config.SSH.HostKeyPath != "" { + opts = append(opts, wish.WithHostKeyPath(s.config.SSH.HostKeyPath)) + } + + srv, err := wish.NewServer(opts...) + if err != nil { + return err + } + + go func() { + <-ctx.Done() + s.logger.Info("shutting down SSH log server") + srv.Close() + }() + + s.logger.Info("SSH log server listening", "address", s.config.SSH.ListenAddr) + if err := srv.ListenAndServe(); err != ssh.ErrServerClosed { + return err + } + s.logger.Info("SSH log server stopped") + return nil +} + +// requirePty is a middleware that rejects connections without a PTY and tells the user to pass -t. +func requirePty(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + _, _, ok := sess.Pty() + if !ok { + fmt.Fprintf(sess.Stderr(), "error: no terminal allocated\nhint: use `ssh -t` to force PTY allocation\n") + sess.Exit(1) + return + } + next(sess) + } +} diff --git a/appview/pipelines/ssh/session.go b/appview/pipelines/ssh/session.go new file mode 100644 --- /dev/null +++ b/appview/pipelines/ssh/session.go @@ -0,0 +1,60 @@ +package ssh + +import ( + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/ssh" + wishtea "github.com/charmbracelet/wish/bubbletea" + "tangled.org/core/appview/db" + "tangled.org/core/orm" +) + +func (s *Server) teaHandler(sess ssh.Session) (tea.Model, []tea.ProgramOption) { + remote := sess.RemoteAddr().String() + args := sess.Command() + l := s.logger.With("remote", remote) + l.Info("SSH connection", "args", args) + defer l.Info("SSH connection closed", "remote", remote) + + renderer := wishtea.MakeRenderer(sess) + + if len(args) != 1 { + l.Warn("bad invocation", "args", args) + return newErrorModel(renderer, "usage: ssh -t -p 2222 \nexample: ssh -t host -p 2222 at://did:web:knot.example/sh.tangled.pipeline/abc123"), wishtea.MakeOptions(sess) + } + + rawURI := args[0] + aturi, err := syntax.ParseATURI(rawURI) + if err != nil { + l.Warn("invalid AT URI", "uri", rawURI, "err", err) + return newErrorModel(renderer, fmt.Sprintf("invalid AT URI %q: %v", rawURI, err)), wishtea.MakeOptions(sess) + } + + did := aturi.Authority().String() + const didWebPrefix = "did:web:" + if len(did) <= len(didWebPrefix) { + l.Warn("unsupported DID format", "did", did) + return newErrorModel(renderer, fmt.Sprintf("unsupported DID format %q (expected did:web:...)", did)), wishtea.MakeOptions(sess) + } + knot := did[len(didWebPrefix):] + rkey := aturi.RecordKey().String() + + l = l.With("knot", knot, "rkey", rkey) + + pipelines, err := db.GetPipelineStatuses(s.db, 1, + orm.FilterEq("p.knot", knot), + orm.FilterEq("p.rkey", rkey), + ) + if err != nil || len(pipelines) == 0 { + l.Warn("pipeline not found", "err", err) + return newErrorModel(renderer, fmt.Sprintf("pipeline not found: %s", rawURI)), wishtea.MakeOptions(sess) + } + + pipeline := pipelines[0] + l.Info("serving pipeline", "workflows", len(pipeline.Statuses)) + pty, _, _ := sess.Pty() + opts := append(wishtea.MakeOptions(sess), tea.WithAltScreen()) + return newPipelineModel(renderer, s, pipeline, pty.Window.Width, pty.Window.Height), opts +} diff --git a/appview/pipelines/ssh/tui.go b/appview/pipelines/ssh/tui.go new file mode 100644 --- /dev/null +++ b/appview/pipelines/ssh/tui.go @@ -0,0 +1,447 @@ +package ssh + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/gorilla/websocket" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pipelines" + "tangled.org/core/orm" + spindlemodel "tangled.org/core/spindle/models" +) + +var ( + colorWhite lipgloss.ANSIColor = 7 + colorBlue lipgloss.ANSIColor = 4 + colorBrightBlack lipgloss.ANSIColor = 8 + colorDarkGrey lipgloss.ANSIColor = 0 +) + +type tickMsg time.Time + +type statusUpdateMsg struct { + pipeline models.Pipeline +} + +type statusUpdateErrMsg struct{ err error } + +type pipelineModel struct { + renderer *lipgloss.Renderer + server *Server + pipeline models.Pipeline + workflows []string + selected int + logs map[string]*workflowLogs + statusCh chan struct{} + spinner spinner.Model + width int + height int +} + +type workflowLogs struct { + steps []step + stepIndex map[int]int + vp viewport.Model + ready bool + done bool + err error +} + +func newPipelineModel(renderer *lipgloss.Renderer, s *Server, pipeline models.Pipeline, width, height int) *pipelineModel { + workflows := pipeline.Workflows() + logs := make(map[string]*workflowLogs, len(workflows)) + for _, wf := range workflows { + logs[wf] = &workflowLogs{stepIndex: make(map[int]int)} + } + statusCh := s.pipelineNotifier.Subscribe(pipeline.AtUri()) + sp := spinner.New(spinner.WithSpinner(spinner.Line)) + return &pipelineModel{ + renderer: renderer, + server: s, + pipeline: pipeline, + workflows: workflows, + logs: logs, + statusCh: statusCh, + spinner: sp, + width: width, + height: height, + } +} + +func (m *pipelineModel) Init() tea.Cmd { + cmds := []tea.Cmd{tick(), m.spinner.Tick, m.waitForStatusUpdate(m.statusCh)} + for _, wf := range m.workflows { + cmds = append(cmds, m.connectCmd(wf)) + } + return tea.Batch(cmds...) +} + +func tick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +// waitForStatusUpdate blocks on the notifier channel, re-fetches pipeline statuses, and returns the result as a tea.Msg. +func (m *pipelineModel) waitForStatusUpdate(ch chan struct{}) tea.Cmd { + knot := m.pipeline.Knot + rkey := m.pipeline.Rkey + return func() tea.Msg { + if _, ok := <-ch; !ok { + return nil + } + ps, err := db.GetPipelineStatuses(m.server.db, 1, + orm.FilterEq("p.knot", knot), + orm.FilterEq("p.rkey", rkey), + ) + if err != nil || len(ps) == 0 { + return statusUpdateErrMsg{err: fmt.Errorf("refreshing pipeline: %w", err)} + } + return statusUpdateMsg{pipeline: ps[0]} + } +} + +// connectCmd dials the spindle websocket for the given workflow and starts streaming log events. +func (m *pipelineModel) connectCmd(workflow string) tea.Cmd { + return func() tea.Msg { + ws, ok := m.pipeline.Statuses[workflow] + if !ok || len(ws.Data) == 0 { + return logDoneMsg{workflow: workflow} + } + url := pipelines.SpindleURL(m.server.config.Core.Dev, ws.Data[0].Spindle, m.pipeline.Knot, m.pipeline.Rkey, workflow) + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return logDoneMsg{workflow: workflow, err: fmt.Errorf("connecting to spindle: %w", err)} + } + ch := make(chan pipelines.LogEvent, 100) + go pipelines.ReadLogs(conn, ch) + return readNextLogEvent(workflow, conn, ch) + } +} + +func (m *pipelineModel) vpHeight() int { + return max(m.height-2, 1) // topbar + divider take 2 lines +} + +// resizeViewports updates all viewport dimensions and re-renders their content after a terminal resize. +// +// TODO: can be tedious if we have logs of logs +func (m *pipelineModel) resizeViewports() { + for _, wl := range m.logs { + if !wl.ready { + continue + } + atBottom := wl.vp.AtBottom() + wl.vp.Width = m.width + wl.vp.Height = m.vpHeight() + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) + if atBottom { + wl.vp.GotoBottom() + } + } +} + +func (m *pipelineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.resizeViewports() + + case tickMsg: + return m, tick() + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.server.pipelineNotifier.Unsubscribe(m.pipeline.AtUri(), m.statusCh) + return m, tea.Quit + case "tab", "right", "l": + m.selected = (m.selected + 1) % len(m.workflows) + return m, nil + case "shift+tab", "left", "h": + m.selected = (m.selected - 1 + len(m.workflows)) % len(m.workflows) + return m, nil + } + if wl := m.selectedLogs(); wl != nil && wl.ready { + switch msg.String() { + case "g": + wl.vp.GotoTop() + return m, nil + case "G": + wl.vp.GotoBottom() + return m, nil + case "ctrl+e": + wl.vp.ScrollDown(1) + return m, nil + case "ctrl+y": + wl.vp.ScrollUp(1) + return m, nil + } + var cmd tea.Cmd + wl.vp, cmd = wl.vp.Update(msg) + return m, cmd + } + + case logEventMsg: + return m, m.handleLogEvent(msg) + + case logDoneMsg: + if wl, ok := m.logs[msg.workflow]; ok { + wl.done, wl.err = true, msg.err + m.initViewport(wl) + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) + wl.vp.GotoBottom() + } + + case statusUpdateMsg: + // detect any workflows that are new since the last update + known := make(map[string]bool, len(m.workflows)) + for _, wf := range m.workflows { + known[wf] = true + } + m.pipeline = msg.pipeline + var newCmds []tea.Cmd + for _, wf := range msg.pipeline.Workflows() { + if !known[wf] { + m.workflows = append(m.workflows, wf) + m.logs[wf] = &workflowLogs{stepIndex: make(map[int]int)} + newCmds = append(newCmds, m.connectCmd(wf)) + } + } + // re-subscribe for the next update + newCmds = append(newCmds, m.waitForStatusUpdate(m.statusCh)) + return m, tea.Batch(newCmds...) + + case statusUpdateErrMsg: + // re-subscribe even on error so we don't stop listening + return m, m.waitForStatusUpdate(m.statusCh) + } + + return m, nil +} + +func (m *pipelineModel) selectedLogs() *workflowLogs { + if len(m.workflows) == 0 { + return nil + } + return m.logs[m.workflows[m.selected]] +} + +func (m *pipelineModel) initViewport(wl *workflowLogs) { + if wl.ready { + return + } + wl.vp = viewport.New(m.width, m.vpHeight()) + wl.ready = true +} + +// handleLogEvent processes a single log event, updates the step state, and re-renders the viewport. +func (m *pipelineModel) handleLogEvent(msg logEventMsg) tea.Cmd { + wl, ok := m.logs[msg.workflow] + if !ok { + return nil + } + if msg.ev.Err != nil { + wl.done = true + if !msg.ev.IsCloseError() { + wl.err = msg.ev.Err + } + m.initViewport(wl) + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) + return nil + } + var line spindlemodel.LogLine + if err := json.Unmarshal(msg.ev.Msg, &line); err != nil { + return readNextCmd(msg.workflow, msg.conn, msg.ch) + } + applyLogLine(wl, line) + m.initViewport(wl) + atBottom := wl.vp.AtBottom() + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) + if atBottom { + wl.vp.GotoBottom() + } + return readNextCmd(msg.workflow, msg.conn, msg.ch) +} + +// applyLogLine mutates wl by appending the log line to the appropriate step. +func applyLogLine(wl *workflowLogs, line spindlemodel.LogLine) { + switch line.Kind { + case spindlemodel.LogKindControl: + switch line.StepStatus { + case spindlemodel.StepStatusStart: + idx := len(wl.steps) + wl.stepIndex[line.StepId] = idx + wl.steps = append(wl.steps, step{ + id: line.StepId, name: line.Content, command: line.StepCommand, + kind: line.StepKind, startTime: line.Time, + }) + case spindlemodel.StepStatusEnd: + if idx, ok := wl.stepIndex[line.StepId]; ok { + wl.steps[idx].endTime, wl.steps[idx].finished = line.Time, true + } + } + case spindlemodel.LogKindData: + if idx, ok := wl.stepIndex[line.StepId]; ok { + wl.steps[idx].lines = append(wl.steps[idx].lines, line.Content) + } + } +} + +// renderLogs builds the full log content string for a workflow, used as viewport content. +func renderLogs(r *lipgloss.Renderer, wl *workflowLogs, width int) string { + headerStyle := r.NewStyle().Foreground(colorWhite).Background(colorBrightBlack).Bold(true) + cmdStyle := r.NewStyle().Foreground(colorBlue).Background(colorDarkGrey).Width(width) + lineStyle := r.NewStyle().Foreground(colorWhite).Background(colorDarkGrey).Width(width) + now := time.Now() + var sb strings.Builder + for i := range wl.steps { + st := &wl.steps[i] + dur := "" + if st.finished { + dur = st.endTime.Sub(st.startTime).Round(time.Millisecond).String() + } else if !st.startTime.IsZero() { + dur = now.Sub(st.startTime).Round(time.Second).String() + } + durRendered := headerStyle.Render(dur) + nameWidth := max(width-lipgloss.Width(dur)-1, 1) + header := fmt.Sprintf("%-*s ", nameWidth, st.name) + durRendered + sb.WriteString(headerStyle.Width(width).Render(header) + "\n") + if st.command != "" { + sb.WriteString(cmdStyle.Render(st.command) + "\n") + } + for _, l := range st.lines { + sb.WriteString(lineStyle.Render(l) + "\n") + } + sb.WriteString("\n") + } + if wl.done && wl.err != nil { + sb.WriteString("error: " + wl.err.Error() + "\n") + } + return sb.String() +} + +func (m *pipelineModel) View() string { + r := m.renderer + divider := r.NewStyle().Foreground(colorBrightBlack).Render(strings.Repeat("─", m.width)) + body := "" + if wl := m.selectedLogs(); wl != nil && wl.ready { + body = wl.vp.View() + } + return lipgloss.JoinVertical(lipgloss.Left, m.topbarView(), divider, body) +} + +// topbarView renders the single-line tab bar with workflow tabs left and trigger info + help right. +func (m *pipelineModel) topbarView() string { + r := m.renderer + activeStyle := r.NewStyle().Background(colorBlue).Foreground(colorWhite) + + now := time.Now() + + var tabs strings.Builder + for i, wf := range m.workflows { + status := spindlemodel.StatusKindPending + elapsed := "" + if ws, ok := m.pipeline.Statuses[wf]; ok { + latest := ws.Latest() + status = latest.Status + if t := ws.TimeTaken(); t > 0 { + elapsed = t.Round(time.Second).String() + } else { + elapsed = now.Sub(latest.Created).Round(time.Second).String() + } + } + dim := r.NewStyle().Faint(true) + base := " " + statusIcon(status, m.spinner.View()) + " " + wf + if i == m.selected { + tab := base + if elapsed != "" { + tab += " " + elapsed + } + tab += " " + tabs.WriteString(activeStyle.Render(tab)) + } else { + tabs.WriteString(base) + if elapsed != "" { + tabs.WriteString(" " + dim.Render(elapsed)) + } + tabs.WriteString(" ") + } + } + + tabsStr := tabs.String() + infoStr := triggerLine(r, m.pipeline.Trigger, m.pipeline.Sha) + " · " + helpText(r) + + gap := max(m.width-lipgloss.Width(tabsStr)-lipgloss.Width(infoStr), 1) + + return tabsStr + strings.Repeat(" ", gap) + infoStr +} + +func helpText(r *lipgloss.Renderer) string { + key := r.NewStyle().Foreground(colorWhite) + action := r.NewStyle().Faint(true) + sep := action.Render(" · ") + + items := []string{ + key.Render("←/→") + " " + action.Render("switch"), + key.Render("↑/↓") + " " + action.Render("scroll"), + key.Render("q") + " " + action.Render("quit"), + } + return strings.Join(items, sep) +} + +func shortSha(sha string) string { + if len(sha) >= 8 { + return sha[:8] + } + return sha +} + +func triggerLine(r *lipgloss.Renderer, t *models.Trigger, sha string) string { + hash := shortSha(sha) + dim := r.NewStyle().Faint(true) + if t == nil { + return dim.Render(hash) + } + if t.IsPush() { + return t.TargetRef() + dim.Render("@"+hash) + dim.Render(" (push)") + } + if t.IsPullRequest() { + source := "" + if t.PRSourceBranch != nil { + source = *t.PRSourceBranch + } + return t.TargetRef() + dim.Render(" <- "+source+"@"+hash) + dim.Render(" (pull-request)") + } + return dim.Render(hash) +} + +func statusIcon(status spindlemodel.StatusKind, spinnerFrame string) string { + switch status { + case spindlemodel.StatusKindSuccess: + return "✓" + case spindlemodel.StatusKindFailed: + return "×" + case spindlemodel.StatusKindRunning: + return spinnerFrame + case spindlemodel.StatusKindPending: + return "·" + case spindlemodel.StatusKindTimeout: + return "⌀" + case spindlemodel.StatusKindCancelled: + return "-" + default: + return "?" + } +} diff --git a/appview/state/state.go b/appview/state/state.go --- a/appview/state/state.go +++ b/appview/state/state.go @@ -254,6 +254,10 @@ // other close up logic goes here return s.db.Close() } +func (s *State) NewSSHServer() *pipelinessh.Server { + return pipelinessh.New(s.db, s.config, s.pipelineNotifier, log.SubLogger(s.logger, "pipelinessh")) +} + func (s *State) SecurityTxt(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Header().Set("Cache-Control", "public, max-age=86400") // one day diff --git a/cmd/appview/main.go b/cmd/appview/main.go --- a/cmd/appview/main.go +++ b/cmd/appview/main.go @@ -44,6 +44,15 @@ logger.Error("failed to start metrics server", "err", err) } }() + if c.SSH.Enabled { + sshServer := state.NewSSHServer() + go func() { + if err := sshServer.ListenAndServe(ctx); err != nil { + logger.Error("SSH server stopped", "err", err) + } + }() + } + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { logger.Error("failed to start appview", "err", err) } diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -19,7 +19,12 @@ github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/carlmjohnson/versioninfo v0.22.5 github.com/casbin/casbin/v2 v2.103.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v0.4.2 + github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 + github.com/charmbracelet/wish v1.4.7 github.com/cloudflare/cloudflare-go/v6 v6.7.0 github.com/cyphar/filepath-securejoin v0.4.1 github.com/dgraph-io/ristretto v0.2.0 @@ -43,6 +48,7 @@ github.com/ipfs/go-cid v0.6.0 github.com/jackc/pgx/v5 v5.8.0 github.com/mattn/go-sqlite3 v1.14.34 github.com/microcosm-cc/bluemonday v1.0.27 + github.com/multiformats/go-multihash v0.2.3 github.com/openbao/openbao/api/v2 v2.3.0 github.com/posthog/posthog-go v1.5.5 github.com/prometheus/client_golang v1.23.2 @@ -94,7 +100,7 @@ github.com/aws/smithy-go v1.24.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.22.0 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/blevesearch/bleve_index_api v1.2.8 // indirect github.com/blevesearch/geo v0.2.4 // indirect github.com/blevesearch/go-faiss v1.0.25 // indirect @@ -115,15 +121,24 @@ github.com/blevesearch/zapx/v16 v16.2.4 // indirect github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/conpty v0.1.0 // indirect + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/x/input v0.3.4 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.0 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect + github.com/creack/pty v1.1.21 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect @@ -132,6 +147,7 @@ github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect @@ -195,10 +211,11 @@ github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/labstack/echo/v4 v4.11.3 // indirect github.com/labstack/gommon v0.4.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -209,11 +226,12 @@ github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multibase v0.2.0 // indirect - github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/gomega v1.37.0 // indirect diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= -github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y= @@ -140,18 +140,38 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw= +github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= +github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= +github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0= @@ -161,6 +181,12 @@ github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/cloudflare-go/v6 v6.7.0 h1:MP6Xy5WmsyrxgTxoLeq/vraqR0nbTtXoHhW4vAYc4SY= @@ -173,6 +199,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= @@ -215,6 +243,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -476,14 +506,16 @@ github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -511,6 +543,10 @@ github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= @@ -600,7 +636,6 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/resend/resend-go/v3 v3.5.0 h1:yScYxHinY352Mj7Cn9rbWsR2gDqD2mtFPWwh2UyFMeE= github.com/resend/resend-go/v3 v3.5.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -800,6 +835,7 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -- tangled.sh