Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

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 <me@oppi.li>

author
oppiliappan
committer
Tangled
date (May 26, 2026, 9:08 AM +0300) commit adb99944 parent 12ebd851 change-id pnppuyqo
+756 -27
+7
appview/config/config.go
··· 150 150 Host string `env:"HOST, default=https://ogre.tangled.network"` 151 151 } 152 152 153 + type SSHConfig struct { 154 + Enabled bool `env:"ENABLED, default=false"` 155 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3333"` 156 + HostKeyPath string `env:"HOST_KEY_PATH"` 157 + } 158 + 153 159 func (cfg RedisConfig) ToURL() string { 154 160 u := &url.URL{ 155 161 Scheme: "redis", ··· 183 189 Sites SitesConfig `env:",prefix=TANGLED_SITES_"` 184 190 KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` 185 191 Ogre OgreConfig `env:",prefix=TANGLED_OGRE_"` 192 + SSH SSHConfig `env:",prefix=TANGLED_SSH_"` 186 193 } 187 194 188 195 func LoadConfig(ctx context.Context) (*Config, error) {
+3 -3
appview/pages/templates/repo/pipelines/fragments/workflowSymbolOOB.html
··· 1 1 {{ define "repo/pipelines/fragments/workflowSymbolOOB" }} 2 - <div id="workflow-symbol-{{ normalizeForHtmlId .Name }}" class="flex-shrink-0" hx-swap-oob="outerHTML:#workflow-symbol-{{ normalizeForHtmlId .Name }}"> 3 - {{ template "repo/pipelines/fragments/workflowSymbol" .Statuses }} 4 - </div> 2 + <div id="workflow-symbol-{{ normalizeForHtmlId .Name }}" class="flex-shrink-0" hx-swap-oob="outerHTML:#workflow-symbol-{{ normalizeForHtmlId .Name }}"> 3 + {{ template "repo/pipelines/fragments/workflowSymbol" .Statuses }} 4 + </div> 5 5 {{ end }}
+31
appview/pipelines/ssh/error.go
··· 1 + package ssh 2 + 3 + import ( 4 + tea "github.com/charmbracelet/bubbletea" 5 + "github.com/charmbracelet/lipgloss" 6 + ) 7 + 8 + var ( 9 + colorBrightRed lipgloss.ANSIColor = 9 10 + ) 11 + 12 + type errorModel struct { 13 + renderer *lipgloss.Renderer 14 + message string 15 + } 16 + 17 + func newErrorModel(renderer *lipgloss.Renderer, message string) *errorModel { 18 + return &errorModel{renderer: renderer, message: message} 19 + } 20 + 21 + func (m *errorModel) Init() tea.Cmd { 22 + return tea.Quit 23 + } 24 + 25 + func (m *errorModel) Update(_ tea.Msg) (tea.Model, tea.Cmd) { 26 + return m, tea.Quit 27 + } 28 + 29 + func (m *errorModel) View() string { 30 + return m.renderer.NewStyle().Foreground(colorBrightRed).Render("error: "+m.message) + "\n" 31 + }
+47
appview/pipelines/ssh/logstream.go
··· 1 + package ssh 2 + 3 + import ( 4 + "time" 5 + 6 + tea "github.com/charmbracelet/bubbletea" 7 + "github.com/gorilla/websocket" 8 + "tangled.org/core/appview/pipelines" 9 + spindlemodel "tangled.org/core/spindle/models" 10 + ) 11 + 12 + type step struct { 13 + id int 14 + name string 15 + command string 16 + kind spindlemodel.StepKind 17 + lines []string 18 + startTime time.Time 19 + endTime time.Time 20 + finished bool 21 + } 22 + 23 + type logDoneMsg struct { 24 + workflow string 25 + err error 26 + } 27 + 28 + type logEventMsg struct { 29 + workflow string 30 + ev pipelines.LogEvent 31 + conn *websocket.Conn 32 + ch chan pipelines.LogEvent 33 + } 34 + 35 + func readNextCmd(workflow string, conn *websocket.Conn, ch chan pipelines.LogEvent) tea.Cmd { 36 + return func() tea.Msg { 37 + return readNextLogEvent(workflow, conn, ch) 38 + } 39 + } 40 + 41 + func readNextLogEvent(workflow string, conn *websocket.Conn, ch chan pipelines.LogEvent) tea.Msg { 42 + ev, ok := <-ch 43 + if !ok { 44 + return logDoneMsg{workflow: workflow} 45 + } 46 + return logEventMsg{workflow: workflow, ev: ev, conn: conn, ch: ch} 47 + }
+70
appview/pipelines/ssh/server.go
··· 1 + package ssh 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + 8 + "github.com/charmbracelet/ssh" 9 + "github.com/charmbracelet/wish" 10 + tea "github.com/charmbracelet/wish/bubbletea" 11 + "tangled.org/core/appview/config" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/pipelines" 14 + ) 15 + 16 + type Server struct { 17 + db *db.DB 18 + config *config.Config 19 + pipelineNotifier *pipelines.StatusNotifier 20 + logger *slog.Logger 21 + } 22 + 23 + func New(db *db.DB, cfg *config.Config, pn *pipelines.StatusNotifier, logger *slog.Logger) *Server { 24 + return &Server{db: db, config: cfg, pipelineNotifier: pn, logger: logger} 25 + } 26 + 27 + func (s *Server) ListenAndServe(ctx context.Context) error { 28 + opts := []ssh.Option{ 29 + wish.WithAddress(s.config.SSH.ListenAddr), 30 + wish.WithMiddleware( 31 + tea.Middleware(s.teaHandler), 32 + requirePty, 33 + ), 34 + } 35 + 36 + if s.config.SSH.HostKeyPath != "" { 37 + opts = append(opts, wish.WithHostKeyPath(s.config.SSH.HostKeyPath)) 38 + } 39 + 40 + srv, err := wish.NewServer(opts...) 41 + if err != nil { 42 + return err 43 + } 44 + 45 + go func() { 46 + <-ctx.Done() 47 + s.logger.Info("shutting down SSH log server") 48 + srv.Close() 49 + }() 50 + 51 + s.logger.Info("SSH log server listening", "address", s.config.SSH.ListenAddr) 52 + if err := srv.ListenAndServe(); err != ssh.ErrServerClosed { 53 + return err 54 + } 55 + s.logger.Info("SSH log server stopped") 56 + return nil 57 + } 58 + 59 + // requirePty is a middleware that rejects connections without a PTY and tells the user to pass -t. 60 + func requirePty(next ssh.Handler) ssh.Handler { 61 + return func(sess ssh.Session) { 62 + _, _, ok := sess.Pty() 63 + if !ok { 64 + fmt.Fprintf(sess.Stderr(), "error: no terminal allocated\nhint: use `ssh -t` to force PTY allocation\n") 65 + sess.Exit(1) 66 + return 67 + } 68 + next(sess) 69 + } 70 + }
+60
appview/pipelines/ssh/session.go
··· 1 + package ssh 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + tea "github.com/charmbracelet/bubbletea" 8 + "github.com/charmbracelet/ssh" 9 + wishtea "github.com/charmbracelet/wish/bubbletea" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/orm" 12 + ) 13 + 14 + func (s *Server) teaHandler(sess ssh.Session) (tea.Model, []tea.ProgramOption) { 15 + remote := sess.RemoteAddr().String() 16 + args := sess.Command() 17 + l := s.logger.With("remote", remote) 18 + l.Info("SSH connection", "args", args) 19 + defer l.Info("SSH connection closed", "remote", remote) 20 + 21 + renderer := wishtea.MakeRenderer(sess) 22 + 23 + if len(args) != 1 { 24 + l.Warn("bad invocation", "args", args) 25 + return newErrorModel(renderer, "usage: ssh -t <host> -p 2222 <at-uri>\nexample: ssh -t host -p 2222 at://did:web:knot.example/sh.tangled.pipeline/abc123"), wishtea.MakeOptions(sess) 26 + } 27 + 28 + rawURI := args[0] 29 + aturi, err := syntax.ParseATURI(rawURI) 30 + if err != nil { 31 + l.Warn("invalid AT URI", "uri", rawURI, "err", err) 32 + return newErrorModel(renderer, fmt.Sprintf("invalid AT URI %q: %v", rawURI, err)), wishtea.MakeOptions(sess) 33 + } 34 + 35 + did := aturi.Authority().String() 36 + const didWebPrefix = "did:web:" 37 + if len(did) <= len(didWebPrefix) { 38 + l.Warn("unsupported DID format", "did", did) 39 + return newErrorModel(renderer, fmt.Sprintf("unsupported DID format %q (expected did:web:...)", did)), wishtea.MakeOptions(sess) 40 + } 41 + knot := did[len(didWebPrefix):] 42 + rkey := aturi.RecordKey().String() 43 + 44 + l = l.With("knot", knot, "rkey", rkey) 45 + 46 + pipelines, err := db.GetPipelineStatuses(s.db, 1, 47 + orm.FilterEq("p.knot", knot), 48 + orm.FilterEq("p.rkey", rkey), 49 + ) 50 + if err != nil || len(pipelines) == 0 { 51 + l.Warn("pipeline not found", "err", err) 52 + return newErrorModel(renderer, fmt.Sprintf("pipeline not found: %s", rawURI)), wishtea.MakeOptions(sess) 53 + } 54 + 55 + pipeline := pipelines[0] 56 + l.Info("serving pipeline", "workflows", len(pipeline.Statuses)) 57 + pty, _, _ := sess.Pty() 58 + opts := append(wishtea.MakeOptions(sess), tea.WithAltScreen()) 59 + return newPipelineModel(renderer, s, pipeline, pty.Window.Width, pty.Window.Height), opts 60 + }
+447
appview/pipelines/ssh/tui.go
··· 1 + package ssh 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "github.com/charmbracelet/bubbles/spinner" 10 + "github.com/charmbracelet/bubbles/viewport" 11 + tea "github.com/charmbracelet/bubbletea" 12 + "github.com/charmbracelet/lipgloss" 13 + "github.com/gorilla/websocket" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pipelines" 17 + "tangled.org/core/orm" 18 + spindlemodel "tangled.org/core/spindle/models" 19 + ) 20 + 21 + var ( 22 + colorWhite lipgloss.ANSIColor = 7 23 + colorBlue lipgloss.ANSIColor = 4 24 + colorBrightBlack lipgloss.ANSIColor = 8 25 + colorDarkGrey lipgloss.ANSIColor = 0 26 + ) 27 + 28 + type tickMsg time.Time 29 + 30 + type statusUpdateMsg struct { 31 + pipeline models.Pipeline 32 + } 33 + 34 + type statusUpdateErrMsg struct{ err error } 35 + 36 + type pipelineModel struct { 37 + renderer *lipgloss.Renderer 38 + server *Server 39 + pipeline models.Pipeline 40 + workflows []string 41 + selected int 42 + logs map[string]*workflowLogs 43 + statusCh chan struct{} 44 + spinner spinner.Model 45 + width int 46 + height int 47 + } 48 + 49 + type workflowLogs struct { 50 + steps []step 51 + stepIndex map[int]int 52 + vp viewport.Model 53 + ready bool 54 + done bool 55 + err error 56 + } 57 + 58 + func newPipelineModel(renderer *lipgloss.Renderer, s *Server, pipeline models.Pipeline, width, height int) *pipelineModel { 59 + workflows := pipeline.Workflows() 60 + logs := make(map[string]*workflowLogs, len(workflows)) 61 + for _, wf := range workflows { 62 + logs[wf] = &workflowLogs{stepIndex: make(map[int]int)} 63 + } 64 + statusCh := s.pipelineNotifier.Subscribe(pipeline.AtUri()) 65 + sp := spinner.New(spinner.WithSpinner(spinner.Line)) 66 + return &pipelineModel{ 67 + renderer: renderer, 68 + server: s, 69 + pipeline: pipeline, 70 + workflows: workflows, 71 + logs: logs, 72 + statusCh: statusCh, 73 + spinner: sp, 74 + width: width, 75 + height: height, 76 + } 77 + } 78 + 79 + func (m *pipelineModel) Init() tea.Cmd { 80 + cmds := []tea.Cmd{tick(), m.spinner.Tick, m.waitForStatusUpdate(m.statusCh)} 81 + for _, wf := range m.workflows { 82 + cmds = append(cmds, m.connectCmd(wf)) 83 + } 84 + return tea.Batch(cmds...) 85 + } 86 + 87 + func tick() tea.Cmd { 88 + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) 89 + } 90 + 91 + // waitForStatusUpdate blocks on the notifier channel, re-fetches pipeline statuses, and returns the result as a tea.Msg. 92 + func (m *pipelineModel) waitForStatusUpdate(ch chan struct{}) tea.Cmd { 93 + knot := m.pipeline.Knot 94 + rkey := m.pipeline.Rkey 95 + return func() tea.Msg { 96 + if _, ok := <-ch; !ok { 97 + return nil 98 + } 99 + ps, err := db.GetPipelineStatuses(m.server.db, 1, 100 + orm.FilterEq("p.knot", knot), 101 + orm.FilterEq("p.rkey", rkey), 102 + ) 103 + if err != nil || len(ps) == 0 { 104 + return statusUpdateErrMsg{err: fmt.Errorf("refreshing pipeline: %w", err)} 105 + } 106 + return statusUpdateMsg{pipeline: ps[0]} 107 + } 108 + } 109 + 110 + // connectCmd dials the spindle websocket for the given workflow and starts streaming log events. 111 + func (m *pipelineModel) connectCmd(workflow string) tea.Cmd { 112 + return func() tea.Msg { 113 + ws, ok := m.pipeline.Statuses[workflow] 114 + if !ok || len(ws.Data) == 0 { 115 + return logDoneMsg{workflow: workflow} 116 + } 117 + url := pipelines.SpindleURL(m.server.config.Core.Dev, ws.Data[0].Spindle, m.pipeline.Knot, m.pipeline.Rkey, workflow) 118 + conn, _, err := websocket.DefaultDialer.Dial(url, nil) 119 + if err != nil { 120 + return logDoneMsg{workflow: workflow, err: fmt.Errorf("connecting to spindle: %w", err)} 121 + } 122 + ch := make(chan pipelines.LogEvent, 100) 123 + go pipelines.ReadLogs(conn, ch) 124 + return readNextLogEvent(workflow, conn, ch) 125 + } 126 + } 127 + 128 + func (m *pipelineModel) vpHeight() int { 129 + return max(m.height-2, 1) // topbar + divider take 2 lines 130 + } 131 + 132 + // resizeViewports updates all viewport dimensions and re-renders their content after a terminal resize. 133 + // 134 + // TODO: can be tedious if we have logs of logs 135 + func (m *pipelineModel) resizeViewports() { 136 + for _, wl := range m.logs { 137 + if !wl.ready { 138 + continue 139 + } 140 + atBottom := wl.vp.AtBottom() 141 + wl.vp.Width = m.width 142 + wl.vp.Height = m.vpHeight() 143 + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 144 + if atBottom { 145 + wl.vp.GotoBottom() 146 + } 147 + } 148 + } 149 + 150 + func (m *pipelineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 151 + switch msg := msg.(type) { 152 + case tea.WindowSizeMsg: 153 + m.width, m.height = msg.Width, msg.Height 154 + m.resizeViewports() 155 + 156 + case tickMsg: 157 + return m, tick() 158 + 159 + case spinner.TickMsg: 160 + var cmd tea.Cmd 161 + m.spinner, cmd = m.spinner.Update(msg) 162 + return m, cmd 163 + 164 + case tea.KeyMsg: 165 + switch msg.String() { 166 + case "q", "ctrl+c": 167 + m.server.pipelineNotifier.Unsubscribe(m.pipeline.AtUri(), m.statusCh) 168 + return m, tea.Quit 169 + case "tab", "right", "l": 170 + m.selected = (m.selected + 1) % len(m.workflows) 171 + return m, nil 172 + case "shift+tab", "left", "h": 173 + m.selected = (m.selected - 1 + len(m.workflows)) % len(m.workflows) 174 + return m, nil 175 + } 176 + if wl := m.selectedLogs(); wl != nil && wl.ready { 177 + switch msg.String() { 178 + case "g": 179 + wl.vp.GotoTop() 180 + return m, nil 181 + case "G": 182 + wl.vp.GotoBottom() 183 + return m, nil 184 + case "ctrl+e": 185 + wl.vp.ScrollDown(1) 186 + return m, nil 187 + case "ctrl+y": 188 + wl.vp.ScrollUp(1) 189 + return m, nil 190 + } 191 + var cmd tea.Cmd 192 + wl.vp, cmd = wl.vp.Update(msg) 193 + return m, cmd 194 + } 195 + 196 + case logEventMsg: 197 + return m, m.handleLogEvent(msg) 198 + 199 + case logDoneMsg: 200 + if wl, ok := m.logs[msg.workflow]; ok { 201 + wl.done, wl.err = true, msg.err 202 + m.initViewport(wl) 203 + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 204 + wl.vp.GotoBottom() 205 + } 206 + 207 + case statusUpdateMsg: 208 + // detect any workflows that are new since the last update 209 + known := make(map[string]bool, len(m.workflows)) 210 + for _, wf := range m.workflows { 211 + known[wf] = true 212 + } 213 + m.pipeline = msg.pipeline 214 + var newCmds []tea.Cmd 215 + for _, wf := range msg.pipeline.Workflows() { 216 + if !known[wf] { 217 + m.workflows = append(m.workflows, wf) 218 + m.logs[wf] = &workflowLogs{stepIndex: make(map[int]int)} 219 + newCmds = append(newCmds, m.connectCmd(wf)) 220 + } 221 + } 222 + // re-subscribe for the next update 223 + newCmds = append(newCmds, m.waitForStatusUpdate(m.statusCh)) 224 + return m, tea.Batch(newCmds...) 225 + 226 + case statusUpdateErrMsg: 227 + // re-subscribe even on error so we don't stop listening 228 + return m, m.waitForStatusUpdate(m.statusCh) 229 + } 230 + 231 + return m, nil 232 + } 233 + 234 + func (m *pipelineModel) selectedLogs() *workflowLogs { 235 + if len(m.workflows) == 0 { 236 + return nil 237 + } 238 + return m.logs[m.workflows[m.selected]] 239 + } 240 + 241 + func (m *pipelineModel) initViewport(wl *workflowLogs) { 242 + if wl.ready { 243 + return 244 + } 245 + wl.vp = viewport.New(m.width, m.vpHeight()) 246 + wl.ready = true 247 + } 248 + 249 + // handleLogEvent processes a single log event, updates the step state, and re-renders the viewport. 250 + func (m *pipelineModel) handleLogEvent(msg logEventMsg) tea.Cmd { 251 + wl, ok := m.logs[msg.workflow] 252 + if !ok { 253 + return nil 254 + } 255 + if msg.ev.Err != nil { 256 + wl.done = true 257 + if !msg.ev.IsCloseError() { 258 + wl.err = msg.ev.Err 259 + } 260 + m.initViewport(wl) 261 + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 262 + return nil 263 + } 264 + var line spindlemodel.LogLine 265 + if err := json.Unmarshal(msg.ev.Msg, &line); err != nil { 266 + return readNextCmd(msg.workflow, msg.conn, msg.ch) 267 + } 268 + applyLogLine(wl, line) 269 + m.initViewport(wl) 270 + atBottom := wl.vp.AtBottom() 271 + wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 272 + if atBottom { 273 + wl.vp.GotoBottom() 274 + } 275 + return readNextCmd(msg.workflow, msg.conn, msg.ch) 276 + } 277 + 278 + // applyLogLine mutates wl by appending the log line to the appropriate step. 279 + func applyLogLine(wl *workflowLogs, line spindlemodel.LogLine) { 280 + switch line.Kind { 281 + case spindlemodel.LogKindControl: 282 + switch line.StepStatus { 283 + case spindlemodel.StepStatusStart: 284 + idx := len(wl.steps) 285 + wl.stepIndex[line.StepId] = idx 286 + wl.steps = append(wl.steps, step{ 287 + id: line.StepId, name: line.Content, command: line.StepCommand, 288 + kind: line.StepKind, startTime: line.Time, 289 + }) 290 + case spindlemodel.StepStatusEnd: 291 + if idx, ok := wl.stepIndex[line.StepId]; ok { 292 + wl.steps[idx].endTime, wl.steps[idx].finished = line.Time, true 293 + } 294 + } 295 + case spindlemodel.LogKindData: 296 + if idx, ok := wl.stepIndex[line.StepId]; ok { 297 + wl.steps[idx].lines = append(wl.steps[idx].lines, line.Content) 298 + } 299 + } 300 + } 301 + 302 + // renderLogs builds the full log content string for a workflow, used as viewport content. 303 + func renderLogs(r *lipgloss.Renderer, wl *workflowLogs, width int) string { 304 + headerStyle := r.NewStyle().Foreground(colorWhite).Background(colorBrightBlack).Bold(true) 305 + cmdStyle := r.NewStyle().Foreground(colorBlue).Background(colorDarkGrey).Width(width) 306 + lineStyle := r.NewStyle().Foreground(colorWhite).Background(colorDarkGrey).Width(width) 307 + now := time.Now() 308 + var sb strings.Builder 309 + for i := range wl.steps { 310 + st := &wl.steps[i] 311 + dur := "" 312 + if st.finished { 313 + dur = st.endTime.Sub(st.startTime).Round(time.Millisecond).String() 314 + } else if !st.startTime.IsZero() { 315 + dur = now.Sub(st.startTime).Round(time.Second).String() 316 + } 317 + durRendered := headerStyle.Render(dur) 318 + nameWidth := max(width-lipgloss.Width(dur)-1, 1) 319 + header := fmt.Sprintf("%-*s ", nameWidth, st.name) + durRendered 320 + sb.WriteString(headerStyle.Width(width).Render(header) + "\n") 321 + if st.command != "" { 322 + sb.WriteString(cmdStyle.Render(st.command) + "\n") 323 + } 324 + for _, l := range st.lines { 325 + sb.WriteString(lineStyle.Render(l) + "\n") 326 + } 327 + sb.WriteString("\n") 328 + } 329 + if wl.done && wl.err != nil { 330 + sb.WriteString("error: " + wl.err.Error() + "\n") 331 + } 332 + return sb.String() 333 + } 334 + 335 + func (m *pipelineModel) View() string { 336 + r := m.renderer 337 + divider := r.NewStyle().Foreground(colorBrightBlack).Render(strings.Repeat("─", m.width)) 338 + body := "" 339 + if wl := m.selectedLogs(); wl != nil && wl.ready { 340 + body = wl.vp.View() 341 + } 342 + return lipgloss.JoinVertical(lipgloss.Left, m.topbarView(), divider, body) 343 + } 344 + 345 + // topbarView renders the single-line tab bar with workflow tabs left and trigger info + help right. 346 + func (m *pipelineModel) topbarView() string { 347 + r := m.renderer 348 + activeStyle := r.NewStyle().Background(colorBlue).Foreground(colorWhite) 349 + 350 + now := time.Now() 351 + 352 + var tabs strings.Builder 353 + for i, wf := range m.workflows { 354 + status := spindlemodel.StatusKindPending 355 + elapsed := "" 356 + if ws, ok := m.pipeline.Statuses[wf]; ok { 357 + latest := ws.Latest() 358 + status = latest.Status 359 + if t := ws.TimeTaken(); t > 0 { 360 + elapsed = t.Round(time.Second).String() 361 + } else { 362 + elapsed = now.Sub(latest.Created).Round(time.Second).String() 363 + } 364 + } 365 + dim := r.NewStyle().Faint(true) 366 + base := " " + statusIcon(status, m.spinner.View()) + " " + wf 367 + if i == m.selected { 368 + tab := base 369 + if elapsed != "" { 370 + tab += " " + elapsed 371 + } 372 + tab += " " 373 + tabs.WriteString(activeStyle.Render(tab)) 374 + } else { 375 + tabs.WriteString(base) 376 + if elapsed != "" { 377 + tabs.WriteString(" " + dim.Render(elapsed)) 378 + } 379 + tabs.WriteString(" ") 380 + } 381 + } 382 + 383 + tabsStr := tabs.String() 384 + infoStr := triggerLine(r, m.pipeline.Trigger, m.pipeline.Sha) + " · " + helpText(r) 385 + 386 + gap := max(m.width-lipgloss.Width(tabsStr)-lipgloss.Width(infoStr), 1) 387 + 388 + return tabsStr + strings.Repeat(" ", gap) + infoStr 389 + } 390 + 391 + func helpText(r *lipgloss.Renderer) string { 392 + key := r.NewStyle().Foreground(colorWhite) 393 + action := r.NewStyle().Faint(true) 394 + sep := action.Render(" · ") 395 + 396 + items := []string{ 397 + key.Render("←/→") + " " + action.Render("switch"), 398 + key.Render("↑/↓") + " " + action.Render("scroll"), 399 + key.Render("q") + " " + action.Render("quit"), 400 + } 401 + return strings.Join(items, sep) 402 + } 403 + 404 + func shortSha(sha string) string { 405 + if len(sha) >= 8 { 406 + return sha[:8] 407 + } 408 + return sha 409 + } 410 + 411 + func triggerLine(r *lipgloss.Renderer, t *models.Trigger, sha string) string { 412 + hash := shortSha(sha) 413 + dim := r.NewStyle().Faint(true) 414 + if t == nil { 415 + return dim.Render(hash) 416 + } 417 + if t.IsPush() { 418 + return t.TargetRef() + dim.Render("@"+hash) + dim.Render(" (push)") 419 + } 420 + if t.IsPullRequest() { 421 + source := "" 422 + if t.PRSourceBranch != nil { 423 + source = *t.PRSourceBranch 424 + } 425 + return t.TargetRef() + dim.Render(" <- "+source+"@"+hash) + dim.Render(" (pull-request)") 426 + } 427 + return dim.Render(hash) 428 + } 429 + 430 + func statusIcon(status spindlemodel.StatusKind, spinnerFrame string) string { 431 + switch status { 432 + case spindlemodel.StatusKindSuccess: 433 + return "✓" 434 + case spindlemodel.StatusKindFailed: 435 + return "×" 436 + case spindlemodel.StatusKindRunning: 437 + return spinnerFrame 438 + case spindlemodel.StatusKindPending: 439 + return "·" 440 + case spindlemodel.StatusKindTimeout: 441 + return "⌀" 442 + case spindlemodel.StatusKindCancelled: 443 + return "-" 444 + default: 445 + return "?" 446 + } 447 + }
+4
appview/state/state.go
··· 254 254 return s.db.Close() 255 255 } 256 256 257 + func (s *State) NewSSHServer() *pipelinessh.Server { 258 + return pipelinessh.New(s.db, s.config, s.pipelineNotifier, log.SubLogger(s.logger, "pipelinessh")) 259 + } 260 + 257 261 func (s *State) SecurityTxt(w http.ResponseWriter, r *http.Request) { 258 262 w.Header().Set("Content-Type", "text/plain") 259 263 w.Header().Set("Cache-Control", "public, max-age=86400") // one day
+9
cmd/appview/main.go
··· 44 44 } 45 45 }() 46 46 47 + if c.SSH.Enabled { 48 + sshServer := state.NewSSHServer() 49 + go func() { 50 + if err := sshServer.ListenAndServe(ctx); err != nil { 51 + logger.Error("SSH server stopped", "err", err) 52 + } 53 + }() 54 + } 55 + 47 56 if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 48 57 logger.Error("failed to start appview", "err", err) 49 58 }
+27 -9
go.mod
··· 19 19 github.com/bmatcuk/doublestar/v4 v4.9.1 20 20 github.com/carlmjohnson/versioninfo v0.22.5 21 21 github.com/casbin/casbin/v2 v2.103.0 22 + github.com/charmbracelet/bubbles v1.0.0 23 + github.com/charmbracelet/bubbletea v1.3.10 24 + github.com/charmbracelet/lipgloss v1.1.0 22 25 github.com/charmbracelet/log v0.4.2 26 + github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 27 + github.com/charmbracelet/wish v1.4.7 23 28 github.com/cloudflare/cloudflare-go/v6 v6.7.0 24 29 github.com/cyphar/filepath-securejoin v0.4.1 25 30 github.com/dgraph-io/ristretto v0.2.0 ··· 43 48 github.com/jackc/pgx/v5 v5.8.0 44 49 github.com/mattn/go-sqlite3 v1.14.34 45 50 github.com/microcosm-cc/bluemonday v1.0.27 51 + github.com/multiformats/go-multihash v0.2.3 46 52 github.com/openbao/openbao/api/v2 v2.3.0 47 53 github.com/posthog/posthog-go v1.5.5 48 54 github.com/prometheus/client_golang v1.23.2 ··· 94 100 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 95 101 github.com/aymerick/douceur v0.2.0 // indirect 96 102 github.com/beorn7/perks v1.0.1 // indirect 97 - github.com/bits-and-blooms/bitset v1.22.0 // indirect 103 + github.com/bits-and-blooms/bitset v1.24.4 // indirect 98 104 github.com/blevesearch/bleve_index_api v1.2.8 // indirect 99 105 github.com/blevesearch/geo v0.2.4 // indirect 100 106 github.com/blevesearch/go-faiss v1.0.25 // indirect ··· 115 121 github.com/casbin/govaluate v1.3.0 // indirect 116 122 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 117 123 github.com/cespare/xxhash/v2 v2.3.0 // indirect 118 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 119 - github.com/charmbracelet/lipgloss v1.1.0 // indirect 120 - github.com/charmbracelet/x/ansi v0.8.0 // indirect 121 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 122 - github.com/charmbracelet/x/term v0.2.1 // indirect 124 + github.com/charmbracelet/colorprofile v0.4.1 // indirect 125 + github.com/charmbracelet/keygen v0.5.3 // indirect 126 + github.com/charmbracelet/x/ansi v0.11.6 // indirect 127 + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect 128 + github.com/charmbracelet/x/conpty v0.1.0 // indirect 129 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect 130 + github.com/charmbracelet/x/input v0.3.4 // indirect 131 + github.com/charmbracelet/x/term v0.2.2 // indirect 132 + github.com/charmbracelet/x/termios v0.1.0 // indirect 133 + github.com/charmbracelet/x/windows v0.2.0 // indirect 134 + github.com/clipperhouse/displaywidth v0.9.0 // indirect 135 + github.com/clipperhouse/stringish v0.1.1 // indirect 136 + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect 123 137 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 124 138 github.com/containerd/errdefs v1.0.0 // indirect 125 139 github.com/containerd/errdefs/pkg v0.3.0 // indirect 126 140 github.com/containerd/log v0.1.0 // indirect 141 + github.com/creack/pty v1.1.21 // indirect 127 142 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 128 143 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 129 144 github.com/distribution/reference v0.6.0 // indirect ··· 132 147 github.com/docker/go-units v0.5.0 // indirect 133 148 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 134 149 github.com/emirpasic/gods v1.18.1 // indirect 150 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 135 151 github.com/felixge/httpsnoop v1.0.4 // indirect 136 152 github.com/fsnotify/fsnotify v1.6.0 // indirect 137 153 github.com/go-enry/go-oniguruma v1.2.1 // indirect ··· 195 211 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 196 212 github.com/labstack/echo/v4 v4.11.3 // indirect 197 213 github.com/labstack/gommon v0.4.1 // indirect 198 - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 214 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 199 215 github.com/mattn/go-colorable v0.1.14 // indirect 200 216 github.com/mattn/go-isatty v0.0.20 // indirect 201 - github.com/mattn/go-runewidth v0.0.16 // indirect 217 + github.com/mattn/go-localereader v0.0.1 // indirect 218 + github.com/mattn/go-runewidth v0.0.19 // indirect 202 219 github.com/minio/sha256-simd v1.0.1 // indirect 203 220 github.com/mitchellh/mapstructure v1.5.0 // indirect 204 221 github.com/moby/docker-image-spec v1.3.1 // indirect ··· 209 226 github.com/morikuni/aec v1.0.0 // indirect 210 227 github.com/mr-tron/base58 v1.2.0 // indirect 211 228 github.com/mschoch/smat v0.2.0 // indirect 229 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 230 + github.com/muesli/cancelreader v0.2.2 // indirect 212 231 github.com/muesli/termenv v0.16.0 // indirect 213 232 github.com/multiformats/go-base32 v0.1.0 // indirect 214 233 github.com/multiformats/go-base36 v0.2.0 // indirect 215 234 github.com/multiformats/go-multibase v0.2.0 // indirect 216 - github.com/multiformats/go-multihash v0.2.3 // indirect 217 235 github.com/multiformats/go-varint v0.1.0 // indirect 218 236 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 219 237 github.com/onsi/gomega v1.37.0 // indirect
+51 -15
go.sum
··· 74 74 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 75 75 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 76 76 github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 77 - github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= 78 - github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 77 + github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= 78 + github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 79 79 github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= 80 80 github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= 81 81 github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y= ··· 140 140 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 141 141 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 142 142 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 143 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 144 - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 143 + github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= 144 + github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= 145 + github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 146 + github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 147 + github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= 148 + github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 149 + github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= 150 + github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= 145 151 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 146 152 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 147 153 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 148 154 github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 149 - github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 150 - github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 151 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 152 - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 153 - github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 154 - github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 155 + github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894 h1:Ffon9TbltLGBsT6XE//YvNuu4OAaThXioqalhH11xEw= 156 + github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894/go.mod h1:hg+I6gvlMl16nS9ZzQNgBIrrCasGwEw0QiLsDcP01Ko= 157 + github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= 158 + github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= 159 + github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= 160 + github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 161 + github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= 162 + github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 163 + github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 164 + github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 165 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 166 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 167 + github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= 168 + github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= 169 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 170 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 171 + github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= 172 + github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= 173 + github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= 174 + github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= 155 175 github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= 156 176 github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= 157 177 github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0= ··· 161 181 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 162 182 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 163 183 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 184 + github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= 185 + github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= 186 + github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 187 + github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 188 + github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= 189 + github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 164 190 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 165 191 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 166 192 github.com/cloudflare/cloudflare-go/v6 v6.7.0 h1:MP6Xy5WmsyrxgTxoLeq/vraqR0nbTtXoHhW4vAYc4SY= ··· 173 199 github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 174 200 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 175 201 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 202 + github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= 203 + github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 176 204 github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 177 205 github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 178 206 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= ··· 215 243 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 216 244 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 217 245 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 246 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 247 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 218 248 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 219 249 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 220 250 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= ··· 476 506 github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= 477 507 github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= 478 508 github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= 479 - github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 480 - github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 509 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 510 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 481 511 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 482 512 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 483 513 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 484 514 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 485 - github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 486 - github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 515 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 516 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 517 + github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 518 + github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 487 519 github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= 488 520 github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 489 521 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 511 543 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 512 544 github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= 513 545 github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= 546 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 547 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 548 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 549 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 514 550 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 515 551 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 516 552 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= ··· 600 636 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 601 637 github.com/resend/resend-go/v3 v3.5.0 h1:yScYxHinY352Mj7Cn9rbWsR2gDqD2mtFPWwh2UyFMeE= 602 638 github.com/resend/resend-go/v3 v3.5.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE= 603 - github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 604 639 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 605 640 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 606 641 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 800 835 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 801 836 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 802 837 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 838 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 803 839 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 804 840 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 805 841 golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=