mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +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")
|
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
|
||||||
fe := forwardemail.ConfigFromEnv()
|
fe := forwardemail.ConfigFromEnv()
|
||||||
if fe.Domain == "" {
|
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{
|
a := &app{
|
||||||
st: st,
|
st: st,
|
||||||
|
|
@ -332,7 +334,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
username := strings.ToLower(s.User())
|
username := strings.ToLower(s.User())
|
||||||
|
|
||||||
var u auth.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.
|
// Keyless or explicitly anonymous → guest. Named accounts require a key.
|
||||||
if !auth.IsGuestName(username) {
|
if !auth.IsGuestName(username) {
|
||||||
wish.Println(s, "note: member access requires an SSH key; joining as guest.")
|
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 {
|
} else {
|
||||||
// A key maps to exactly one account: if this key is already
|
// A key maps to exactly one account: if this key is already
|
||||||
// registered, that identity wins regardless of the username typed.
|
// 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 {
|
if err == nil && !found {
|
||||||
su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp)
|
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.
|
// URL works the moment a member first signs in.
|
||||||
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
|
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
|
// 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"))
|
}, "\n"))
|
||||||
|
|
||||||
// 1) email -> emailed code -> enter code. A verified account is a free
|
// 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 !u.EmailVerified {
|
||||||
if !a.verifyEmailInteractive(s, in, &u) {
|
if !a.verifyEmailInteractive(s, in, &u) {
|
||||||
_ = s.Exit(1)
|
_ = 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)
|
seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host)
|
||||||
|
|
||||||
wish.Println(s, "\n"+strings.Join([]string{
|
wish.Println(s, "\n"+strings.Join([]string{
|
||||||
" You're in — free membership includes:",
|
" You're in. One login gets you everything — no other servers to ssh into:",
|
||||||
" pod ssh pod@" + a.host + " your own Linux pod",
|
"",
|
||||||
" hub ssh " + u.Name + "@" + a.host,
|
" ssh " + u.Name + "@" + a.host,
|
||||||
" homepage https://" + a.host + "/~" + u.Name,
|
"",
|
||||||
|
" 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"))
|
}, "\n"))
|
||||||
|
|
||||||
// 2) Founding Lifetime ($99 one-time): personal @host email + custom domains.
|
// 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
|
// registerNewMember asks the visitor to choose a username, then creates their
|
||||||
// member account under it. The name is sanitized to the hub/subdomain charset,
|
// 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
|
// 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) {
|
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, "\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++ {
|
for tries := 0; tries < 5; tries++ {
|
||||||
wish.Print(s, "\n Username ["+def+"]: ")
|
wish.Print(s, "\n Username ["+def+"]: ")
|
||||||
|
|
@ -651,23 +764,19 @@ func (a *app) ensurePremium(u *store.User) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// showPremiumWelcome prints a premium member's perks: their personal email,
|
// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail
|
||||||
// where it forwards, the webmail URL, and custom domains.
|
// URL, the in-hub Mail/Tor entries, and custom domains.
|
||||||
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
|
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
|
||||||
lines := []string{
|
lines := []string{
|
||||||
"",
|
"",
|
||||||
" ★ Founding Lifetime Member — thanks! Your perks:",
|
" ★ Founding Lifetime Member — thanks! Your perks:",
|
||||||
"",
|
"",
|
||||||
" email " + a.fe.Address(u.Name),
|
" mailbox " + a.fe.Address(u.Name),
|
||||||
" forwards " + u.Email,
|
" webmail https://" + a.fe.Domain,
|
||||||
}
|
" mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
|
||||||
if url := a.fe.WebmailURL(); url != "" {
|
|
||||||
lines = append(lines, " webmail "+url)
|
|
||||||
}
|
|
||||||
lines = append(lines,
|
|
||||||
" domains ssh domain@" + a.host + " add <yourdomain.com>",
|
" domains ssh domain@" + a.host + " add <yourdomain.com>",
|
||||||
"",
|
"",
|
||||||
)
|
}
|
||||||
wish.Println(s, strings.Join(lines, "\n"))
|
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",
|
" Everything in your free membership stays free — founding adds these",
|
||||||
" bonus features, forever:",
|
" 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",
|
" • 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",
|
" • 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")
|
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc")
|
||||||
defer func() { _ = a.st.EndSession(sessID) }()
|
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"))
|
addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = irc.DefaultAddr
|
addr = irc.DefaultAddr
|
||||||
}
|
}
|
||||||
log.Info("irc connect", "user", u.Name, "addr", addr)
|
log.Info("irc connect", "user", name, "addr", addr)
|
||||||
c, err := irc.Dial(s.Context(), addr, u.Name)
|
c, err := irc.Dial(s.Context(), addr, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wish.Println(s, "irc: "+err.Error())
|
return err
|
||||||
_ = s.Exit(1)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
_ = c.Join(irc.DefaultChannel)
|
_ = c.Join(irc.DefaultChannel)
|
||||||
if err := irc.Run(s, c); err != nil {
|
return irc.Run(s, c, canCreate)
|
||||||
wish.Println(s, "irc: "+err.Error())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server
|
// 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")
|
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news")
|
||||||
defer func() { _ = a.st.EndSession(sessID) }()
|
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
|
addr := a.newsAddr
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = news.DefaultAddr
|
addr = news.DefaultAddr
|
||||||
}
|
}
|
||||||
log.Info("news connect", "user", u.Name, "addr", addr)
|
log.Info("news connect", "user", name, "addr", addr)
|
||||||
if err := news.RunReader(s, addr, u.Name); err != nil {
|
return news.RunReader(s, addr, name)
|
||||||
wish.Println(s, "news: "+err.Error())
|
|
||||||
_ = s.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
|
// 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):
|
// 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,
|
// it lists registered plugins and routes the session to the selection,
|
||||||
// reclaiming it when the plugin emits ExitMsg.
|
// 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
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -21,11 +28,30 @@ var (
|
||||||
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
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.
|
// Model is the hub menu.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
user auth.User
|
user auth.User
|
||||||
ctx plugin.Context
|
ctx plugin.Context
|
||||||
plugins []plugin.Plugin
|
plugins []plugin.Plugin
|
||||||
|
apps []SessionApp
|
||||||
|
|
||||||
cursor int
|
cursor int
|
||||||
active tea.Model
|
active tea.Model
|
||||||
|
|
@ -34,19 +60,31 @@ type Model struct {
|
||||||
note string
|
note string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a hub for one session.
|
// New builds a hub for one session. apps are terminal-takeover features listed
|
||||||
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model {
|
// after the in-hub plugins (may be nil).
|
||||||
return Model{user: user, ctx: ctx, plugins: plugins}
|
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 }
|
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) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
// Window size is shared with whichever model is active.
|
// Window size is shared with whichever model is active.
|
||||||
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
||||||
m.width, m.height = ws.Width, ws.Height
|
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).
|
// A plugin owns the session until it emits ExitMsg (PRD §4.3).
|
||||||
if m.active != nil {
|
if m.active != nil {
|
||||||
if _, ok := msg.(plugin.ExitMsg); ok {
|
if _, ok := msg.(plugin.ExitMsg); ok {
|
||||||
|
|
@ -69,10 +107,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if m.cursor < len(m.plugins)-1 {
|
if m.cursor < m.entries()-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
}
|
}
|
||||||
case "enter":
|
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]
|
p := m.plugins[m.cursor]
|
||||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||||
m.note = "members only — ssh join@ to register"
|
m.note = "members only — ssh join@ to register"
|
||||||
|
|
@ -87,9 +134,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
return m, tea.Batch(cmds...)
|
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, nil
|
||||||
}
|
}
|
||||||
|
return m, tea.Exec(app.Cmd, func(err error) tea.Msg { return appExitMsg{err} })
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
if m.active != nil {
|
if m.active != nil {
|
||||||
|
|
@ -97,16 +149,22 @@ func (m Model) View() string {
|
||||||
}
|
}
|
||||||
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
|
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
|
||||||
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
|
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
|
||||||
for i, p := range m.plugins {
|
row := 0
|
||||||
cur := " "
|
for _, p := range m.plugins {
|
||||||
if i == m.cursor {
|
|
||||||
cur = cursorStyle.Render("> ")
|
|
||||||
}
|
|
||||||
label := p.Title()
|
label := p.Title()
|
||||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||||
label += lockStyle.Render(" [members]")
|
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")
|
s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit")
|
||||||
if m.note != "" {
|
if m.note != "" {
|
||||||
|
|
@ -114,3 +172,12 @@ func (m Model) View() string {
|
||||||
}
|
}
|
||||||
return frameStyle.Render(s)
|
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