Files
llmgw/internal/cli/tui_init.go
T
Ilker ManapandClaude Opus 5.5 b5f412f9df Put the mirror on its own machine in two commands
mirror init sets a machine up as the model mirror — the user, /srv/models,
and a unit serving files on :8081 and the management API on :8082 — and
server init --mirror points the control plane at it instead of running one.
Before, that took a unit written by hand and an edit to server.yaml.

The management API can fetch and delete models and answered anyone who
could reach it. mirror serve --api-allow limits it to the addresses given
and the machine itself; mirror init passes the control plane's. The unit
text is shared with the one server init writes for a mirror beside it.

With --no-node the starter model is still fetched and catalogued, unpinned,
so it starts on the first node added, and the init form shows the mirror in
use instead of offering to run one.

Tried on three empty VMs: the mirror answered the control plane and refused
the node, and the starter model came up on the node when it was added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 22:17:24 +02:00

404 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"context"
"fmt"
"io"
"net/mail"
"strings"
"sync"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// The full-screen way through 'llmgw server init': a short form for what
// init would otherwise take as flags, then init's own lines as a list that
// fills in, with the administrator's password kept in view — and printed
// again once the screen closes, so it is in the terminal's history.
// initLineMsg is one line init wrote.
type initLineMsg string
// initDoneMsg is init finishing.
type initDoneMsg struct{ err error }
// lineWriter turns init's output into one message per line.
type lineWriter struct {
mu sync.Mutex
buf []byte
send func(string)
}
func (w *lineWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buf = append(w.buf, p...)
for {
i := strings.IndexByte(string(w.buf), '\n')
if i < 0 {
return len(p), nil
}
w.send(string(w.buf[:i]))
w.buf = w.buf[i+1:]
}
}
// Form fields, in order.
const (
fAdmin = iota
fHosts
fPostgres
fMirror
fNode
fModel
fStart
fCount
)
type initModel struct {
in *serverInit
found []string // this machine's addresses
// The form, until it is submitted.
form bool
focus int
admin textinput.Model
hosts textinput.Model
toggles [fCount]bool
formError string
// Then the run.
running bool
lines []string // init's lines, as it wrote them
password string
next []string // the closing "Next:" text
spin spinner.Model
width int
done bool
err error
quit bool
start func()
}
func newInitModel(in *serverInit, found []string, askForm bool) initModel {
admin := textinput.New()
admin.Placeholder = "you@example.com"
admin.SetValue(in.admin)
admin.CharLimit = 200
admin.Width = 40
hosts := textinput.New()
hosts.Placeholder = "llm.example.com, 203.0.113.10"
hosts.SetValue(strings.Join(in.hosts, ", "))
hosts.CharLimit = 400
hosts.Width = 40
sp := spinner.New()
sp.Spinner = spinner.MiniDot
sp.Style = lipgloss.NewStyle().Foreground(tuiAccent)
m := initModel{in: in, found: found, form: askForm, admin: admin, hosts: hosts, spin: sp, width: 100}
m.toggles[fPostgres] = in.postgres
m.toggles[fMirror] = !in.noMirror
m.toggles[fNode] = !in.noNode
m.toggles[fModel] = !in.noModel
if askForm {
m.admin.Focus()
}
return m
}
func (m initModel) Init() tea.Cmd {
if m.form {
return textinput.Blink
}
return tea.Batch(m.spin.Tick, func() tea.Msg { return startInitMsg{} })
}
type startInitMsg struct{}
func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
case startInitMsg:
m.running = true
if m.start != nil {
m.start()
}
case initLineMsg:
m.addLine(string(msg))
case initDoneMsg:
m.done, m.err = true, msg.err
return m, tea.Quit
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quit = true
return m, tea.Quit
}
if m.form {
return m.formKey(msg)
}
}
return m, nil
}
// addLine files one of init's lines: the password aside, the closing text
// aside, a progress line replacing the one before it.
func (m *initModel) addLine(l string) {
t := strings.TrimSpace(l)
switch {
case t == "":
return
case strings.HasPrefix(t, "password: "):
m.password = strings.TrimPrefix(t, "password: ")
return
case strings.HasPrefix(t, "shown once"):
return
case !strings.HasPrefix(l, " ") || len(m.next) > 0:
// Unindented lines are the closing text, and everything after its
// first line belongs to it.
m.next = append(m.next, l)
return
}
if strings.HasPrefix(t, "..") && len(m.lines) > 0 && strings.HasPrefix(strings.TrimSpace(m.lines[len(m.lines)-1]), "..") {
m.lines[len(m.lines)-1] = l
return
}
m.lines = append(m.lines, l)
}
func (m initModel) formKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k.Type {
case tea.KeyTab, tea.KeyDown:
m.moveFocus(1)
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
m.moveFocus(-1)
return m, nil
case tea.KeyEnter:
if m.focus != fStart {
m.moveFocus(1)
return m, nil
}
admin := strings.TrimSpace(m.admin.Value())
if admin != "" {
if _, err := mail.ParseAddress(admin); err != nil {
m.formError = "the administrator's email is not an address"
m.focus = fAdmin
m.admin.Focus()
return m, nil
}
}
m.in.admin = admin
m.in.hosts = nil
for _, h := range strings.Split(m.hosts.Value(), ",") {
if h = strings.TrimSpace(h); h != "" {
m.in.hosts = append(m.in.hosts, h)
}
}
m.in.postgres = m.toggles[fPostgres]
m.in.noMirror = !m.toggles[fMirror]
m.in.noNode = !m.toggles[fNode]
m.in.noModel = !m.toggles[fModel]
m.form = false
return m, tea.Batch(m.spin.Tick, func() tea.Msg { return startInitMsg{} })
}
switch m.focus {
case fAdmin:
var cmd tea.Cmd
m.admin, cmd = m.admin.Update(k)
return m, cmd
case fHosts:
var cmd tea.Cmd
m.hosts, cmd = m.hosts.Update(k)
return m, cmd
case fPostgres, fMirror, fNode, fModel:
if k.String() == " " || k.String() == "x" {
m.toggles[m.focus] = !m.toggles[m.focus]
}
}
return m, nil
}
func (m *initModel) moveFocus(d int) {
m.focus = (m.focus + d + fCount) % fCount
// With --mirror there is no mirror to run here, so nothing to tick.
if m.focus == fMirror && m.in.mirrorURL != "" {
m.focus = (m.focus + d + fCount) % fCount
}
m.admin.Blur()
m.hosts.Blur()
switch m.focus {
case fAdmin:
m.admin.Focus()
case fHosts:
m.hosts.Focus()
}
}
func (m initModel) View() string {
var b strings.Builder
b.WriteString(sTitle.Render("llmgw · setting up this machine as a control plane") + "\n\n")
if m.form {
return b.String() + m.formView()
}
for _, l := range m.visibleLines() {
b.WriteString(m.lineView(l) + "\n")
}
if m.password != "" {
box := sBox.BorderForeground(tuiWarn).Render(
fmt.Sprintf("Administrator %s\npassword %s\n%s", m.in.admin,
lipgloss.NewStyle().Bold(true).Render(m.password),
sDim.Render("Shown once. Copy it now; change it on the account page after signing in.")))
b.WriteString("\n" + box + "\n")
}
switch {
case m.done && m.err != nil:
b.WriteString("\n" + sBad.Render("✗ ") + wrap(m.err.Error(), m.width-4, " ") + "\n" +
sDim.Render(" It is safe to run init again once that is fixed: it finishes what is missing.") + "\n")
case m.done:
b.WriteString("\n" + sOK.Render("✓ ") + "The control plane is up. Add a node: " +
lipgloss.NewStyle().Bold(true).Render("sudo llmgw node add <user>@<address>") + "\n")
b.WriteString(" " + sDim.Render("Back up "+m.in.pkiDir+": its ca-key.pem signs every node's identity, and nothing recovers it.") + "\n")
case m.quit:
b.WriteString("\n" + sDim.Render("stopped; run init again to finish") + "\n")
case m.running:
b.WriteString("\n" + sDim.Render("ctrl+c to stop") + "\n")
}
return b.String()
}
// visibleLines are the last lines, enough to follow without the screen
// outgrowing the terminal: the whole log is printed when it closes.
func (m initModel) visibleLines() []string {
const keep = 16
if len(m.lines) > keep {
return m.lines[len(m.lines)-keep:]
}
return m.lines
}
func (m initModel) lineView(l string) string {
t := strings.TrimSpace(l)
word, rest, _ := strings.Cut(t, " ")
rest = truncate(strings.TrimSpace(rest), max(20, m.width-6))
switch word {
case "ok":
return " " + sOK.Render("✓") + " " + rest
case "kept":
return " " + sDim.Render("· "+rest)
case "skip":
return " " + sDim.Render("- "+rest)
case "!!":
return " " + sWarn.Render("! ") + rest
case "..":
if m.done {
return " " + sDim.Render("· "+rest)
}
return " " + m.spin.View() + " " + rest
}
return " " + t
}
func (m initModel) formView() string {
var b strings.Builder
label := func(i int, s string) string {
if m.focus == i {
return sTitle.Render("› " + s)
}
return " " + s
}
check := func(i int, s, hint string) string {
box := "[ ]"
if m.toggles[i] {
box = "[" + sOK.Render("x") + "]"
}
return fmt.Sprintf("%s %s %s\n", label(i, box), s, sDim.Render(hint))
}
fmt.Fprintf(&b, "%s\n %s\n", label(fAdmin, "Administrator's email"), m.admin.View())
b.WriteString(" " + sDim.Render("A password is generated and shown once. Leave empty to create accounts later.") + "\n\n")
fmt.Fprintf(&b, "%s\n %s\n", label(fHosts, "Other names nodes will reach this machine by"), m.hosts.View())
found := "none found"
if len(m.found) > 0 {
found = strings.Join(m.found, ", ")
}
b.WriteString(" " + sDim.Render("Already in the certificate: "+found+". Add a DNS name or NAT address here.") + "\n\n")
b.WriteString(check(fPostgres, "PostgreSQL", "rather than SQLite; installed here, for several control planes or a larger store"))
if m.in.mirrorURL != "" {
b.WriteString(" Model mirror " + sDim.Render("at "+m.in.mirrorURL+", from --mirror") + "\n")
} else {
b.WriteString(check(fMirror, "Model mirror", "downloads model files once for every node"))
}
b.WriteString(check(fNode, "This machine as a node", "serves models here too, on its GPUs or CPU"))
b.WriteString(check(fModel, "Starter model", "a small Qwen, so there is something to ask at once"))
b.WriteString("\n" + label(fStart, lipgloss.NewStyle().Bold(true).Render("Set up")) + "\n")
if m.formError != "" {
b.WriteString("\n" + sBad.Render(m.formError) + "\n")
}
b.WriteString("\n" + sDim.Render("tab or ↑↓ to move · space to tick · enter on Set up to start · ctrl+c to leave") + "\n")
return b.String()
}
// runInitTUI runs init with the screen above, asking the form first when
// the flags left the administrator unset.
func (in *serverInit) runInitTUI(ctx context.Context, out io.Writer) error {
var found []string
for _, ip := range localAddrs() {
found = append(found, ip.String())
}
m := newInitModel(in, found, in.admin == "")
var p *tea.Program
var (
logMu sync.Mutex
log []string
)
w := &lineWriter{send: func(l string) {
logMu.Lock()
log = append(log, l)
logMu.Unlock()
p.Send(initLineMsg(l))
}}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
started := make(chan struct{})
m.start = func() {
close(started)
go func() {
in.out = w
err := in.run(ctx)
p.Send(initDoneMsg{err})
}()
}
p = tea.NewProgram(m, tea.WithOutput(out))
final, err := p.Run()
if err != nil {
return err
}
fm := final.(initModel)
select {
case <-started:
default:
return fmt.Errorf("left before anything was done")
}
if fm.quit && !fm.done {
cancel()
return fmt.Errorf("stopped; run init again to finish what was started")
}
// The screen showed the last lines; the whole run goes to the terminal's
// history, the password with it.
logMu.Lock()
defer logMu.Unlock()
fmt.Fprintln(out)
for _, l := range log {
fmt.Fprintln(out, l)
}
return fm.err
}