From 68c64265a62865385a58c58b51949ede69f48ef9 Mon Sep 17 00:00:00 2001 From: 28allday Date: Thu, 28 May 2026 07:23:07 +0100 Subject: [PATCH] once-add: deploy + remove Once apps at .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 `.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. --- .gitignore | 3 + LICENSE | 21 ++ README.md | 73 +++++ cmd/once-add/main.go | 150 +++++++++ go.mod | 34 ++ go.sum | 54 ++++ internal/config/config.go | 64 ++++ internal/deploy/deploy.go | 356 +++++++++++++++++++++ internal/deploy/deploy_test.go | 94 ++++++ internal/tui/render_test.go | 76 +++++ internal/tui/styles.go | 22 ++ internal/tui/tui.go | 549 +++++++++++++++++++++++++++++++++ 12 files changed, 1496 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/once-add/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/deploy/deploy.go create mode 100644 internal/deploy/deploy_test.go create mode 100644 internal/tui/render_test.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/tui.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b35b32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/once-add +/dist/ +*.iso diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5acd172 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3941d33 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# once-add + +Deploy and remove a [Once](https://once.com) app at a single-label `.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 .local" | sudo tee -a /etc/hosts # so Once's verify resolves it +once deploy --host .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 `.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://.local`. + +**Remove** runs `once remove ` 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. diff --git a/cmd/once-add/main.go b/cmd/once-add/main.go new file mode 100644 index 0000000..995a4fb --- /dev/null +++ b/cmd/once-add/main.go @@ -0,0 +1,150 @@ +// Command once-add manages LAN-reachable Once sites served as single-label +// .local hosts. +// +// once-add interactive wizard (Add / Remove menu) +// once-add add (non-interactive) +// once-add remove [--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 (.local) + + once-add interactive wizard (Add / Remove) + once-add add, e.g. once-add book ghcr.io/basecamp/writebook + once-add remove [--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) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..58282ec --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9a42145 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3450fe5 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go new file mode 100644 index 0000000..fad92fb --- /dev/null +++ b/internal/deploy/deploy.go @@ -0,0 +1,356 @@ +// Package deploy implements the once-add recipe for adding a LAN-reachable +// Once site as a single-label .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 .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 ` 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:///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 [--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 +} diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go new file mode 100644 index 0000000..2cf7720 --- /dev/null +++ b/internal/deploy/deploy_test.go @@ -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) + } + } +} diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go new file mode 100644 index 0000000..d93c106 --- /dev/null +++ b/internal/tui/render_test.go @@ -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) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..5c80168 --- /dev/null +++ b/internal/tui/styles.go @@ -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) +) diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..777c662 --- /dev/null +++ b/internal/tui/tui.go @@ -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 (.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 .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() +}