Rename: Hamlet -> Ham Lab (binary/module/service/config hamlab)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 10:40:40 -07:00
co-authored by Claude Fable 5.1
parent 436732dac0
commit d76da4b37a
18 changed files with 61 additions and 61 deletions
+2 -2
View File
@@ -10,11 +10,11 @@ web:
cd web && npm ci --no-audit --no-fund && npm run build cd web && npm ci --no-audit --no-fund && npm run build
build: web build: web
$(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
# backend only (assumes web/dist already built) # backend only (assumes web/dist already built)
go: go:
$(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet $(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
dev: dev:
cd web && npm run dev cd web && npm run dev
+16 -16
View File
@@ -1,4 +1,4 @@
# Hamlet — MVP plan # Ham Lab — MVP plan
Proxmox-style web console for a bare-metal Debian 13 host. First target: `core` Proxmox-style web console for a bare-metal Debian 13 host. First target: `core`
(10.0.0.234). MVP is **service + shell + persistent terminals**; everything else is a (10.0.0.234). MVP is **service + shell + persistent terminals**; everything else is a
@@ -13,17 +13,17 @@ stub tab or a roadmap item.
| Transport | One WebSocket per attached terminal; REST for everything else | Standard; NPM passes WebSockets with one toggle. | | Transport | One WebSocket per attached terminal; REST for everything else | Standard; NPM passes WebSockets with one toggle. |
| Frontend | **React + TypeScript + Vite**, `xterm.js` (+ fit, webgl, web-links addons) | Matches `calorie-counter` / `wedding-website`, so no new toolchain to learn. | | Frontend | **React + TypeScript + Vite**, `xterm.js` (+ fit, webgl, web-links addons) | Matches `calorie-counter` / `wedding-website`, so no new toolchain to learn. |
| Auth | Single-user **password login**, argon2id hash, HttpOnly/Secure/SameSite=Strict session cookie, login rate-limit, Origin check on WS upgrade | This is a root-capable shell on the public internet. Auth is MVP, not roadmap. | | Auth | Single-user **password login**, argon2id hash, HttpOnly/Secure/SameSite=Strict session cookie, login rate-limit, Origin check on WS upgrade | This is a root-capable shell on the public internet. Auth is MVP, not roadmap. |
| Service | `hamlet.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlet` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. | | Service | `hamlab.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlab` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. |
| Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. | | Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. |
| Data | `/home/core/apps/hamlet/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. | | Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
| Ingress | NPM proxy host `hamlet.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. | | Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
| Repo | Gitea `mitchell/hamlet`, private, `main` | Created 2026-09-17. | | Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
## Layout ## Layout
``` ```
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
│ header: Hamlet · core · load 0.42 · ● connected · [logout] │ │ header: Ham Lab · core · load 0.42 · ● connected · [logout] │
├──────────────┬───────────────────────────────────────────────┤ ├──────────────┬───────────────────────────────────────────────┤
│ ▾ Terminal │ │ │ ▾ Terminal │ │
│ shell 1 ● │ │ │ shell 1 ● │ │
@@ -34,7 +34,7 @@ stub tab or a roadmap item.
│ ▸ ZFS │ │ │ ▸ ZFS │ │
│ ▸ Dashboard │ │ │ ▸ Dashboard │ │
├──────────────┴───────────────────────────────────────────────┤ ├──────────────┴───────────────────────────────────────────────┤
│ footer: v0.1.0 · up 12d · hamlet.bigham.dev │ │ footer: v0.1.0 · up 12d · hamlab.bigham.dev │
└──────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────┘
``` ```
@@ -80,8 +80,8 @@ Everything under `/api` except `login` and `/healthz` requires the session cooki
## Project layout ## Project layout
``` ```
hamlet/ hamlab/
cmd/hamlet/main.go serve | passwd | version cmd/hamlab/main.go serve | passwd | version
internal/ internal/
auth/ argon2id, sessions, rate limit auth/ argon2id, sessions, rate limit
term/ PTY manager, ring buffer, attach/detach term/ PTY manager, ring buffer, attach/detach
@@ -90,16 +90,16 @@ hamlet/
web/ go:embed of web/dist web/ go:embed of web/dist
web/ Vite + React + TS + xterm.js, PWA manifest + SW web/ Vite + React + TS + xterm.js, PWA manifest + SW
deploy/ deploy/
hamlet.service hamlab.service
install.sh build → /usr/local/bin/hamlet → systemctl restart install.sh build → /usr/local/bin/hamlab → systemctl restart
Makefile build / dev / deploy / lint / test Makefile build / dev / deploy / lint / test
``` ```
## Milestones ## Milestones
**M0 — skeleton** (service boots, login works) **M0 — skeleton** (service boots, login works)
- Go module, chi, config loading, `hamlet passwd`, argon2id login, session cookie. - Go module, chi, config loading, `hamlab passwd`, argon2id login, session cookie.
- `hamlet.service` on `core`, `:8100`, serves a placeholder page. - `hamlab.service` on `core`, `:8100`, serves a placeholder page.
- `make deploy` from `core` itself. - `make deploy` from `core` itself.
**M1 — terminals** (the MVP feature) **M1 — terminals** (the MVP feature)
@@ -111,7 +111,7 @@ hamlet/
- Mobile drawer, key bar, fit-on-rotate. - Mobile drawer, key bar, fit-on-rotate.
- `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service - `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service
worker caching the app shell only (never `/api`). worker caching the app shell only (never `/api`).
- NPM proxy host for `hamlet.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2. - NPM proxy host for `hamlab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
- Stub groups for Services / Cron / ZFS / Dashboard. - Stub groups for Services / Cron / ZFS / Dashboard.
**Done when:** from the phone, open the PWA, log in, open two terminals, run **Done when:** from the phone, open the PWA, log in, open two terminals, run
@@ -120,7 +120,7 @@ something long in one, switch away and back, refresh — it's all still there.
## Roadmap (not MVP — notes only) ## Roadmap (not MVP — notes only)
- **tmux-backed sessions** so shells survive a daemon restart / redeploy. Would spawn - **tmux-backed sessions** so shells survive a daemon restart / redeploy. Would spawn
`tmux new -A -s hamlet-<id>`; needs `mouse on` and status-bar handling in xterm. `tmux new -A -s hamlab-<id>`; needs `mouse on` and status-bar handling in xterm.
- **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal - **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal
lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer. lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer.
- **Cron tab** — parse `/etc/crontab`, `/etc/cron.d/*`, user crontabs, and systemd - **Cron tab** — parse `/etc/crontab`, `/etc/cron.d/*`, user crontabs, and systemd
@@ -129,7 +129,7 @@ something long in one, switch away and back, refresh — it's all still there.
scrub status, and the `freeing` counter. `grid` is the target, which implies scrub status, and the `freeing` counter. `grid` is the target, which implies
multi-host. multi-host.
- **Dashboard** — CPU/mem/load/net, disks and SMART (`smartctl -j`), temps and fans - **Dashboard** — CPU/mem/load/net, disks and SMART (`smartctl -j`), temps and fans
(`sensors -j`), UPS from `pwrstat`. Hamlet collects on a timer into a SQLite (`sensors -j`), UPS from `pwrstat`. Ham Lab collects on a timer into a SQLite
ring (e.g. 10 s resolution, 7 days) and draws sparklines. This is where the ring (e.g. 10 s resolution, 7 days) and draws sparklines. This is where the
"collects its own data" requirement lands; no Prometheus dependency. "collects its own data" requirement lands; no Prometheus dependency.
- **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini / - **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini /
+3 -3
View File
@@ -1,10 +1,10 @@
# Hamlet # Ham Lab
Web-based Linux management console for bare-metal Debian, in the style of the Proxmox Web-based Linux management console for bare-metal Debian, in the style of the Proxmox
web UI. Named after Ham the cat (see also HamTV), and the play. web UI. Named after Ham the cat (see also HamTV).
Status: **planning**. See [`PLAN.md`](PLAN.md) for the MVP scope and roadmap. No code yet. Status: **planning**. See [`PLAN.md`](PLAN.md) for the MVP scope and roadmap. No code yet.
- Runs as a systemd service on the host it manages, as user `core`. - Runs as a systemd service on the host it manages, as user `core`.
- Public at `https://hamlet.bigham.dev` (behind Nginx Proxy Manager on `core`). - Public at `https://hamlab.bigham.dev` (behind Nginx Proxy Manager on `core`).
- Installable as a PWA. - Installable as a PWA.
+13 -13
View File
@@ -1,4 +1,4 @@
// Hamlet — Proxmox-style web console for a bare-metal Debian host. // Ham Lab — Proxmox-style web console for a bare-metal Debian host.
package main package main
import ( import (
@@ -17,25 +17,25 @@ import (
"golang.org/x/term" "golang.org/x/term"
"hamlet/internal/api" "hamlab/internal/api"
"hamlet/internal/auth" "hamlab/internal/auth"
"hamlet/internal/config" "hamlab/internal/config"
"hamlet/internal/store" "hamlab/internal/store"
hterm "hamlet/internal/term" hterm "hamlab/internal/term"
"hamlet/internal/web" "hamlab/internal/web"
) )
var version = "dev" var version = "dev"
func main() { func main() {
defaultCfg := os.Getenv("HAMLET_CONFIG") defaultCfg := os.Getenv("HAMLAB_CONFIG")
if defaultCfg == "" { if defaultCfg == "" {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
defaultCfg = filepath.Join(home, "apps", "hamlet", "config.json") defaultCfg = filepath.Join(home, "apps", "hamlab", "config.json")
} }
cfgPath := flag.String("config", defaultCfg, "path to config.json") cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: hamlet [-config path] [serve|passwd|version]\n") fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version]\n")
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -50,7 +50,7 @@ func main() {
} }
switch cmd { switch cmd {
case "version": case "version":
fmt.Println("hamlet", version) fmt.Println("hamlab", version)
case "passwd": case "passwd":
if err := passwd(cfg); err != nil { if err := passwd(cfg); err != nil {
log.Fatal(err) log.Fatal(err)
@@ -98,7 +98,7 @@ func passwd(cfg *config.Config) error {
func serve(cfg *config.Config) error { func serve(cfg *config.Config) error {
if cfg.PasswordHash == "" { if cfg.PasswordHash == "" {
return fmt.Errorf("no password set — run: hamlet -config %s passwd", cfg.Path()) return fmt.Errorf("no password set — run: hamlab -config %s passwd", cfg.Path())
} }
if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil { if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil {
return err return err
@@ -152,7 +152,7 @@ func serve(cfg *config.Config) error {
_ = hs.Shutdown(ctx) _ = hs.Shutdown(ctx)
mgr.Shutdown() mgr.Shutdown()
}() }()
log.Printf("hamlet %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell) log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Hamlet web console Description=Ham Lab web console
After=network.target After=network.target
[Service] [Service]
@@ -7,8 +7,8 @@ Type=simple
User=core User=core
Group=core Group=core
WorkingDirectory=/home/core WorkingDirectory=/home/core
Environment=HAMLET_CONFIG=/home/core/apps/hamlet/config.json Environment=HAMLAB_CONFIG=/home/core/apps/hamlab/config.json
ExecStart=/usr/local/bin/hamlet serve ExecStart=/usr/local/bin/hamlab serve
Restart=always Restart=always
RestartSec=3 RestartSec=3
KillMode=control-group KillMode=control-group
+5 -5
View File
@@ -2,10 +2,10 @@
# Install the built binary and unit on this host, then (re)start the service. # Install the built binary and unit on this host, then (re)start the service.
set -eu set -eu
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
sudo install -m 0755 bin/hamlet /usr/local/bin/hamlet sudo install -m 0755 bin/hamlab /usr/local/bin/hamlab
sudo install -m 0644 deploy/hamlet.service /etc/systemd/system/hamlet.service sudo install -m 0644 deploy/hamlab.service /etc/systemd/system/hamlab.service
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now hamlet.service >/dev/null sudo systemctl enable --now hamlab.service >/dev/null
sudo systemctl restart hamlet.service sudo systemctl restart hamlab.service
sleep 1 sleep 1
systemctl is-active hamlet.service && /usr/local/bin/hamlet version systemctl is-active hamlab.service && /usr/local/bin/hamlab version
+1 -1
View File
@@ -1,4 +1,4 @@
module hamlet module hamlab
go 1.26.0 go 1.26.0
+4 -4
View File
@@ -15,12 +15,12 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
"hamlet/internal/auth" "hamlab/internal/auth"
"hamlet/internal/config" "hamlab/internal/config"
"hamlet/internal/term" "hamlab/internal/term"
) )
const cookieName = "hamlet_session" const cookieName = "hamlab_session"
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"hamlet/internal/store" "hamlab/internal/store"
) )
const ( const (
+1 -1
View File
@@ -1,4 +1,4 @@
// Package config loads and saves hamlet's JSON config file. // Package config loads and saves hamlab's JSON config file.
package config package config
import ( import (
+1 -1
View File
@@ -87,7 +87,7 @@ func (m *Manager) Create(id, name string) (*Session, error) {
} }
cmd := exec.Command(m.shell, "-l") cmd := exec.Command(m.shell, "-l")
cmd.Dir = m.home cmd.Dir = m.home
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLET=1") cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow}) ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
if err != nil { if err != nil {
return nil, err return nil, err
+2 -2
View File
@@ -7,11 +7,11 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hamlet" /> <meta name="apple-mobile-web-app-title" content="Ham Lab" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon-192.png" /> <link rel="icon" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Hamlet</title> <title>Ham Lab</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "hamlet-web", "name": "hamlab-web",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hamlet-web", "name": "hamlab-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "hamlet-web", "name": "hamlab-web",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "Hamlet", "name": "Ham Lab",
"short_name": "Hamlet", "short_name": "Ham Lab",
"description": "Linux management console", "description": "Linux management console",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
+2 -2
View File
@@ -1,5 +1,5 @@
// Hamlet service worker: cache hashed assets, never the API, network-first for the shell. // Ham Lab service worker: cache hashed assets, never the API, network-first for the shell.
const CACHE = 'hamlet-v1'; const CACHE = 'hamlab-v1';
self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => { self.addEventListener('activate', (e) => {
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())); e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
+1 -1
View File
@@ -17,7 +17,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
return ( return (
<div className="center"> <div className="center">
<form className="login" onSubmit={submit}> <form className="login" onSubmit={submit}>
<h1>Hamlet</h1> <h1>Ham Lab</h1>
<input type="password" autoFocus autoComplete="current-password" placeholder="password" <input type="password" autoFocus autoComplete="current-password" placeholder="password"
value={pw} onChange={(e) => setPw(e.target.value)} /> value={pw} onChange={(e) => setPw(e.target.value)} />
<button disabled={busy || !pw}>{busy ? '…' : 'Sign in'}</button> <button disabled={busy || !pw}>{busy ? '…' : 'Sign in'}</button>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<div className={'app' + (drawer ? ' drawer-open' : '')}> <div className={'app' + (drawer ? ' drawer-open' : '')}>
<header className="header"> <header className="header">
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button> <button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
<span className="brand">Hamlet</span> <span className="brand">Ham Lab</span>
<span className="host">{me.user}@{me.host}</span> <span className="host">{me.user}@{me.host}</span>
<span className="spacer" /> <span className="spacer" />
<span className="stat" title="load average">{me.load}</span> <span className="stat" title="load average">{me.load}</span>