diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 3bf9214..583f033 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -10,7 +10,8 @@ // ssh domain@host point your own domain at your homepage (Premium; add/rm/list) // ssh @host (from another account) prints a finger card for that member // ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin -// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only) +// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only; +// sysop@/root@ are aliases) // ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated, // agent-vs-agent (also on wss://host/play). See docs/agentgames.md // diff --git a/internal/admin/admin.go b/internal/admin/admin.go index a67adda..b3bd238 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -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 } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 026a57b..1679036 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index aec7996..9e08477 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -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) } diff --git a/internal/hub/hub.go b/internal/hub/hub.go index d3a6092..cbe4d73 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -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)) - } - 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) + b.WriteString("\n" + ui.Danger.Render(m.note)) } - return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc)) + return ui.Frame.Render(b.String()) } diff --git a/internal/news/tui.go b/internal/news/tui.go index 62f3d7d..6859a68 100644 --- a/internal/news/tui.go +++ b/internal/news/tui.go @@ -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 { diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..1324080 --- /dev/null +++ b/internal/ui/theme.go @@ -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(" · ")) +} diff --git a/plugins/about/about.go b/plugins/about/about.go index 8461d27..8640530 100644 --- a/plugins/about/about.go +++ b/plugins/about/about.go @@ -3,11 +3,14 @@ package about import ( + "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/ui" ) type Plugin struct{} @@ -26,22 +29,57 @@ type model struct{ user auth.User } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if _, ok := msg.(tea.KeyMsg); ok { - return m, plugin.Exit + if k, ok := msg.(tea.KeyMsg); ok { + switch k.String() { + case "esc", "q", "enter", "ctrl+c", " ": + return m, plugin.Exit + } } return m, nil } var ( - h = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - d = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + theme = ui.New(ui.Green) + taglineStyle = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("245")) + cmdStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Cyan) ) +// route is one connection entry point shown in the CONNECT card. +type route struct { + cmd, desc string + badgeVar, tag string +} + func (m model) View() string { - return lipgloss.NewStyle().Padding(1, 2).Render( - h.Render("AgentBBS") + " — a modern BBS over SSH for humans and AI agents.\n\n" + - " ssh bbs@profullstack.com this hub (guests welcome)\n" + - " ssh join@profullstack.com register your SSH key\n" + - " ssh pod@profullstack.com your own Linux pod (members, $1/mo via coinpay)\n\n" + - d.Render("Maintained by Profullstack, Inc. · AgentGames spec at logicsrc.com\n\npress any key to return")) + const cmdW, descW = 28, 22 + + routes := []route{ + {"ssh bbs@profullstack.com", "the public hub", ui.BadgeOK, "guests welcome"}, + {"ssh join@profullstack.com", "register your SSH key", ui.BadgeInfo, "free"}, + {"ssh pod@profullstack.com", "your own Linux pod", ui.BadgeGold, "$1/mo · members"}, + } + + rows := make([]string, 0, len(routes)) + for _, r := range routes { + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, + cmdStyle.Width(cmdW).Render(r.cmd), + ui.Body.Width(descW).Render(r.desc), + ui.Badge(r.badgeVar, r.tag), + )) + } + + footer := ui.Dim.Render("Maintained by Profullstack, Inc.") + "\n" + + ui.Dim.Render("AgentGames spec → logicsrc.com") + + body := lipgloss.JoinVertical(lipgloss.Left, + theme.Title("AgentBBS"), + taglineStyle.Render("a modern BBS over SSH — for humans and AI agents"), + "", + theme.Card("Connect", strings.Join(rows, "\n")), + "", + footer, + "", + ui.KeyBar("esc/q return to menu"), + ) + return ui.Frame.Render(body) } diff --git a/plugins/agentgames/agentgames.go b/plugins/agentgames/agentgames.go index eca6d38..0a7947c 100644 --- a/plugins/agentgames/agentgames.go +++ b/plugins/agentgames/agentgames.go @@ -18,6 +18,7 @@ import ( "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/store" + "github.com/profullstack/agentbbs/internal/ui" ) // Plugin is the AgentGames hub entry. @@ -52,12 +53,12 @@ const ( ) var ( - title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) - head = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) - warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - frame = lipgloss.NewStyle().Padding(1, 2) + title = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + dim = ui.Dim + cursor = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + head = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue) + warn = ui.Danger + frame = ui.Frame ) var actions = []string{"Ladder", "Replays", "Play vs bot"} @@ -302,7 +303,7 @@ func (m *model) View() string { case scPlay: body, help = m.viewPlay() } - out := title.Render("AgentGames") + "\n\n" + body + "\n" + dim.Render(help) + out := title.Render("AgentGames") + "\n\n" + body + "\n" + ui.KeyBar(help) if m.note != "" { out += "\n" + m.note } @@ -393,7 +394,7 @@ func (m *model) viewPlay() (string, string) { func (m *model) row(i int) string { if i == m.cursor { - return cursor.Render("> ") + return cursor.Render("❯ ") } return " " } diff --git a/plugins/arcade/arcade.go b/plugins/arcade/arcade.go index 7fb120c..e5195d6 100644 --- a/plugins/arcade/arcade.go +++ b/plugins/arcade/arcade.go @@ -1,37 +1,65 @@ // Package arcade is the flagship plugin (PRD §5.1): classic terminal games. -// DOOM runs as a sandboxed external binary (doom-ascii + Freedoom); built-in -// TUI games (snake) feed the global leaderboards. +// DOOM and the 80s arcade classics (Space Invaders, Pac-Man, Tetris, Moon +// Patrol) run as sandboxed external binaries on a real PTY; built-in TUI games +// (snake) feed the global leaderboards. package arcade import ( - "fmt" "os" + "os/exec" "path/filepath" "strings" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/ui" ) type Plugin struct{} func (Plugin) ID() string { return "arcade" } func (Plugin) Title() string { return "Arcade" } -func (Plugin) Description() string { return "DOOM (ASCII), snake, leaderboards" } +func (Plugin) Description() string { return "DOOM, Space Invaders, Pac-Man, Tetris, snake & leaderboards" } func (Plugin) RequiresAuth() bool { return false } func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { return newMenu(user, ctx) } +// extGame is an 80s arcade classic launched as a sandboxed subprocess on a real +// PTY — the doom-ascii pattern, generalized. The binary is resolved from the +// platform assets dir first, then the host PATH and the well-known distro game +// dirs, so either `scripts/fetch-assets.sh --arcade` (distro install) or a +// hand-built binary dropped in assets/bin makes the game appear in the menu. +type extGame struct { + id string // stable id; also the per-user save subdir under arcade/ + label string // menu label + desc string // one-line menu description + bins []string // candidate binary names (first that resolves wins) + args []string // launch args (most need none) +} + +// extGames is the arcade catalog of external classics, in menu order. +var extGames = []extGame{ + {id: "invaders", label: "Space Invaders", desc: "nInvaders — shoot the descending alien fleet", bins: []string{"ninvaders", "nInvaders"}}, + {id: "pacman", label: "Pac-Man", desc: "pacman4console — clear the maze, dodge the ghosts", bins: []string{"pacman4console"}}, + {id: "tetris", label: "Tetris", desc: "tint — stack the falling tetrominoes", bins: []string{"tint", "vitetris", "tetris"}}, + {id: "moonpatrol", label: "Moon Patrol", desc: "moon-buggy — jump the craters across the lunar surface", bins: []string{"moon-buggy"}}, +} + +// gameDirs are the well-known locations distro packages drop game binaries. +// Debian/Ubuntu put them in /usr/games, which is usually off the daemon's PATH, +// so we probe these explicitly in addition to exec.LookPath. +var gameDirs = []string{"/usr/games", "/usr/local/games", "/usr/local/bin", "/usr/bin"} + // entry is one row in the arcade menu. type entry struct { - label string - desc string - run func(m *menu) (tea.Model, tea.Cmd) + section string + label string + desc string + run func(m *menu) (tea.Model, tea.Cmd) } type menu struct { @@ -45,42 +73,69 @@ type menu struct { child tea.Model // snake / leaderboard take over here } -var ( - tStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#fbbf24")) - dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - cStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#fbbf24")) - eStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) -) +var theme = ui.New(ui.Gold) func newMenu(user auth.User, ctx plugin.Context) *menu { m := &menu{user: user, ctx: ctx} + + // --- DOOM (per WAD) --- for _, wad := range findWADs(ctx, user) { wad := wad m.entries = append(m.entries, entry{ - label: "DOOM — " + filepath.Base(wad), - desc: "doom-ascii in a sandbox (24-bit color terminal recommended)", - run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) }, + section: "DOOM", + label: "DOOM — " + filepath.Base(wad), + desc: "doom-ascii in a sandbox (24-bit color terminal recommended)", + run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchDoom(wad) }, + }) + } + if doomBin(ctx) == "" { + m.entries = append(m.entries, entry{ + section: "DOOM", + label: "DOOM — not installed", + desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom", + run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil }, + }) + } + + // --- arcade classics (external binaries) --- + var arcadeFound bool + for _, g := range extGames { + g := g + if resolveBin(ctx, g.bins) == "" { + continue + } + arcadeFound = true + m.entries = append(m.entries, entry{ + section: "ARCADE", + label: g.label, + desc: g.desc, + run: func(m *menu) (tea.Model, tea.Cmd) { return m, m.launchExt(g) }, }) } - if len(m.entries) == 0 { + if !arcadeFound { m.entries = append(m.entries, entry{ - label: "DOOM — not installed", - desc: "run scripts/fetch-assets.sh on the host to build doom-ascii + Freedoom", - run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "assets missing on host"; return m, nil }, + section: "ARCADE", + label: "Arcade classics — not installed", + desc: "run scripts/fetch-assets.sh --arcade on the host (Space Invaders, Pac-Man, Tetris, Moon Patrol)", + run: func(m *menu) (tea.Model, tea.Cmd) { m.note = "arcade binaries missing on host"; return m, nil }, }) } + + // --- built-in (leaderboard-backed) --- m.entries = append(m.entries, entry{ - label: "Snake", - desc: "built-in; high scores hit the global leaderboard", + section: "BUILT-IN", + label: "Snake", + desc: "built-in; high scores hit the global leaderboard", run: func(m *menu) (tea.Model, tea.Cmd) { m.child = newSnake(m.user, m.ctx, m.width, m.height) return m, m.child.Init() }, }, entry{ - label: "Leaderboard", - desc: "global top scores", + section: "BUILT-IN", + label: "Leaderboard", + desc: "global top scores", run: func(m *menu) (tea.Model, tea.Cmd) { m.child = newBoard(m.ctx) return m, m.child.Init() @@ -116,24 +171,72 @@ func doomBin(ctx plugin.Context) string { return "" } +// resolveBin finds the first candidate binary that exists: bundled in the +// platform assets dir, on PATH, or in a well-known distro game dir. +func resolveBin(ctx plugin.Context, names []string) string { + for _, n := range names { + if p := filepath.Join(ctx.AssetsDir, "bin", n); isExec(p) { + return p + } + if p, err := exec.LookPath(n); err == nil { + return p + } + for _, d := range gameDirs { + if p := filepath.Join(d, n); isExec(p) { + return p + } + } + } + return "" +} + +func isExec(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0 +} + +// workDir returns the writable per-game save dir: a stable path for members, +// a throwaway temp dir for guests. +func (m *menu) workDir(sub string) string { + if m.ctx.DataDir == "" { + d, _ := os.MkdirTemp("", "agentbbs-guest-"+strings.ReplaceAll(sub, "/", "-")+"-") + return d + } + d := filepath.Join(m.ctx.DataDir, "arcade", sub) + _ = os.MkdirAll(d, 0o755) + return d +} + // launchDoom suspends the TUI and bridges the session to a sandboxed // doom-ascii on a real PTY. Savegames land in the per-user work dir. func (m *menu) launchDoom(wad string) tea.Cmd { bin := doomBin(m.ctx) - work := m.ctx.DataDir - if work == "" { // guests: throwaway saves - work, _ = os.MkdirTemp("", "agentbbs-guest-doom-") - } else { - work = filepath.Join(work, "doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad))) - _ = os.MkdirAll(work, 0o755) - } + work := m.workDir(filepath.Join("doom", strings.TrimSuffix(filepath.Base(wad), filepath.Ext(wad)))) cmd := m.ctx.Sandbox.Command(work, bin, "-iwad", wad) return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { - return doomDoneMsg{err: err} + return gameDoneMsg{name: "DOOM", err: err} }) } -type doomDoneMsg struct{ err error } +// launchExt suspends the TUI and bridges the session to a sandboxed arcade +// classic on a real PTY (the generalized doom path). +func (m *menu) launchExt(g extGame) tea.Cmd { + bin := resolveBin(m.ctx, g.bins) + if bin == "" { // raced with an uninstall; surface rather than exec "" + m.note = g.label + " is no longer installed on the host" + return nil + } + work := m.workDir(g.id) + cmd := m.ctx.Sandbox.Command(work, bin, g.args...) + return tea.Exec(newPtyExec(cmd, m.width, m.height), func(err error) tea.Msg { + return gameDoneMsg{name: g.label, err: err} + }) +} + +type gameDoneMsg struct { + name string + err error +} func (m *menu) Init() tea.Cmd { return nil } @@ -151,9 +254,9 @@ func (m *menu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } switch msg := msg.(type) { - case doomDoneMsg: + case gameDoneMsg: if msg.err != nil { - m.note = "doom exited: " + msg.err.Error() + m.note = msg.name + " exited: " + msg.err.Error() } return m, nil case tea.KeyMsg: @@ -180,19 +283,23 @@ func (m *menu) View() string { if m.child != nil { return m.child.View() } - s := tStyle.Render("Arcade") + "\n\n" + s := theme.Title("Arcade") + ui.Dim.Render(" · classic terminal games, sandboxed") + "\n\n" + prevSection := "" for i, e := range m.entries { - cur := " " - if i == m.cursor { - cur = cStyle.Render("> ") + if e.section != prevSection { + if prevSection != "" { + s += "\n" + } + s += theme.Section(e.section) + "\n" + prevSection = e.section } - s += fmt.Sprintf("%s%s\n %s\n", cur, e.label, dStyle.Render(e.desc)) + s += theme.MenuItem(i == m.cursor, e.label, "", e.desc) } - s += "\n" + dStyle.Render("↑/↓ move · enter play · q back to hub") + s += "\n" + ui.KeyBar("↑/↓ move · enter play · q back to hub") if m.note != "" { - s += "\n" + eStyle.Render(m.note) + s += "\n" + ui.Danger.Render(m.note) } - return lipgloss.NewStyle().Padding(1, 2).Render(s) + return ui.Frame.Render(s) } // backMsg returns from a child (snake/leaderboard) to the arcade menu. diff --git a/plugins/arcade/board.go b/plugins/arcade/board.go index 3b1d56c..024af2e 100644 --- a/plugins/arcade/board.go +++ b/plugins/arcade/board.go @@ -4,10 +4,10 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/profullstack/agentbbs/internal/plugin" "github.com/profullstack/agentbbs/internal/store" + "github.com/profullstack/agentbbs/internal/ui" ) // board renders the global top scores (PRD §5.1 leaderboards). @@ -42,17 +42,17 @@ func (b *board) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (b *board) View() string { - s := tStyle.Render("Leaderboard — snake") + "\n\n" + s := theme.Title("Leaderboard — snake") + "\n\n" switch { case b.err != nil: - s += eStyle.Render("error: " + b.err.Error()) + s += ui.Danger.Render("error: " + b.err.Error()) case len(b.scores) == 0: - s += dStyle.Render("no scores yet — be the first") + s += ui.Dim.Render("no scores yet — be the first") default: for i, sc := range b.scores { s += fmt.Sprintf("%2d. %-20s %6d\n", i+1, sc.User, sc.Score) } } - s += "\n" + dStyle.Render("any key to return") - return lipgloss.NewStyle().Padding(1, 2).Render(s) + s += "\n" + ui.KeyBar("any-key return to menu") + return ui.Frame.Render(s) } diff --git a/plugins/qryptinvite/qryptinvite.go b/plugins/qryptinvite/qryptinvite.go index 2b35680..eaaf0db 100644 --- a/plugins/qryptinvite/qryptinvite.go +++ b/plugins/qryptinvite/qryptinvite.go @@ -18,6 +18,7 @@ import ( "github.com/profullstack/agentbbs/internal/plugin" qi "github.com/profullstack/agentbbs/internal/qryptinvite" "github.com/profullstack/agentbbs/internal/store" + "github.com/profullstack/agentbbs/internal/ui" ) // Plugin is the hub registration. It admits members only (guests have no @@ -99,13 +100,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { - return lipgloss.NewStyle().Padding(1, 2).Render( - m.body + "\n\n" + dStyle.Render("press any key to return")) + return ui.Frame.Render(m.body + "\n\n" + ui.KeyBar("esc/q return to menu")) } var ( - hStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) - dStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")) + hStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green) + dStyle = ui.Dim + errStyle = ui.Danger + urlStyle = lipgloss.NewStyle().Foreground(ui.Blue) ) diff --git a/scripts/fetch-assets.sh b/scripts/fetch-assets.sh index 8db0192..d7999e5 100755 --- a/scripts/fetch-assets.sh +++ b/scripts/fetch-assets.sh @@ -3,10 +3,13 @@ # ./assets — Freedoom by default (PRD §9.1). Run on the host before enabling # the arcade's DOOM entries. # -# scripts/fetch-assets.sh [--shareware] +# scripts/fetch-assets.sh [--shareware] [--arcade] # # --shareware additionally fetches the freely redistributable doom1.wad -# shareware episode. +# shareware episode. +# --arcade installs the 80s arcade classics (Space Invaders, Pac-Man, Tetris, +# Moon Patrol) the arcade plugin launches via the same sandboxed-PTY path as +# DOOM. Needs apt + sudo (Debian/Ubuntu); the menu lists whatever installs. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -14,6 +17,16 @@ ASSETS="$ROOT/assets" BUILD="$ROOT/.build" FREEDOOM_VERSION="${FREEDOOM_VERSION:-0.13.0}" +want_shareware=0 +want_arcade=0 +for arg in "$@"; do + case "$arg" in + --shareware) want_shareware=1 ;; + --arcade) want_arcade=1 ;; + *) echo "!! unknown flag: $arg (use --shareware and/or --arcade)" >&2; exit 2 ;; + esac +done + mkdir -p "$ASSETS/bin" "$ASSETS/wads" "$BUILD" # --- doom-ascii ------------------------------------------------------------- @@ -47,12 +60,39 @@ else fi # --- Doom shareware (optional) ---------------------------------------------- -if [ "${1:-}" = "--shareware" ] && [ ! -f "$ASSETS/wads/doom1.wad" ]; then +if [ "$want_shareware" = 1 ] && [ ! -f "$ASSETS/wads/doom1.wad" ]; then echo ">> fetching Doom shareware episode" curl -fsSL -o "$ASSETS/wads/doom1.wad" \ "https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad" echo ">> installed doom1.wad (shareware)" fi +# --- Arcade classics (optional) --------------------------------------------- +# Tiny, well-packaged ncurses C programs from the distro (Debian/Ubuntu +# universe). They land in /usr/games, which the arcade plugin probes alongside +# assets/bin and PATH. The arcade menu lists whichever of these is present. +ARCADE_PKGS="ninvaders pacman4console moon-buggy tint" +if [ "$want_arcade" = 1 ]; then + if command -v apt-get >/dev/null 2>&1; then + SUDO="" + [ "$(id -u)" -ne 0 ] && SUDO="sudo" + echo ">> installing arcade classics: $ARCADE_PKGS" + $SUDO apt-get update -y + # Install individually so one missing package doesn't abort the rest. + for pkg in $ARCADE_PKGS; do + $SUDO apt-get install -y "$pkg" || echo "!! $pkg not available; skipping" + done + else + echo "!! --arcade needs apt-get (Debian/Ubuntu)." >&2 + echo " On other distros, install equivalents of: $ARCADE_PKGS" >&2 + fi +fi + echo ">> done. WADs:" ls -l "$ASSETS/wads" +echo ">> arcade classics on host:" +for bin in ninvaders pacman4console moon-buggy tint vitetris; do + p="$(command -v "$bin" 2>/dev/null || true)" + [ -z "$p" ] && [ -x "/usr/games/$bin" ] && p="/usr/games/$bin" + [ -n "$p" ] && echo " $bin -> $p" +done diff --git a/scripts/rebuild-pods.sh b/scripts/rebuild-pods.sh new file mode 100755 index 0000000..0cc3fb0 --- /dev/null +++ b/scripts/rebuild-pods.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Rebuild every AgentBBS member pod so it picks up the current container profile +# (e.g. the rootless-podman default capability set added for apt/chown/su/:80). +# +# It removes each pod CONTAINER but keeps that pod's named home volume +# (agentbbs-pod--home) and the host-side public_html, so member data and +# websites are untouched. Pods are recreated automatically — with the new +# profile — the next time each member runs `ssh pod@`. Caddy serves +# public_html from the host, so sites stay up while a pod is briefly down. +# +# Anything a member installed into the pod's system rootfs (apt packages, etc.) +# is lost on rebuild; only /home/dev and public_html persist. +# +# Run this as the user that owns the pods. For rootless podman that's the +# AgentBBS service user (pods are per-user), not necessarily root. +# +# Usage: +# scripts/rebuild-pods.sh # list, then prompt before removing +# scripts/rebuild-pods.sh --yes # non-interactive (for cron/deploy) +# AGENTBBS_POD_ENGINE=docker scripts/rebuild-pods.sh # force engine +set -euo pipefail + +ENGINE="${AGENTBBS_POD_ENGINE:-}" +if [ -z "$ENGINE" ]; then + if command -v podman >/dev/null 2>&1; then + ENGINE=podman + elif command -v docker >/dev/null 2>&1; then + ENGINE=docker + else + echo "rebuild-pods: neither podman nor docker found" >&2 + exit 1 + fi +fi + +mapfile -t pods < <("$ENGINE" ps -a --filter 'name=agentbbs-pod-' --format '{{.Names}}' | sort) + +if [ "${#pods[@]}" -eq 0 ]; then + echo "rebuild-pods: no pods found (engine: $ENGINE)" + exit 0 +fi + +echo "Found ${#pods[@]} pod(s) via $ENGINE:" +printf ' %s\n' "${pods[@]}" + +if [ "${1:-}" != "--yes" ] && [ "${1:-}" != "-y" ]; then + printf 'Remove these containers (home volumes kept)? [y/N] ' + read -r reply + case "$reply" in + y | Y | yes | YES) ;; + *) + echo "aborted" + exit 0 + ;; + esac +fi + +for p in "${pods[@]}"; do + # No -v: named home volumes are preserved, only the container is destroyed. + "$ENGINE" rm -f "$p" >/dev/null && echo "removed $p" +done + +echo +echo "Done. Each pod recreates with the new profile on its owner's next 'ssh pod@'." diff --git a/setup.sh b/setup.sh index 59b497c..9fbd606 100755 --- a/setup.sh +++ b/setup.sh @@ -34,6 +34,7 @@ HTTP_ADDR="${HTTP_ADDR:-127.0.0.1:8088}" # agentbbs /verify endpoint (join@ emai GO_VERSION="${GO_VERSION:-1.26.4}" POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}" FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets +FETCH_ARCADE="${FETCH_ARCADE:-1}" # set 0 to skip the 80s arcade classics (apt: ninvaders, pacman4console, moon-buggy, tint) SKIP_BUILD="${SKIP_BUILD:-0}" # set 1 to use prebuilt /usr/local/bin/{agentbbs,ascii-live} (tiny droplets can't compile) SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 0 to skip) SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer @@ -194,8 +195,10 @@ else fi if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/scripts/fetch-assets.sh" ]; then - log "fetching arcade assets (set FETCH_ASSETS=0 to skip)" - ( cd "$SRC_DIR" && ./scripts/fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited" + fetch_flags="" + [ "$FETCH_ARCADE" = "1" ] && fetch_flags="--arcade" + log "fetching arcade assets (set FETCH_ASSETS=0 to skip; FETCH_ARCADE=0 for DOOM only)" + ( cd "$SRC_DIR" && ./scripts/fetch-assets.sh $fetch_flags ) || warn "asset fetch failed; arcade may be limited" fi # Add swap on tiny droplets before the build (and for runtime headroom).