mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
One login: pod/IRC/news/Tor as hub menu items + single-login onboarding
The join@ output and hub previously pushed members to ssh into separate servers (ssh pod@, ssh irc@, ssh news@, ssh tor@). Members now reach everything from one `ssh <name>@bbs.profullstack.com` login: - hub: new SessionApp entries (Pod, IRC, News, Tor shell) run as terminal-takeover features via tea.Exec, then return to the menu. Gated by membership/email-verification/plan; shown locked otherwise. - main: build the session apps in teaHandler; extract runIRC/runNews so the irc@/news@ routes and the hub share one path. The old pod@/irc@/ news@/tor@ routes stay as aliases (handy for bots). - onboarding: rewrite the "You're in" and Founding-Member copy to present ONE login and stop advertising separate servers. - email: member mailboxes move to mail.profullstack.com (apex reserved for corporate mail); webmail shown at https://mail.profullstack.com. - username: harden the default handle to a safe /home/<name> token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
579999153e
commit
d4ada98b69
2 changed files with 252 additions and 63 deletions
|
|
@ -150,7 +150,9 @@ func main() {
|
|||
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
|
||||
fe := forwardemail.ConfigFromEnv()
|
||||
if fe.Domain == "" {
|
||||
fe.Domain = host // personal addresses live on the BBS host by default
|
||||
// Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com),
|
||||
// not the BBS host and not the apex (which is reserved for corporate mail).
|
||||
fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
|
||||
}
|
||||
a := &app{
|
||||
st: st,
|
||||
|
|
@ -332,7 +334,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||
username := strings.ToLower(s.User())
|
||||
|
||||
var u auth.User
|
||||
if auth.IsGuestName(username) || fp == "" {
|
||||
var su store.User
|
||||
guest := auth.IsGuestName(username) || fp == ""
|
||||
if guest {
|
||||
// Keyless or explicitly anonymous → guest. Named accounts require a key.
|
||||
if !auth.IsGuestName(username) {
|
||||
wish.Println(s, "note: member access requires an SSH key; joining as guest.")
|
||||
|
|
@ -341,7 +345,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||
} else {
|
||||
// A key maps to exactly one account: if this key is already
|
||||
// registered, that identity wins regardless of the username typed.
|
||||
su, found, err := a.st.UserByFingerprint(fp)
|
||||
var found bool
|
||||
var err error
|
||||
su, found, err = a.st.UserByFingerprint(fp)
|
||||
if err == nil && !found {
|
||||
su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp)
|
||||
}
|
||||
|
|
@ -377,7 +383,88 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||
// URL works the moment a member first signs in.
|
||||
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
|
||||
}
|
||||
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
|
||||
return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest)), []tea.ProgramOption{tea.WithAltScreen()}
|
||||
}
|
||||
|
||||
// sessionExec adapts a func to tea.ExecCommand so the hub can run a
|
||||
// terminal-takeover feature (pod shell, IRC, news, mail, Tor) via tea.Exec and
|
||||
// return to the menu afterwards. The feature reads and writes the ssh.Session
|
||||
// directly, so the stream hooks are no-ops.
|
||||
type sessionExec struct{ run func() error }
|
||||
|
||||
func (e sessionExec) Run() error { return e.run() }
|
||||
func (e sessionExec) SetStdin(io.Reader) {}
|
||||
func (e sessionExec) SetStdout(io.Writer) {}
|
||||
func (e sessionExec) SetStderr(io.Writer) {}
|
||||
|
||||
// sessionApps builds the hub's terminal-takeover entries (pod, IRC, news, Tor)
|
||||
// so a member reaches everything from one `ssh <name>@host` login instead of
|
||||
// separate `ssh pod@`/`irc@`/`news@`/`tor@` connections (which still work as
|
||||
// aliases, mainly for bots). Each entry is gated by membership/verification/plan
|
||||
// and shown locked with a reason when unavailable.
|
||||
func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.SessionApp {
|
||||
membersOnly := "members only — register first: ssh join@" + a.host
|
||||
apps := make([]hub.SessionApp, 0, 4)
|
||||
|
||||
// Pod — free for verified members.
|
||||
podLock := ""
|
||||
switch {
|
||||
case guest:
|
||||
podLock = membersOnly
|
||||
case env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !su.EmailVerified:
|
||||
podLock = "confirm your email first — re-run: ssh join@" + a.host
|
||||
case a.pods == nil:
|
||||
podLock = "pods are temporarily unavailable on this host"
|
||||
}
|
||||
apps = append(apps, hub.SessionApp{
|
||||
Title: "Pod",
|
||||
Description: "your own Linux pod — a full shell",
|
||||
Locked: podLock,
|
||||
Cmd: sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
|
||||
})
|
||||
|
||||
// IRC — free for any registered member.
|
||||
ircLock := ""
|
||||
if guest {
|
||||
ircLock = membersOnly
|
||||
}
|
||||
apps = append(apps, hub.SessionApp{
|
||||
Title: "IRC",
|
||||
Description: "members-only chat network (humans + agents)",
|
||||
Locked: ircLock,
|
||||
Cmd: sessionExec{run: func() error { return a.runIRC(s, su.Name, su.Premium) }},
|
||||
})
|
||||
|
||||
// News — free for any registered member.
|
||||
newsLock := ""
|
||||
if guest {
|
||||
newsLock = membersOnly
|
||||
}
|
||||
apps = append(apps, hub.SessionApp{
|
||||
Title: "News",
|
||||
Description: "members-only Usenet/NNTP discussion",
|
||||
Locked: newsLock,
|
||||
Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }},
|
||||
})
|
||||
|
||||
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
|
||||
torLock := ""
|
||||
switch {
|
||||
case guest:
|
||||
torLock = membersOnly
|
||||
case !su.Premium:
|
||||
torLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
|
||||
case a.pods == nil:
|
||||
torLock = "pods are temporarily unavailable on this host"
|
||||
}
|
||||
apps = append(apps, hub.SessionApp{
|
||||
Title: "Tor shell",
|
||||
Description: "a torsocks shell in your pod (everything over Tor)",
|
||||
Locked: torLock,
|
||||
Cmd: sessionExec{run: func() error { return a.pods.Exec(s, su.Name, tor.Torsocks([]string{"bash", "-l"})) }},
|
||||
})
|
||||
|
||||
return apps
|
||||
}
|
||||
|
||||
// readLine reads one line of interactive input from an SSH session that is
|
||||
|
|
@ -464,7 +551,7 @@ func (a *app) handleJoin(s ssh.Session) {
|
|||
}, "\n"))
|
||||
|
||||
// 1) email -> emailed code -> enter code. A verified account is a free
|
||||
// member: it gets a Docker pod (ssh pod@) and a /~name homepage.
|
||||
// member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub.
|
||||
if !u.EmailVerified {
|
||||
if !a.verifyEmailInteractive(s, in, &u) {
|
||||
_ = s.Exit(1)
|
||||
|
|
@ -477,10 +564,15 @@ func (a *app) handleJoin(s ssh.Session) {
|
|||
seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host)
|
||||
|
||||
wish.Println(s, "\n"+strings.Join([]string{
|
||||
" You're in — free membership includes:",
|
||||
" pod ssh pod@" + a.host + " your own Linux pod",
|
||||
" hub ssh " + u.Name + "@" + a.host,
|
||||
" homepage https://" + a.host + "/~" + u.Name,
|
||||
" You're in. One login gets you everything — no other servers to ssh into:",
|
||||
"",
|
||||
" ssh " + u.Name + "@" + a.host,
|
||||
"",
|
||||
" Inside, free membership includes:",
|
||||
" • your own Linux pod (a full shell)",
|
||||
" • IRC chat + Usenet/news (members-only)",
|
||||
" • the arcade & games",
|
||||
" • your homepage https://" + a.host + "/~" + u.Name,
|
||||
}, "\n"))
|
||||
|
||||
// 2) Founding Lifetime ($99 one-time): personal @host email + custom domains.
|
||||
|
|
@ -515,14 +607,35 @@ func (a *app) acceptTerms(s ssh.Session, in *bufio.Reader) bool {
|
|||
}
|
||||
}
|
||||
|
||||
// fpToken derives up to n lowercase alphanumeric characters from an SSH key
|
||||
// fingerprint, for use in a default username/home-dir. The raw base64
|
||||
// fingerprint can contain '+' and '/', which are unsafe as a filesystem token,
|
||||
// so we keep only [a-z0-9].
|
||||
func fpToken(fp string, n int) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
return r
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, strings.ToLower(strings.TrimPrefix(fp, "SHA256:")))
|
||||
if len(cleaned) > n {
|
||||
cleaned = cleaned[:n]
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// registerNewMember asks the visitor to choose a username, then creates their
|
||||
// member account under it. The name is sanitized to the hub/subdomain charset,
|
||||
// rejected if reserved, and must be free; pressing enter accepts a generated
|
||||
// member-<fp8> default. Returns the created user.
|
||||
// member-<fp8> default. The chosen name is also the member's pod home
|
||||
// (/home/<name>), so it must be a safe shell/filesystem token — the sanitizer
|
||||
// (and the default below) keep it to lowercase [a-z0-9-]. Returns the user.
|
||||
func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) {
|
||||
def := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8]
|
||||
def := "member-" + fpToken(fp, 8)
|
||||
wish.Println(s, "\n Pick a username — letters, numbers and dashes, 3–20 chars.")
|
||||
wish.Println(s, " It's your handle for ssh <name>@"+a.host+" and https://"+a.host+"/~<name>.")
|
||||
wish.Println(s, " It's your handle for ssh <name>@"+a.host+" (your pod home is /home/<name>).")
|
||||
|
||||
for tries := 0; tries < 5; tries++ {
|
||||
wish.Print(s, "\n Username ["+def+"]: ")
|
||||
|
|
@ -651,23 +764,19 @@ func (a *app) ensurePremium(u *store.User) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// showPremiumWelcome prints a premium member's perks: their personal email,
|
||||
// where it forwards, the webmail URL, and custom domains.
|
||||
// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail
|
||||
// URL, the in-hub Mail/Tor entries, and custom domains.
|
||||
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
|
||||
lines := []string{
|
||||
"",
|
||||
" ★ Founding Lifetime Member — thanks! Your perks:",
|
||||
"",
|
||||
" email " + a.fe.Address(u.Name),
|
||||
" forwards " + u.Email,
|
||||
}
|
||||
if url := a.fe.WebmailURL(); url != "" {
|
||||
lines = append(lines, " webmail "+url)
|
||||
}
|
||||
lines = append(lines,
|
||||
" domains ssh domain@"+a.host+" add <yourdomain.com>",
|
||||
" mailbox " + a.fe.Address(u.Name),
|
||||
" webmail https://" + a.fe.Domain,
|
||||
" mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
|
||||
" domains ssh domain@" + a.host + " add <yourdomain.com>",
|
||||
"",
|
||||
)
|
||||
}
|
||||
wish.Println(s, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
|
|
@ -691,9 +800,9 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
|
|||
"",
|
||||
" Everything in your free membership stays free — founding adds these",
|
||||
" bonus features, forever:",
|
||||
" • your own email " + a.fe.Address(u.Name) + " (forwards to you, webmail included)",
|
||||
" • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")",
|
||||
" • custom domains point yourdomain.com at your homepage",
|
||||
" • Tor access ssh tor@" + a.host + " — fetch URLs & join IRC over Tor",
|
||||
" • Tor a “Tor shell” in your pod — everything over Tor",
|
||||
" • locked-in price founding rate is yours for life — never renew, never pay again",
|
||||
"",
|
||||
}
|
||||
|
|
@ -1071,21 +1180,28 @@ func (a *app) handleIRC(s ssh.Session) {
|
|||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc")
|
||||
defer func() { _ = a.st.EndSession(sessID) }()
|
||||
|
||||
// Premium members may /create channels; free members can still join existing
|
||||
// ones. ensurePremium also settles any pending charge.
|
||||
if err := a.runIRC(s, u.Name, a.ensurePremium(&u)); err != nil {
|
||||
wish.Println(s, "irc: "+err.Error())
|
||||
_ = s.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runIRC dials the members-only IRC network as name and runs the in-process
|
||||
// client TUI on the session. Shared by the irc@ route and the hub's IRC entry.
|
||||
func (a *app) runIRC(s ssh.Session, name string, canCreate bool) error {
|
||||
addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
|
||||
if addr == "" {
|
||||
addr = irc.DefaultAddr
|
||||
}
|
||||
log.Info("irc connect", "user", u.Name, "addr", addr)
|
||||
c, err := irc.Dial(s.Context(), addr, u.Name)
|
||||
log.Info("irc connect", "user", name, "addr", addr)
|
||||
c, err := irc.Dial(s.Context(), addr, name)
|
||||
if err != nil {
|
||||
wish.Println(s, "irc: "+err.Error())
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
return err
|
||||
}
|
||||
_ = c.Join(irc.DefaultChannel)
|
||||
if err := irc.Run(s, c); err != nil {
|
||||
wish.Println(s, "irc: "+err.Error())
|
||||
}
|
||||
return irc.Run(s, c, canCreate)
|
||||
}
|
||||
|
||||
// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server
|
||||
|
|
@ -1114,15 +1230,21 @@ func (a *app) handleNews(s ssh.Session) {
|
|||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news")
|
||||
defer func() { _ = a.st.EndSession(sessID) }()
|
||||
|
||||
if err := a.runNews(s, u.Name); err != nil {
|
||||
wish.Println(s, "news: "+err.Error())
|
||||
_ = s.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runNews runs the in-process newsreader TUI for name on the session. Shared by
|
||||
// the news@ route and the hub's News entry.
|
||||
func (a *app) runNews(s ssh.Session, name string) error {
|
||||
addr := a.newsAddr
|
||||
if addr == "" {
|
||||
addr = news.DefaultAddr
|
||||
}
|
||||
log.Info("news connect", "user", u.Name, "addr", addr)
|
||||
if err := news.RunReader(s, addr, u.Name); err != nil {
|
||||
wish.Println(s, "news: "+err.Error())
|
||||
_ = s.Exit(1)
|
||||
}
|
||||
log.Info("news connect", "user", name, "addr", addr)
|
||||
return news.RunReader(s, addr, name)
|
||||
}
|
||||
|
||||
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
// Package hub is the Bubble Tea model every BBS session lands in (PRD §4.1):
|
||||
// it lists registered plugins and routes the session to the selection,
|
||||
// reclaiming it when the plugin emits ExitMsg.
|
||||
//
|
||||
// Besides in-hub plugins, the hub also lists "session apps" — features that take
|
||||
// over the whole terminal (a pod shell, the IRC client, the newsreader, the mail
|
||||
// reader, Tor) rather than rendering inside the hub. Selecting one suspends the
|
||||
// hub via tea.Exec, runs it, and returns to the menu. This is what lets a member
|
||||
// reach everything from a single `ssh <name>@host` login instead of separate
|
||||
// `ssh pod@` / `ssh irc@` / `ssh news@` connections.
|
||||
package hub
|
||||
|
||||
import (
|
||||
|
|
@ -21,11 +28,30 @@ var (
|
|||
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
)
|
||||
|
||||
// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC
|
||||
// client, the newsreader, the mail reader, or Tor — instead of running as an
|
||||
// in-hub model. Selecting it suspends the hub (tea.Exec), runs Cmd, then returns
|
||||
// to the menu.
|
||||
type SessionApp struct {
|
||||
Title string
|
||||
Description string
|
||||
// Locked, when non-empty, marks the entry visible-but-unavailable (e.g. a
|
||||
// paid feature for a free member, or members-only for a guest). The reason
|
||||
// is shown when the entry is selected; the app cannot be launched.
|
||||
Locked string
|
||||
// Cmd is the terminal-takeover command run under tea.Exec.
|
||||
Cmd tea.ExecCommand
|
||||
}
|
||||
|
||||
// appExitMsg is delivered when a SessionApp launched via tea.Exec returns.
|
||||
type appExitMsg struct{ err error }
|
||||
|
||||
// Model is the hub menu.
|
||||
type Model struct {
|
||||
user auth.User
|
||||
ctx plugin.Context
|
||||
plugins []plugin.Plugin
|
||||
apps []SessionApp
|
||||
|
||||
cursor int
|
||||
active tea.Model
|
||||
|
|
@ -34,19 +60,31 @@ type Model struct {
|
|||
note string
|
||||
}
|
||||
|
||||
// New builds a hub for one session.
|
||||
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model {
|
||||
return Model{user: user, ctx: ctx, plugins: plugins}
|
||||
// New builds a hub for one session. apps are terminal-takeover features listed
|
||||
// after the in-hub plugins (may be nil).
|
||||
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin, apps []SessionApp) Model {
|
||||
return Model{user: user, ctx: ctx, plugins: plugins, apps: apps}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
// entries is the total number of selectable rows (plugins then apps).
|
||||
func (m Model) entries() int { return len(m.plugins) + len(m.apps) }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Window size is shared with whichever model is active.
|
||||
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width, m.height = ws.Width, ws.Height
|
||||
}
|
||||
|
||||
// A returning session app (tea.Exec finished) hands control back here.
|
||||
if ae, ok := msg.(appExitMsg); ok {
|
||||
if ae.err != nil {
|
||||
m.note = ae.err.Error()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// A plugin owns the session until it emits ExitMsg (PRD §4.3).
|
||||
if m.active != nil {
|
||||
if _, ok := msg.(plugin.ExitMsg); ok {
|
||||
|
|
@ -69,10 +107,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.plugins)-1 {
|
||||
if m.cursor < m.entries()-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "enter":
|
||||
return m.selectEntry()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// selectEntry launches whatever the cursor is on: an in-hub plugin or a session app.
|
||||
func (m Model) selectEntry() (tea.Model, tea.Cmd) {
|
||||
if m.cursor < len(m.plugins) {
|
||||
p := m.plugins[m.cursor]
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
m.note = "members only — ssh join@ to register"
|
||||
|
|
@ -87,8 +134,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
|
||||
app := m.apps[m.cursor-len(m.plugins)]
|
||||
if app.Locked != "" {
|
||||
m.note = app.Locked
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Exec(app.Cmd, func(err error) tea.Msg { return appExitMsg{err} })
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
|
|
@ -97,16 +149,22 @@ func (m Model) View() string {
|
|||
}
|
||||
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
|
||||
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
|
||||
for i, p := range m.plugins {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = cursorStyle.Render("> ")
|
||||
}
|
||||
row := 0
|
||||
for _, p := range m.plugins {
|
||||
label := p.Title()
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
label += lockStyle.Render(" [members]")
|
||||
}
|
||||
s += fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(p.Description()))
|
||||
s += m.renderRow(row, label, p.Description())
|
||||
row++
|
||||
}
|
||||
for _, app := range m.apps {
|
||||
label := app.Title
|
||||
if app.Locked != "" {
|
||||
label += lockStyle.Render(" [locked]")
|
||||
}
|
||||
s += m.renderRow(row, label, app.Description)
|
||||
row++
|
||||
}
|
||||
s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit")
|
||||
if m.note != "" {
|
||||
|
|
@ -114,3 +172,12 @@ func (m Model) View() string {
|
|||
}
|
||||
return frameStyle.Render(s)
|
||||
}
|
||||
|
||||
// renderRow renders one menu line with the cursor and dimmed description.
|
||||
func (m Model) renderRow(i int, label, desc string) string {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = cursorStyle.Render("> ")
|
||||
}
|
||||
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue