mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
362b47fdde
commit
e0a267e343
15 changed files with 539 additions and 138 deletions
|
|
@ -10,7 +10,8 @@
|
|||
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
|
||||
// ssh <name>@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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
159
internal/ui/theme.go
Normal 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(" · "))
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
||||
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"))
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 " "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 len(m.entries) == 0 {
|
||||
if doomBin(ctx) == "" {
|
||||
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: "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 !arcadeFound {
|
||||
m.entries = append(m.entries, entry{
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
63
scripts/rebuild-pods.sh
Executable file
63
scripts/rebuild-pods.sh
Executable file
|
|
@ -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-<name>-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@<host>`. 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@'."
|
||||
7
setup.sh
7
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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue