diff --git a/appview/config/config.go b/appview/config/config.go
index f55b159a..0c422989 100644
--- 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 @@ type Config struct {
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
index baef7dea..6786ca2a 100644
--- 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
index 00000000..d8ed4aab
--- /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
index 00000000..8d0a3f6a
--- /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
index 00000000..96fff723
--- /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
index 00000000..c1a9a275
--- /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
index 00000000..19309984
--- /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
index c36c76fa..941aef1b 100644
--- a/appview/state/state.go
+++ b/appview/state/state.go
@@ -254,6 +254,10 @@ func (s *State) Close() error {
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
index 54c38653..e4e6b842 100644
--- a/cmd/appview/main.go
+++ b/cmd/appview/main.go
@@ -44,6 +44,15 @@ func main() {
}
}()
+ 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
index 5a442303..f5b020f6 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,12 @@ require (
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 @@ require (
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 @@ require (
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 @@ require (
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 @@ require (
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 @@ require (
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 @@ require (
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
index fcad8535..a1363340 100644
--- a/go.sum
+++ b/go.sum
@@ -74,8 +74,8 @@ github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
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/j3BFtOZR5XL
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:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHG
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+XErNsatSElgRZZVCwXX9QzkKYNvO7x0
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:h1NjWce9XRLGQEsW7w
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=