Fix/pod public html bind mount (#30)

* feat(pods/admin): root admin alias, default-caps cleanup, pod rebuild script

- auth: add `root` as an admin-console route alias (alongside admin/sysop);
  still gated by $AGENTBBS_ADMINS — the name confers nothing on its own.
- pods: drop the now-redundant tuneApt apt-sandbox hack. Rootless podman keeps
  its default capability set, so apt/chown/su work without disabling the
  download sandbox.
- scripts/rebuild-pods.sh: recreate all member pods (keeps home volumes) so
  they pick up the current container profile on next `ssh pod@`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(arcade,ui): 80s arcade classics + shared menu theme

Arcade games (PRD §5.1), generalizing the sandboxed-PTY DOOM path into an
external-game registry: Space Invaders (nInvaders), Pac-Man (pacman4console),
Tetris (tint/vitetris), Moon Patrol (moon-buggy). Binaries resolve from
assets/bin, PATH, then /usr/games, so a distro install or a hand-built binary
lights each game up; missing games are skipped with a discovery hint. Installed
on the host via `scripts/fetch-assets.sh --arcade` (apt), wired into setup.sh
behind FETCH_ARCADE (default on).

UI: new shared ui.Theme.MenuItem widget (accent cursor + badge, description
shown only for the focused row) adopted by the hub and arcade menus; the hub
groups rows under Features/Sessions headers and the arcade under
DOOM/ARCADE/BUILT-IN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-15 08:06:32 -07:00 committed by GitHub
parent 362b47fdde
commit e0a267e343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 539 additions and 138 deletions

View file

@ -17,6 +17,7 @@ import (
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/store"
"github.com/profullstack/agentbbs/internal/ui"
)
// Live is one connected SSH session, as seen by the registry.
@ -70,13 +71,13 @@ var menuItems = []struct {
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
frameStyle = lipgloss.NewStyle().Padding(1, 2)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
dimStyle = ui.Dim
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
warnStyle = ui.Danger
okStyle = lipgloss.NewStyle().Foreground(ui.Green)
headStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
frameStyle = ui.Frame
)
// Model is the admin console.
@ -306,7 +307,7 @@ func (m Model) View() string {
}
header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name)
out := header + "\n\n" + body + "\n" + dimStyle.Render(help)
out := header + "\n\n" + body + "\n" + ui.KeyBar(help)
if m.note != "" {
out += "\n" + m.note
}

View file

@ -43,8 +43,9 @@ var DomainNames = map[string]bool{"domain": true, "domains": true}
// AdminNames are usernames that route to the privileged admin console (PRD §6).
// The route only opens for accounts whose name is in the operator allowlist
// (see IsAdmin); the name itself confers nothing.
var AdminNames = map[string]bool{"admin": true, "sysop": true}
// (see IsAdmin); the name itself confers nothing — so "root" is just a familiar
// alias here, not a backdoor.
var AdminNames = map[string]bool{"admin": true, "sysop": true, "root": true}
// TorURLNames route to the one-shot "fetch a URL over Tor" command (premium).
var TorURLNames = map[string]bool{"tor-url": true}

View file

@ -3,7 +3,7 @@ package auth
import "testing"
func TestIsAdminName(t *testing.T) {
for _, name := range []string{"admin", "ADMIN", "sysop"} {
for _, name := range []string{"admin", "ADMIN", "sysop", "root"} {
if !IsAdminName(name) {
t.Errorf("IsAdminName(%q) = false, want true", name)
}

View file

@ -19,21 +19,12 @@ import (
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/ui"
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
selStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
theme = ui.New(ui.Green)
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))
motdStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#4ade80")).
Foreground(lipgloss.Color("252")).
Padding(0, 1)
frameStyle = lipgloss.NewStyle().Padding(1, 2)
)
// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC
@ -170,42 +161,37 @@ func (m Model) View() string {
b.WriteString(bannerStyle.Render(m.banner) + "\n\n")
}
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
b.WriteString(titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n")
b.WriteString(theme.Title("AgentBBS") + ui.Dim.Render(" · "+who) + "\n")
if m.motd != "" {
b.WriteString("\n" + motdStyle.Render(m.motd) + "\n")
b.WriteString("\n" + theme.Card("", m.motd) + "\n")
}
b.WriteString("\n")
row := 0
for _, p := range m.plugins {
label := p.Title()
if p.RequiresAuth() && m.user.Kind == auth.Guest {
label += lockStyle.Render(" [members]")
if len(m.plugins) > 0 {
b.WriteString(theme.Section("Features") + "\n")
for _, p := range m.plugins {
badge := ""
if p.RequiresAuth() && m.user.Kind == auth.Guest {
badge = ui.Badge(ui.BadgeMuted, "members")
}
b.WriteString(theme.MenuItem(row == m.cursor, p.Title(), badge, p.Description()))
row++
}
b.WriteString(m.renderRow(row, label, p.Description()))
row++
}
for _, app := range m.apps {
label := app.Title
if app.Locked != "" {
label += lockStyle.Render(" [locked]")
if len(m.apps) > 0 {
b.WriteString("\n" + theme.Section("Sessions") + "\n")
for _, app := range m.apps {
badge := ""
if app.Locked != "" {
badge = ui.Badge(ui.BadgeGold, "locked")
}
b.WriteString(theme.MenuItem(row == m.cursor, app.Title, badge, app.Description))
row++
}
b.WriteString(m.renderRow(row, label, app.Description))
row++
}
b.WriteString("\n" + dimStyle.Render("↑/↓ move · enter select · ctrl+c back · q quit"))
b.WriteString("\n" + ui.KeyBar("↑/↓ move · enter select · ctrl+c back · q quit"))
if m.note != "" {
b.WriteString("\n" + lockStyle.Render(m.note))
b.WriteString("\n" + ui.Danger.Render(m.note))
}
return frameStyle.Render(b.String())
}
// renderRow renders one menu line with the cursor and dimmed description. The
// selected row's cursor and label are highlighted.
func (m Model) renderRow(i int, label, desc string) string {
cur := " "
if i == m.cursor {
cur = cursorStyle.Render(" ")
label = selStyle.Render(label)
}
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
return ui.Frame.Render(b.String())
}

View file

@ -8,15 +8,16 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"github.com/dustin/go-nntp"
"github.com/profullstack/agentbbs/internal/ui"
)
var (
nTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc"))
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(lipgloss.Color("#38bdf8"))
nMeta = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
nFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
nErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
nHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
theme = ui.New(ui.Purple)
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(ui.Cyan)
nMeta = ui.Dim
nFrom = lipgloss.NewStyle().Foreground(ui.Green)
nErr = ui.Danger
)
// RunReader connects the member to the loopback NNTP server and drives the
@ -311,7 +312,7 @@ func (m *model) frame(header, body, hint string) string {
status = "\n" + nMeta.Render(m.status)
}
return lipgloss.NewStyle().Padding(0, 1).Render(
nTitle.Render(header) + "\n\n" + body + status + "\n\n" + nHint.Render(hint))
theme.Title(header) + "\n\n" + body + status + "\n\n" + ui.KeyBar(hint))
}
func (m *model) viewGroups() string {

159
internal/ui/theme.go Normal file
View file

@ -0,0 +1,159 @@
// Package ui is the shared TUI theme for AgentBBS: one palette and a small set
// of structural widgets (cards, menu rows, status badges, key bars) so every
// screen — the hub and each plugin — looks like part of the same product.
//
// Screens keep their own accent color for identity (the hub is green, the
// arcade amber, the newsreader purple) by constructing a Theme with that
// accent; the layout primitives are shared.
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// Palette — the only colors any screen should reach for.
const (
Green = lipgloss.Color("#4ade80")
Cyan = lipgloss.Color("#38bdf8")
Blue = lipgloss.Color("#60a5fa")
Gold = lipgloss.Color("#fbbf24")
Purple = lipgloss.Color("#c084fc")
Red = lipgloss.Color("#f87171")
white = lipgloss.Color("#e2e8f0")
text = lipgloss.Color("252")
muted = lipgloss.Color("245")
faint = lipgloss.Color("240")
)
// Structural styles shared by every screen.
var (
// Frame is the outer padding every top-level View should wrap itself in.
Frame = lipgloss.NewStyle().Padding(1, 2)
// Dim is for secondary text (descriptions, metadata).
Dim = lipgloss.NewStyle().Foreground(muted)
// Body is primary readable text.
Body = lipgloss.NewStyle().Foreground(text)
// Danger is for errors and warnings.
Danger = lipgloss.NewStyle().Foreground(Red)
selStyle = lipgloss.NewStyle().Bold(true).Foreground(white)
hintText = lipgloss.NewStyle().Foreground(faint)
keyText = lipgloss.NewStyle().Bold(true).Foreground(muted)
)
// Theme carries one screen's accent color and renders the shared widgets in it.
type Theme struct{ Accent lipgloss.Color }
// New returns a theme that tints titles, sections, card borders, cursors, and
// selected rows with accent (use a palette color).
func New(accent lipgloss.Color) Theme { return Theme{Accent: accent} }
func (t Theme) accentStyle() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(t.Accent)
}
// Title renders the screen's main heading.
func (t Theme) Title(s string) string { return t.accentStyle().Render(s) }
// Section renders an upper-cased sub-heading inside a screen.
func (t Theme) Section(s string) string { return t.accentStyle().Render(strings.ToUpper(s)) }
// Card frames body in a rounded border tinted with the accent. A non-empty
// title is rendered as a section header at the top of the card.
func (t Theme) Card(title, body string) string {
if title != "" {
body = t.Section(title) + "\n\n" + body
}
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(t.Accent).
Padding(1, 2).
Render(body)
}
// Row renders one selectable menu line: an accent cursor and bold label when
// selected, with a dimmed description on the next line. An empty desc yields a
// single-line row. The returned string ends in a newline.
func (t Theme) Row(selected bool, label, desc string) string {
cur := " "
if selected {
cur = t.accentStyle().Render(" ")
label = selStyle.Render(label)
}
row := cur + label + "\n"
if desc != "" {
row += " " + Dim.Render(desc) + "\n"
}
return row
}
// MenuItem renders one polished menu line shared by the hub and the plugin
// menus (PRD §4.1): an accent cursor and bold label when selected, an optional
// status badge after the label, and — to keep long menus uncluttered — the
// description shown only for the focused row. The result ends in a newline.
func (t Theme) MenuItem(selected bool, label, badge, desc string) string {
name := Body.Render(label)
cur := " "
if selected {
name = selStyle.Render(label)
cur = t.accentStyle().Render(" ")
}
if badge != "" {
name += " " + badge
}
out := cur + name + "\n"
if selected && desc != "" {
out += " " + Dim.Render(desc) + "\n"
}
return out
}
// Badge variants.
const (
BadgeOK = "ok"
BadgeInfo = "info"
BadgeGold = "gold"
BadgeWarn = "warn"
BadgeMuted = "muted"
)
// Badge renders a small filled status tag, e.g. Badge(BadgeOK, "guests welcome").
func Badge(variant, label string) string {
var fg, bg lipgloss.Color
switch variant {
case BadgeOK:
fg, bg = lipgloss.Color("#052e16"), Green
case BadgeInfo:
fg, bg = lipgloss.Color("#082f49"), Cyan
case BadgeGold:
fg, bg = lipgloss.Color("#451a03"), Gold
case BadgeWarn:
fg, bg = lipgloss.Color("#450a0a"), Red
default:
fg, bg = lipgloss.Color("#0b1020"), muted
}
return lipgloss.NewStyle().Bold(true).Foreground(fg).Background(bg).Padding(0, 1).Render(label)
}
// KeyBar renders a footer hint, emphasizing the key token of each segment. It
// accepts the conventional " · "-separated form ("↑/↓ move · enter select ·
// q quit") so call sites read naturally; the first word of each segment is
// brightened as the key.
func KeyBar(s string) string {
segs := strings.Split(s, "·")
for i, seg := range segs {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
if parts := strings.SplitN(seg, " ", 2); len(parts) == 2 {
segs[i] = keyText.Render(parts[0]) + hintText.Render(" "+parts[1])
} else {
segs[i] = keyText.Render(seg)
}
}
return strings.Join(segs, hintText.Render(" · "))
}