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:
commit
68c64265a6
12 changed files with 1496 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/once-add
|
||||||
|
/dist/
|
||||||
|
*.iso
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
73
README.md
Normal 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
150
cmd/once-add/main.go
Normal 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
34
go.mod
Normal 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
54
go.sum
Normal 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
64
internal/config/config.go
Normal 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
356
internal/deploy/deploy.go
Normal 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
|
||||||
|
}
|
||||||
94
internal/deploy/deploy_test.go
Normal file
94
internal/deploy/deploy_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/tui/render_test.go
Normal file
76
internal/tui/render_test.go
Normal 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
22
internal/tui/styles.go
Normal 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
549
internal/tui/tui.go
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue