once-add: deploy + remove Once apps at <name>.local

A small Go tool that wraps the `/etc/hosts` + `once deploy --disable-tls`
recipe needed to host a Once app on a single-label `<name>.local`. Adds
the hosts entry, runs the deploy, polls /up, and on removal cleans the
hosts line that the Once UI orphans (including the orphan-only case,
where the app is already gone).

Bubble Tea TUI for interactive use; non-interactive CLI for scripts.
Curated apps live in /etc/once-add/apps.toml; seeded with Writebook +
Campfire on first run.
This commit is contained in:
28allday 2026-05-28 07:23:07 +01:00
commit cd978acde0
12 changed files with 1496 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/once-add
/dist/
*.iso

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gavin Nugent
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

73
README.md Normal file
View file

@ -0,0 +1,73 @@
# once-add
Deploy and remove a [Once](https://once.com) app at a single-label `<name>.local`
host. Adds the right `/etc/hosts` entry, runs `once deploy --disable-tls`, and
(on removal) cleans the `/etc/hosts` line that the Once UI orphans.
## Why it's needed
Once's binary is statically linked (pure-Go resolver), so its post-deploy
`GET /up` verify resolves `/etc/hosts` + real DNS only — never mDNS — and the
TUI install forces TLS on (no Let's Encrypt cert for a private `.local`). Either
one makes Once roll back the container. `once-add` runs the working recipe:
```sh
echo "127.0.0.1 <name>.local" | sudo tee -a /etc/hosts # so Once's verify resolves it
once deploy <image> --host <name>.local --disable-tls # TLS off; http verify passes
```
For other machines on your LAN to reach the site you'll also need an mDNS
publisher running on the box (something that announces `<name>.local` over
Avahi). Without one, the site is only reachable from the hosting box itself.
## Install
```sh
curl -fsSL https://github.com/28allday/once-add/releases/latest/download/once-add \
-o once-add && sudo install -m 755 once-add /usr/local/bin/once-add && rm once-add
```
## Usage
Runs as root (it edits `/etc/hosts`); self-elevates with `sudo` if needed.
```sh
once-add # interactive wizard (Add / Remove)
once-add book ghcr.io/basecamp/writebook # add (non-interactive)
once-add remove book # remove app + its /etc/hosts entry
once-add remove book --data # also delete the data volume (irreversible)
once-add remove # interactive remove picker
```
**Add** wizard: pick a curated app (or a custom image) → name it → deploy →
reachable at `http://<name>.local`.
**Remove** runs `once remove <host>` and then clears the `/etc/hosts` line
once-add added (the Once UI removes the container but orphans that line). Data
volumes are kept by default — pass `--data` (or tick the toggle in the wizard)
to delete them too. If the app is already gone, the orphaned `/etc/hosts` line
is cleaned up anyway.
## Curated apps
Edit `/etc/once-add/apps.toml` to add entries — no rebuild needed. `once-add`
seeds a default file (Writebook, Campfire) on first run if it's missing.
```toml
[[app]]
name = "Writebook"
image = "ghcr.io/basecamp/writebook"
description = "Books & documentation"
suggested = "book"
```
## Build
```sh
CGO_ENABLED=0 go build -o once-add ./cmd/once-add # static binary
go test ./...
```
## Licence
MIT.

150
cmd/once-add/main.go Normal file
View file

@ -0,0 +1,150 @@
// Command once-add manages LAN-reachable Once sites served as single-label
// <name>.local hosts.
//
// once-add interactive wizard (Add / Remove menu)
// once-add <name> <image> add (non-interactive)
// once-add remove <name> [--data] remove (non-interactive; --data also drops the volume)
// once-add remove interactive remove picker
package main
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
tea "github.com/charmbracelet/bubbletea"
"once-add/internal/deploy"
"once-add/internal/tui"
)
const usage = `once-add manage LAN-reachable Once sites (<name>.local)
once-add interactive wizard (Add / Remove)
once-add <name> <image> add, e.g. once-add book ghcr.io/basecamp/writebook
once-add remove <name> [--data] remove the app + its /etc/hosts entry
(--data also deletes the data volume)
once-add remove interactive remove picker`
func main() {
args := os.Args[1:]
for _, a := range args {
if a == "-h" || a == "--help" {
fmt.Println(usage)
return
}
}
ensureRoot()
if len(args) == 0 {
run(tui.New())
return
}
switch args[0] {
case "remove", "rm":
rest, removeData := extractDataFlag(args[1:])
switch len(rest) {
case 0:
run(tui.NewRemove())
case 1:
runRemoveCLI(rest[0], removeData)
default:
usageErr()
}
default:
if len(args) == 2 {
runAddCLI(args[0], args[1])
} else {
usageErr()
}
}
}
func usageErr() {
fmt.Fprintln(os.Stderr, usage)
os.Exit(2)
}
// extractDataFlag pulls a --data/--remove-data flag out of args, returning the
// remaining positional args and whether the flag was present.
func extractDataFlag(args []string) (rest []string, data bool) {
for _, a := range args {
switch a {
case "--data", "--remove-data":
data = true
default:
rest = append(rest, a)
}
}
return rest, data
}
// ensureRoot re-execs under sudo when not root, since the recipe edits /etc/hosts.
func ensureRoot() {
if os.Geteuid() == 0 {
return
}
sudo, err := exec.LookPath("sudo")
if err != nil {
fmt.Fprintln(os.Stderr, "once-add must run as root (it edits /etc/hosts). Re-run with sudo.")
os.Exit(1)
}
fmt.Fprintln(os.Stderr, "once-add needs root to edit /etc/hosts — elevating with sudo…")
self, err := os.Executable()
if err != nil {
self = os.Args[0]
}
argv := append([]string{"sudo", self}, os.Args[1:]...)
if err := syscall.Exec(sudo, argv, os.Environ()); err != nil {
fmt.Fprintf(os.Stderr, "failed to elevate: %v\n", err)
os.Exit(1)
}
}
func run(m tea.Model) {
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func runAddCLI(name, image string) {
if err := deploy.ValidateName(name); err != nil {
fmt.Fprintf(os.Stderr, "invalid name %q: %v\n", name, err)
os.Exit(1)
}
stream(deploy.Run(context.Background(), name, image), func(url string) {
fmt.Printf("✓ reachable at %s\n", url)
fmt.Println(" Published on the LAN within ~20s; manage it in the Once interface.")
})
}
func runRemoveCLI(name string, removeData bool) {
if err := deploy.ValidateName(name); err != nil {
fmt.Fprintf(os.Stderr, "invalid name %q: %v\n", name, err)
os.Exit(1)
}
stream(deploy.RunRemove(context.Background(), deploy.Host(name), removeData), func(line string) {
fmt.Printf("✓ %s\n", line)
})
}
// stream prints progress events from a deploy/remove channel, calling done with
// the terminal Event's Line on success, or exiting non-zero on failure.
func stream(ch <-chan deploy.Event, done func(line string)) {
for ev := range ch {
switch {
case ev.Err != nil:
fmt.Fprintf(os.Stderr, "✗ %v\n", ev.Err)
os.Exit(1)
case ev.Done:
done(ev.Line)
default:
fmt.Println(ev.Line)
}
}
}

34
go.mod Normal file
View file

@ -0,0 +1,34 @@
module once-add
go 1.26.1
require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

54
go.sum Normal file
View file

@ -0,0 +1,54 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

64
internal/config/config.go Normal file
View file

@ -0,0 +1,64 @@
// Package config loads the curated app list for the once-add wizard from
// /etc/once-add/apps.toml, writing a default seed if the file is absent.
package config
import (
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
// Path is the on-disk location of the curated app list. Edit it to add apps;
// no rebuild of once-add is needed.
const Path = "/etc/once-add/apps.toml"
// App is one curated entry shown in the wizard's app picker.
type App struct {
Name string `toml:"name"` // display name, e.g. "Writebook"
Image string `toml:"image"` // container image ref
Description string `toml:"description"` // one-line description
Suggested string `toml:"suggested"` // pre-fills the hostname field
}
type file struct {
App []App `toml:"app"`
}
const defaultSeed = `# once-add curated apps. Edit freely add [[app]] blocks; no rebuild needed.
# 'suggested' pre-fills the hostname field in the wizard.
[[app]]
name = "Writebook"
image = "ghcr.io/basecamp/writebook"
description = "Books & documentation"
suggested = "book"
[[app]]
name = "Campfire"
image = "ghcr.io/basecamp/once-campfire"
description = "Team chat"
suggested = "chat"
`
// Load reads the curated app list, writing the default seed first if the file
// does not yet exist. Writing the seed requires root (once-add runs as root).
func Load() ([]App, error) {
if _, err := os.Stat(Path); os.IsNotExist(err) {
if err := writeDefault(); err != nil {
return nil, err
}
}
var f file
if _, err := toml.DecodeFile(Path, &f); err != nil {
return nil, err
}
return f.App, nil
}
func writeDefault() error {
if err := os.MkdirAll(filepath.Dir(Path), 0o755); err != nil {
return err
}
return os.WriteFile(Path, []byte(defaultSeed), 0o644)
}

356
internal/deploy/deploy.go Normal file
View file

@ -0,0 +1,356 @@
// Package deploy implements the once-add recipe for adding a LAN-reachable
// Once site as a single-label <name>.local host.
//
// Once's binary is statically linked (pure-Go resolver), so its post-deploy
// HTTP verify resolves /etc/hosts + real DNS only — never mDNS. The TUI install
// also forces TLS on and can't get a cert for a private .local host. So we add
// an /etc/hosts entry (verify resolves to loopback) and deploy from the CLI with
// --disable-tls; once-mdns-sync then publishes <name>.local to the LAN.
package deploy
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"time"
)
const (
hostsPath = "/etc/hosts"
verifyTimeout = 60 * time.Second
)
var nameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
// ValidateName enforces a single DNS label: lowercase letters, digits and
// hyphens, not starting or ending with a hyphen, 63 characters or fewer.
func ValidateName(name string) error {
switch {
case name == "":
return fmt.Errorf("name is required")
case strings.Contains(name, "."):
return fmt.Errorf("name must be a single label (no dots), e.g. 'book'")
case len(name) > 63:
return fmt.Errorf("name must be 63 characters or fewer")
case !nameRe.MatchString(name):
return fmt.Errorf("use lowercase letters, digits and hyphens (not leading/trailing)")
}
return nil
}
// Host returns the .local hostname for a name.
func Host(name string) string { return name + ".local" }
var hostLabelRe = regexp.MustCompile(`"host"\s*:\s*"([^"]+)"`)
// InUseHosts returns the set of *.local hosts currently served by running Once
// apps. It mirrors once-mdns-sync: read each app container's `once` label (Once
// stores ApplicationSettings as JSON there) and pull its "host" field, keeping
// only the *.local names — so image names and env values can't false-match.
func InUseHosts() (map[string]bool, error) { return dockerHosts(false) }
// PresentHosts returns *.local hosts on this box's Once containers including
// stopped ones — the right check for "does this app still exist?" before
// attempting removal.
func PresentHosts() (map[string]bool, error) { return dockerHosts(true) }
func dockerHosts(includeStopped bool) (map[string]bool, error) {
psArgs := []string{"ps", "-q"}
if includeStopped {
psArgs = []string{"ps", "-a", "-q"}
}
out, err := exec.Command("docker", psArgs...).Output()
if err != nil {
return nil, fmt.Errorf("docker ps: %w", err)
}
ids := strings.Fields(string(out))
hosts := map[string]bool{}
if len(ids) == 0 {
return hosts, nil
}
args := append([]string{"inspect", "--format", `{{ index .Config.Labels "once" }}`}, ids...)
out, err = exec.Command("docker", args...).Output()
if err != nil {
return nil, fmt.Errorf("docker inspect: %w", err)
}
return parseHosts(string(out)), nil
}
// parseHosts extracts the *.local "host" values from concatenated `once` label
// JSON blobs.
func parseHosts(blob string) map[string]bool {
hosts := map[string]bool{}
for _, m := range hostLabelRe.FindAllStringSubmatch(blob, -1) {
h := strings.ToLower(m[1])
if strings.HasSuffix(h, ".local") {
hosts[h] = true
}
}
return hosts
}
// DeployedHosts returns the *.local hosts currently served by running Once
// apps, sorted — the candidates for removal.
func DeployedHosts() ([]string, error) {
m, err := InUseHosts()
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(m))
for h := range m {
hosts = append(hosts, h)
}
sort.Strings(hosts)
return hosts, nil
}
// EnsureHostsEntry adds `127.0.0.1 <host>` to /etc/hosts if no line already
// resolves host. It reports whether it added a line. Requires root.
func EnsureHostsEntry(host string) (bool, error) {
data, err := os.ReadFile(hostsPath)
if err != nil {
return false, err
}
present := regexp.MustCompile(`(?m)^[0-9.]+[ \t]+` + regexp.QuoteMeta(host) + `([ \t]|$)`)
if present.Match(data) {
return false, nil
}
f, err := os.OpenFile(hostsPath, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
defer f.Close()
var b strings.Builder
if len(data) > 0 && data[len(data)-1] != '\n' {
b.WriteByte('\n') // don't fuse onto a file that lacks a trailing newline
}
b.WriteString("127.0.0.1 " + host + "\n")
if _, err := f.WriteString(b.String()); err != nil {
return false, err
}
return true, nil
}
// RemoveHostsEntry deletes any line in /etc/hosts that resolves host. Returns
// whether it removed one. Requires root.
func RemoveHostsEntry(host string) (bool, error) {
data, err := os.ReadFile(hostsPath)
if err != nil {
return false, err
}
kept, removed := filterHostLines(string(data), host)
if !removed {
return false, nil
}
// O_TRUNC keeps the existing file mode; root owns /etc/hosts.
return true, os.WriteFile(hostsPath, []byte(kept), 0o644)
}
// filterHostLines drops every line resolving host, returning the rewritten file
// content and whether anything was removed. A line matches only when host is the
// first name right after the leading IP — so 'book.local' never matches
// 'mybook.local' or 'book.local2'.
func filterHostLines(data, host string) (string, bool) {
re := regexp.MustCompile(`^[0-9.]+[ \t]+` + regexp.QuoteMeta(host) + `([ \t]|$)`)
lines := strings.Split(data, "\n")
kept := make([]string, 0, len(lines))
removed := false
for _, ln := range lines {
if re.MatchString(ln) {
removed = true
continue
}
kept = append(kept, ln)
}
return strings.Join(kept, "\n"), removed
}
// Verify polls http://<host>/up until it returns 2xx or the timeout elapses.
// The box resolves single-label .local via nss, so this is a real end-to-end
// reachability check.
func Verify(ctx context.Context, host string, timeout time.Duration) error {
url := "http://" + host + "/up"
client := &http.Client{Timeout: 5 * time.Second}
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
lastErr = err
} else {
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
if lastErr == nil {
lastErr = fmt.Errorf("timed out")
}
return fmt.Errorf("%s not reachable within %s: %w", url, timeout, lastErr)
}
// Event is one step of progress from Run. Exactly one of the terminal flags is
// set on the final event: Err for failure, Done for success.
type Event struct {
Line string // progress or streamed command output
Err error // non-nil: terminal failure
Done bool // true: terminal success (Line holds the reachable URL note)
}
// Run executes the full recipe for name+image, emitting progress on the
// returned channel. The channel is closed after a terminal Event. Used by both
// the TUI and the non-interactive CLI path.
func Run(ctx context.Context, name, image string) <-chan Event {
ch := make(chan Event)
go func() {
defer close(ch)
emit := func(s string) { ch <- Event{Line: s} }
fail := func(err error) { ch <- Event{Err: err} }
host := Host(name)
emit("Checking " + host + " isn't already in use…")
inUse, err := InUseHosts()
if err != nil {
fail(err)
return
}
if inUse[host] {
fail(fmt.Errorf("%s is already served by a running app — pick another name", host))
return
}
added, err := EnsureHostsEntry(host)
if err != nil {
fail(fmt.Errorf("updating /etc/hosts: %w", err))
return
}
if added {
emit("Added '127.0.0.1 " + host + "' to /etc/hosts")
} else {
emit("/etc/hosts already resolves " + host)
}
emit("Deploying " + image + " as " + host + " (TLS off)…")
if err := streamCmd(ctx, "once", []string{"deploy", image, "--host", host, "--disable-tls"}, emit); err != nil {
fail(fmt.Errorf("once deploy failed: %w", err))
return
}
emit("Waiting for " + host + " to come up…")
if err := Verify(ctx, host, verifyTimeout); err != nil {
fail(err)
return
}
ch <- Event{Done: true, Line: "http://" + host}
}()
return ch
}
// RunRemove tears down the app on host: `once remove <host> [--remove-data]`
// (skipped if no container for host exists — the orphan case, where the Once
// UI already removed the app but left its /etc/hosts line behind), then clears
// that /etc/hosts line (once-mdns-sync drops the LAN publisher on its own).
// Emits progress like Run; channel closes after a terminal Event.
func RunRemove(ctx context.Context, host string, removeData bool) <-chan Event {
ch := make(chan Event)
go func() {
defer close(ch)
emit := func(s string) { ch <- Event{Line: s} }
fail := func(err error) { ch <- Event{Err: err} }
present, err := PresentHosts()
if err != nil {
fail(err)
return
}
appRemoved := false
if present[host] {
args := []string{"remove", host}
note := ""
if removeData {
args = append(args, "--remove-data")
note = " (incl. data volume)"
}
emit("Removing app " + host + note + "…")
if err := streamCmd(ctx, "once", args, emit); err != nil {
fail(fmt.Errorf("once remove failed: %w", err))
return
}
appRemoved = true
} else {
emit("No container for " + host + " — cleaning up /etc/hosts only")
}
hostsRemoved, err := RemoveHostsEntry(host)
if err != nil {
fail(fmt.Errorf("updating /etc/hosts: %w", err))
return
}
if hostsRemoved {
emit("Removed '" + host + "' from /etc/hosts")
} else {
emit("No /etc/hosts entry for " + host)
}
ch <- Event{Done: true, Line: removeDoneMsg(host, appRemoved, hostsRemoved)}
}()
return ch
}
// removeDoneMsg picks the success-line wording for the four outcomes of remove.
func removeDoneMsg(host string, appRemoved, hostsRemoved bool) string {
switch {
case appRemoved && hostsRemoved:
return "Removed " + host
case appRemoved:
return "Removed " + host + " (no /etc/hosts entry to clean)"
case hostsRemoved:
return "Cleaned orphan /etc/hosts entry for " + host
default:
return "Nothing to remove for " + host
}
}
// streamCmd runs name+args, streaming combined stdout/stderr line by line
// through emit, and returns the command's exit error.
func streamCmd(ctx context.Context, name string, args []string, emit func(string)) error {
cmd := exec.CommandContext(ctx, name, args...)
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stderr = pw // same writer: os/exec uses a single pipe, so lines stay ordered
if err := cmd.Start(); err != nil {
pw.Close()
return fmt.Errorf("starting %s: %w", name, err)
}
scanDone := make(chan struct{})
go func() {
sc := bufio.NewScanner(pr)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
emit(sc.Text())
}
close(scanDone)
}()
werr := cmd.Wait()
pw.Close()
<-scanDone
return werr
}

View file

@ -0,0 +1,94 @@
package deploy
import (
"strings"
"testing"
)
func TestValidateName(t *testing.T) {
good := []string{"book", "chat", "a", "my-app", "app1", "1book"}
for _, n := range good {
if err := ValidateName(n); err != nil {
t.Errorf("ValidateName(%q) = %v, want nil", n, err)
}
}
bad := []string{"", "Book", "book.local", "-book", "book-", "a_b", "café", "two words"}
for _, n := range bad {
if err := ValidateName(n); err == nil {
t.Errorf("ValidateName(%q) = nil, want error", n)
}
}
long := make([]byte, 64)
for i := range long {
long[i] = 'a'
}
if err := ValidateName(string(long)); err == nil {
t.Errorf("ValidateName(64 chars) = nil, want error")
}
}
func TestParseHosts(t *testing.T) {
// Two app containers' `once` labels concatenated, as docker inspect emits.
blob := `{"image":"ghcr.io/basecamp/writebook","host":"Book.local","tls":false}
{"host":"chat.local","image":"once-campfire"}
{"host":"public.example.com"}`
got := parseHosts(blob)
if !got["book.local"] { // lowercased
t.Errorf("expected book.local in %v", got)
}
if !got["chat.local"] {
t.Errorf("expected chat.local in %v", got)
}
if got["public.example.com"] {
t.Errorf("non-.local host should be excluded: %v", got)
}
if len(got) != 2 {
t.Errorf("got %d hosts, want 2: %v", len(got), got)
}
}
func TestHost(t *testing.T) {
if Host("book") != "book.local" {
t.Errorf("Host(book) = %q", Host("book"))
}
}
func TestFilterHostLines(t *testing.T) {
in := "127.0.1.1\tdevbox\n" +
"127.0.0.1 book.local\n" +
"127.0.0.1 mybook.local\n" + // must NOT match book.local
"127.0.0.1 book.local2\n" + // must NOT match book.local
"127.0.0.1 chat.local\n"
out, removed := filterHostLines(in, "book.local")
if !removed {
t.Fatal("expected book.local to be removed")
}
if strings.Contains(out, "\n127.0.0.1 book.local\n") || strings.HasSuffix(out, "127.0.0.1 book.local") {
t.Errorf("book.local line still present:\n%s", out)
}
for _, keep := range []string{"devbox", "mybook.local", "book.local2", "chat.local"} {
if !strings.Contains(out, keep) {
t.Errorf("filter wrongly dropped %q:\n%s", keep, out)
}
}
if _, removed := filterHostLines(in, "absent.local"); removed {
t.Error("absent.local should report nothing removed")
}
}
func TestRemoveDoneMsg(t *testing.T) {
cases := []struct {
appRemoved, hostsRemoved bool
want string
}{
{true, true, "Removed book.local"},
{true, false, "Removed book.local (no /etc/hosts entry to clean)"},
{false, true, "Cleaned orphan /etc/hosts entry for book.local"},
{false, false, "Nothing to remove for book.local"},
}
for _, c := range cases {
if got := removeDoneMsg("book.local", c.appRemoved, c.hostsRemoved); got != c.want {
t.Errorf("removeDoneMsg(_, %v, %v) = %q, want %q", c.appRemoved, c.hostsRemoved, got, c.want)
}
}
}

View file

@ -0,0 +1,76 @@
package tui
import (
"fmt"
"strings"
"testing"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"once-add/internal/config"
)
// testModel builds a model without touching /etc, with components initialised
// so View() never panics.
func testModel() model {
sp := spinner.New()
sp.Spinner = spinner.Dot
return model{
customInput: textinput.New(),
nameInput: textinput.New(),
spinner: sp,
apps: []config.App{
{Name: "Writebook", Image: "ghcr.io/basecamp/writebook", Description: "Books", Suggested: "book"},
},
}
}
func TestViewAllStates(t *testing.T) {
states := []state{
stateMenu, stateSelectApp, stateCustomImage, stateName, stateDeploying,
stateRemovePick, stateRemoveConfirm, stateRemoving, stateDone, stateError,
}
for _, st := range states {
m := testModel()
m.state = st
m.name = "book"
m.image = "ghcr.io/basecamp/writebook"
m.resultURL = "http://book.local"
m.removeHosts = []string{"book.local", "chat.local"}
m.removeTarget = "chat.local"
m.doneMsg = "Removed chat.local"
m.err = fmt.Errorf("boom")
out := m.View()
if !strings.Contains(out, "once-add") {
t.Errorf("state %d: view missing title:\n%s", st, out)
}
}
}
func TestRemoveDoneWording(t *testing.T) {
m := testModel()
m.state = stateDone
m.op = opRemove
m.doneMsg = "Removed chat.local"
if !strings.Contains(m.View(), "Removed chat.local") {
t.Errorf("remove done view missing message:\n%s", m.View())
}
}
func TestEmptyRemovePick(t *testing.T) {
m := testModel()
m.state = stateRemovePick
m.removeHosts = nil
if !strings.Contains(m.View(), "No deployed") {
t.Errorf("empty remove pick should say nothing to remove:\n%s", m.View())
}
}
func TestSelectShowsCustomRow(t *testing.T) {
m := testModel()
out := m.viewSelect()
if !strings.Contains(out, "Writebook") || !strings.Contains(out, "Custom image") {
t.Errorf("select view missing rows:\n%s", out)
}
}

22
internal/tui/styles.go Normal file
View file

@ -0,0 +1,22 @@
package tui
import "github.com/charmbracelet/lipgloss"
// ANSI palette indices so colours adapt to the terminal theme (omaterm-friendly).
var (
accent = lipgloss.Color("12") // bright blue
good = lipgloss.Color("10") // green
bad = lipgloss.Color("9") // red
subtle = lipgloss.Color("8") // grey
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
descStyle = lipgloss.NewStyle().Foreground(subtle)
helpStyle = lipgloss.NewStyle().Foreground(subtle)
hintStyle = lipgloss.NewStyle().Foreground(subtle).Italic(true)
errStyle = lipgloss.NewStyle().Foreground(bad)
okStyle = lipgloss.NewStyle().Foreground(good)
urlStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(good)
)

549
internal/tui/tui.go Normal file
View file

@ -0,0 +1,549 @@
// Package tui implements the once-add wizard.
//
// Add path: menu → select app → (custom image) → name → deploying → done/error
// Remove path: menu → pick app → confirm (+ data toggle) → removing → done/error
package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"once-add/internal/config"
"once-add/internal/deploy"
)
type state int
const (
stateMenu state = iota
stateSelectApp
stateCustomImage
stateName
stateDeploying
stateRemovePick
stateRemoveConfirm
stateRemoving
stateDone
stateError
)
// op tracks which recipe produced a terminal Done event, so viewDone can word
// itself correctly.
type op int
const (
opAdd op = iota
opRemove
)
// maxLogLines caps the streamed output kept on screen.
const maxLogLines = 12
type model struct {
state state
op op
menuCursor int // 0 = add, 1 = remove
// add path
apps []config.App
cursor int // index into apps; len(apps) == the "Custom image…" row
customInput textinput.Model
nameInput textinput.Model
fromCustom bool
image string
name string
nameErr error
// remove path
removeHosts []string
removeCursor int
removeTarget string
removeData bool
// shared
spinner spinner.Model
logLines []string
events <-chan deploy.Event
cancel context.CancelFunc
resultURL string // add: the reachable URL
doneMsg string // remove: the done line
err error
}
func newBase() model {
custom := textinput.New()
custom.Placeholder = "ghcr.io/org/image:tag"
custom.CharLimit = 256
name := textinput.New()
name.Placeholder = "book"
name.CharLimit = 63
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = okStyle
return model{state: stateMenu, customInput: custom, nameInput: name, spinner: sp}
}
// New builds the wizard starting at the Add/Remove menu.
func New() model { return newBase() }
// NewRemove builds the wizard jumping straight to the remove picker (for
// `once-add remove` with no name).
func NewRemove() model {
m := newBase()
return m.loadRemove()
}
func (m model) Init() tea.Cmd { return nil }
// --- streaming plumbing -----------------------------------------------------
type eventMsg deploy.Event
func waitEvent(ch <-chan deploy.Event) tea.Cmd {
return func() tea.Msg {
ev, ok := <-ch
if !ok {
return nil
}
return eventMsg(ev)
}
}
func (m *model) startDeploy() tea.Cmd {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.events = deploy.Run(ctx, m.name, m.image)
m.op = opAdd
m.state = stateDeploying
m.logLines = nil
return tea.Batch(m.spinner.Tick, waitEvent(m.events))
}
func (m *model) startRemove() tea.Cmd {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.events = deploy.RunRemove(ctx, m.removeTarget, m.removeData)
m.op = opRemove
m.state = stateRemoving
m.logLines = nil
return tea.Batch(m.spinner.Tick, waitEvent(m.events))
}
// loadRemove enumerates deployed .local hosts and enters the picker (or the
// error state if docker can't be reached).
func (m model) loadRemove() model {
hosts, err := deploy.DeployedHosts()
if err != nil {
m.err = fmt.Errorf("listing deployed apps: %w", err)
m.state = stateError
return m
}
m.removeHosts = hosts
m.removeCursor = 0
m.removeData = false
m.state = stateRemovePick
return m
}
func (m model) loadAdd() (model, tea.Cmd) {
apps, err := config.Load()
if err != nil {
m.err = fmt.Errorf("loading %s: %w", config.Path, err)
m.state = stateError
return m, nil
}
m.apps = apps
m.cursor = 0
m.state = stateSelectApp
return m, nil
}
func (m *model) quit() (tea.Model, tea.Cmd) {
if m.cancel != nil {
m.cancel()
}
return m, tea.Quit
}
// --- update -----------------------------------------------------------------
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case eventMsg:
return m.handleEvent(deploy.Event(msg))
case spinner.TickMsg:
if m.state != stateDeploying && m.state != stateRemoving {
return m, nil
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
return m.quit()
}
switch m.state {
case stateMenu:
return m.updateMenu(msg)
case stateSelectApp:
return m.updateSelect(msg)
case stateCustomImage:
return m.updateCustom(msg)
case stateName:
return m.updateName(msg)
case stateRemovePick:
return m.updateRemovePick(msg)
case stateRemoveConfirm:
return m.updateRemoveConfirm(msg)
case stateDeploying, stateRemoving:
return m, nil // in progress; ctrl+c handled above
case stateDone, stateError:
return m.quit()
}
}
return m, nil
}
func (m model) handleEvent(ev deploy.Event) (tea.Model, tea.Cmd) {
switch {
case ev.Err != nil:
m.err = ev.Err
m.state = stateError
return m, nil
case ev.Done:
if m.op == opAdd {
m.resultURL = ev.Line
} else {
m.doneMsg = ev.Line
}
m.state = stateDone
return m, nil
default:
m.logLines = append(m.logLines, ev.Line)
if len(m.logLines) > maxLogLines {
m.logLines = m.logLines[len(m.logLines)-maxLogLines:]
}
return m, waitEvent(m.events)
}
}
func (m model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
return m.quit()
case "up", "k":
m.menuCursor = 0
case "down", "j":
m.menuCursor = 1
case "enter":
if m.menuCursor == 0 {
return m.loadAdd()
}
return m.loadRemove(), nil
}
return m, nil
}
func (m model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
last := len(m.apps) // index of the "Custom image…" row
switch msg.String() {
case "q":
return m.quit()
case "esc":
m.state = stateMenu
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < last {
m.cursor++
}
case "enter":
if m.cursor == last {
m.fromCustom = true
m.customInput.SetValue("")
m.customInput.Focus()
m.state = stateCustomImage
return m, textinput.Blink
}
app := m.apps[m.cursor]
m.image = app.Image
m.fromCustom = false
return m.enterNameScreen(app.Suggested)
}
return m, nil
}
func (m model) updateCustom(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.state = stateSelectApp
return m, nil
case "enter":
img := strings.TrimSpace(m.customInput.Value())
if img == "" {
return m, nil
}
m.image = img
return m.enterNameScreen("")
}
var cmd tea.Cmd
m.customInput, cmd = m.customInput.Update(msg)
return m, cmd
}
func (m model) enterNameScreen(suggested string) (tea.Model, tea.Cmd) {
m.nameInput.SetValue(suggested)
m.nameInput.CursorEnd()
m.nameInput.Focus()
m.nameErr = nil
m.state = stateName
return m, textinput.Blink
}
func (m model) updateName(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
if m.fromCustom {
m.customInput.Focus()
m.state = stateCustomImage
return m, textinput.Blink
}
m.state = stateSelectApp
return m, nil
case "enter":
name := strings.TrimSpace(m.nameInput.Value())
if err := deploy.ValidateName(name); err != nil {
m.nameErr = err
return m, nil
}
if hosts, err := deploy.InUseHosts(); err == nil && hosts[deploy.Host(name)] {
m.nameErr = fmt.Errorf("%s is already in use — pick another", deploy.Host(name))
return m, nil
}
m.name = name
return m, m.startDeploy()
}
var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg)
if v := strings.TrimSpace(m.nameInput.Value()); v != "" {
m.nameErr = deploy.ValidateName(v)
} else {
m.nameErr = nil
}
return m, cmd
}
func (m model) updateRemovePick(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
return m.quit()
case "esc":
m.state = stateMenu
return m, nil
case "up", "k":
if m.removeCursor > 0 {
m.removeCursor--
}
case "down", "j":
if m.removeCursor < len(m.removeHosts)-1 {
m.removeCursor++
}
case "enter":
if len(m.removeHosts) == 0 {
return m, nil
}
m.removeTarget = m.removeHosts[m.removeCursor]
m.removeData = false
m.state = stateRemoveConfirm
}
return m, nil
}
func (m model) updateRemoveConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "n":
m.state = stateRemovePick
return m, nil
case " ":
m.removeData = !m.removeData
return m, nil
case "enter", "y":
return m, m.startRemove()
}
return m, nil
}
// --- view -------------------------------------------------------------------
func (m model) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("once-add") + descStyle.Render(" — manage LAN Once sites (<name>.local)") + "\n\n")
switch m.state {
case stateMenu:
b.WriteString(m.viewMenu())
case stateSelectApp:
b.WriteString(m.viewSelect())
case stateCustomImage:
b.WriteString(m.viewCustom())
case stateName:
b.WriteString(m.viewName())
case stateRemovePick:
b.WriteString(m.viewRemovePick())
case stateRemoveConfirm:
b.WriteString(m.viewRemoveConfirm())
case stateDeploying:
b.WriteString(m.viewProgress("Deploying " + deploy.Host(m.name)))
case stateRemoving:
b.WriteString(m.viewProgress("Removing " + m.removeTarget))
case stateDone:
b.WriteString(m.viewDone())
case stateError:
b.WriteString(m.viewError())
}
return b.String()
}
func (m model) viewMenu() string {
var b strings.Builder
b.WriteString("What do you want to do?\n\n")
b.WriteString(m.menuRow(0, "Add a site", "deploy an app as <name>.local"))
b.WriteString(m.menuRow(1, "Remove a site", "tear down a deployed app + its hosts entry"))
b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · q quit"))
return b.String()
}
func (m model) menuRow(i int, name, desc string) string {
cursor, label := " ", name
if m.menuCursor == i {
cursor = selectedStyle.Render("➜ ")
label = selectedStyle.Render(name)
}
return fmt.Sprintf("%s%s %s\n", cursor, label, descStyle.Render(desc))
}
func (m model) viewSelect() string {
var b strings.Builder
b.WriteString("Which app do you want to add?\n\n")
for i, app := range m.apps {
b.WriteString(m.row(i, app.Name, app.Description))
}
b.WriteString(m.row(len(m.apps), "Custom image…", "deploy any container image"))
b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · esc back · q quit"))
return b.String()
}
func (m model) row(i int, name, desc string) string {
cursor, label := " ", name
if m.cursor == i {
cursor = selectedStyle.Render("➜ ")
label = selectedStyle.Render(name)
}
return fmt.Sprintf("%s%s %s\n", cursor, label, descStyle.Render(desc))
}
func (m model) viewCustom() string {
var b strings.Builder
b.WriteString("Container image to deploy:\n\n")
b.WriteString(" " + m.customInput.View() + "\n\n")
b.WriteString(helpStyle.Render("enter continue · esc back · ctrl+c quit"))
return b.String()
}
func (m model) viewName() string {
var b strings.Builder
b.WriteString("Name for this site (single label):\n\n")
b.WriteString(" " + m.nameInput.View() + "\n")
if name := strings.TrimSpace(m.nameInput.Value()); name != "" && m.nameErr == nil {
b.WriteString(" " + okStyle.Render("→ http://"+deploy.Host(name)) + "\n")
}
if m.nameErr != nil {
b.WriteString(" " + errStyle.Render("✗ "+m.nameErr.Error()) + "\n")
}
b.WriteString("\n" + descStyle.Render("image: "+m.image) + "\n")
b.WriteString(helpStyle.Render("enter deploy · esc back · ctrl+c quit"))
return b.String()
}
func (m model) viewRemovePick() string {
var b strings.Builder
if len(m.removeHosts) == 0 {
b.WriteString(descStyle.Render("No deployed .local apps to remove.") + "\n\n")
b.WriteString(helpStyle.Render("esc back · q quit"))
return b.String()
}
b.WriteString("Which site do you want to remove?\n\n")
for i, h := range m.removeHosts {
cursor, label := " ", h
if m.removeCursor == i {
cursor = selectedStyle.Render("➜ ")
label = selectedStyle.Render(h)
}
b.WriteString(fmt.Sprintf("%s%s\n", cursor, label))
}
b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · esc back · q quit"))
return b.String()
}
func (m model) viewRemoveConfirm() string {
var b strings.Builder
b.WriteString("Remove " + selectedStyle.Render(m.removeTarget) + "? This stops and deletes the app.\n\n")
box := "[ ]"
if m.removeData {
box = okStyle.Render("[x]")
}
b.WriteString(" " + box + " also delete its data volume " + errStyle.Render("(irreversible)") + "\n\n")
if m.removeData {
b.WriteString(errStyle.Render(" Data will be permanently deleted.") + "\n\n")
} else {
b.WriteString(hintStyle.Render(" Data is kept; re-deploying the same name restores it.") + "\n\n")
}
b.WriteString(helpStyle.Render("space toggle data · enter/y remove · esc/n back · ctrl+c quit"))
return b.String()
}
func (m model) viewProgress(title string) string {
var b strings.Builder
b.WriteString(m.spinner.View() + title + "\n\n")
for _, line := range m.logLines {
b.WriteString(descStyle.Render(" "+line) + "\n")
}
b.WriteString("\n" + helpStyle.Render("ctrl+c cancel"))
return b.String()
}
func (m model) viewDone() string {
var b strings.Builder
if m.op == opAdd {
b.WriteString(okStyle.Render("✓ Added.") + " Reachable at " + urlStyle.Render(m.resultURL) + "\n\n")
b.WriteString(hintStyle.Render("Published on the LAN within ~20s (once-mdns-sync).") + "\n")
b.WriteString(hintStyle.Render("Manage it in the Once web interface.") + "\n\n")
} else {
b.WriteString(okStyle.Render("✓ "+m.doneMsg) + "\n\n")
b.WriteString(hintStyle.Render("once-mdns-sync stops publishing it on the LAN automatically.") + "\n\n")
}
b.WriteString(helpStyle.Render("enter/q quit"))
return b.String()
}
func (m model) viewError() string {
var b strings.Builder
b.WriteString(errStyle.Render("✗ "+m.err.Error()) + "\n\n")
b.WriteString(hintStyle.Render("Inspect: journalctl -u once-mdns-sync -f") + "\n")
b.WriteString(hintStyle.Render(" docker logs once-proxy") + "\n\n")
b.WriteString(helpStyle.Render("enter/q quit"))
return b.String()
}