diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..669883d --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,102 @@ +name: Package +on: + push: + branches: [main] + paths: &paths + - Makefile + - Dockerfile + - go.mod + - go.sum + - config/logwisp.toml + - doc/lw.1 + - deploy/package/** + - .github/workflows/package.yml + pull_request: + paths: *paths + workflow_dispatch: +permissions: + contents: read +jobs: + # The AUR package from this commit: makepkg finds the tarball the PKGBUILD + # names next to it and skips the download, then builds, tests and packages. + # checkout's safe.directory lasts only its own step; root owns .git here. + arch: + runs-on: ubuntu-latest + timeout-minutes: 20 + container: archlinux:base-devel + steps: + - run: pacman -Syu --noconfirm --needed git go + - uses: actions/checkout@v5 + - run: | + ver=$(sed -n 's/^pkgver=//p' deploy/package/arch/PKGBUILD) + dir=$(mktemp -d) + git -c safe.directory="$PWD" archive --prefix="logwisp-$ver/" -o "$dir/logwisp-$ver.tar.gz" HEAD + cp deploy/package/arch/PKGBUILD "$dir" + useradd -m build + chown -R build "$dir" + su build -c "cd '$dir' && makepkg --noconfirm" + pacman -U --noconfirm "$dir"/logwisp-*.pkg.tar.zst + - run: | + lw --version + test -f /usr/share/bash-completion/completions/lw + test -f /usr/share/zsh/site-functions/_lw + test -f /usr/share/fish/vendor_completions.d/lw.fish + pacman -R --noconfirm logwisp + test ! -e /usr/bin/lw + # make deb on a host with systemd: the service runs from the package's + # configuration; remove stops and disables it and leaves the conffile, purge + # removes that too + deb: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: make deb + - run: | + sudo apt-get install -y ./bin/logwisp_*.deb + lw --version + test -f /usr/share/zsh/vendor-completions/_lw + sudo systemctl enable --now logwisp + sleep 3 + systemctl is-active logwisp || { sudo journalctl -u logwisp --no-pager; exit 1; } + sudo apt-get remove -y logwisp + test ! -e /usr/bin/lw + test ! -e /usr/lib/systemd/system/logwisp.service + test ! -e /etc/systemd/system/multi-user.target.wants/logwisp.service + if systemctl is-active --quiet logwisp; then exit 1; fi + test -f /etc/logwisp/logwisp.toml + sudo apt-get purge -y logwisp + test ! -e /etc/logwisp/logwisp.toml + # BSD make on FreeBSD: build, install, run the rc.d service, uninstall + freebsd: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 + with: + release: "15.1" + copyback: false + prepare: pkg install -y go127 + run: | + set -e + make release + make install + lw --version + pw groupadd logwisp + pw useradd logwisp -g logwisp -d /nonexistent -s /usr/sbin/nologin + service logwisp onestart + sleep 3 + service logwisp onestatus + service logwisp onestop + make uninstall + test ! -e /usr/local/bin/lw + image: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - run: make image image-check diff --git a/Makefile b/Makefile index 9742d41..e9898fb 100644 --- a/Makefile +++ b/Makefile @@ -56,10 +56,17 @@ BINDIR ?= $(PREFIX)/bin MANDIR ?= $(PREFIX)/share/man DOCDIR ?= $(PREFIX)/share/doc/logwisp LICENSEDIR ?= $(PREFIX)/share/licenses/logwisp +BASHCOMPDIR ?= $(PREFIX)/share/bash-completion/completions +# Debian's zsh reads /usr/share/zsh/vendor-completions, not site-functions +ZSHCOMPDIR_DEFAULT != [ '$(PREFIX)' = /usr ] && [ -f /etc/debian_version ] && \ + echo /usr/share/zsh/vendor-completions || echo '$(PREFIX)/share/zsh/site-functions' +ZSHCOMPDIR ?= $(ZSHCOMPDIR_DEFAULT) +FISHCOMPDIR ?= $(PREFIX)/share/fish/vendor_completions.d +DEB_MAINTAINER ?= Maintainer .DEFAULT_GOAL := help -.PHONY: help check-go build release dev test verify e2e image image-check install uninstall clean version +.PHONY: help check-go build release dev test verify e2e image image-check install uninstall deb completion clean version help: @echo "Usage: make [VARIABLE=value ...]" @@ -69,6 +76,7 @@ help: @echo " release Build it static, stripped and trimmed (CGO_ENABLED=0)" @echo " dev Build it with the race detector" @echo " version Print the version metadata a build embeds" + @echo " completion Regenerate $(PKG_DIR)/completion from lw's command tables" @echo " clean Remove $(BIN_DIR)/" @echo "Check:" @echo " test Run the Go tests" @@ -79,13 +87,16 @@ help: @echo " image-check Run it read-only, offline, without capabilities: --version, then" @echo " --check on $(IMAGE_CHECK_CONFIG)" @echo "Install (build or release first):" - @echo " install Binary, manual, sample config, service files, licence and docs" + @echo " install Binary, manual, sample config, service files, shell completion," + @echo " licence and docs" @echo " uninstall Remove them, keeping $(SYSCONFDIR)/logwisp" + @echo " deb Package a release build as $(BIN_DIR)/logwisp_VERSION_ARCH.deb (dpkg-deb)" @echo "" @echo "Variables: GO=$(GO) PREFIX=$(PREFIX) SYSCONFDIR=$(SYSCONFDIR) DESTDIR=$(DESTDIR)" @echo " INSTALL_OS=$(INSTALL_OS) (the layout install uses: FreeBSD or any other)" @echo " GO_BUILDFLAGS, GO_LDFLAGS (extra go build and linker flags), E2E (scripts)," - @echo " CONTAINER_ENGINE, IMAGE, IMAGE_TAG, BUILD_CA, IMAGE_BUILD_FLAGS, IMAGE_CHECK_CONFIG" + @echo " CONTAINER_ENGINE, IMAGE, IMAGE_TAG, BUILD_CA, IMAGE_BUILD_FLAGS, IMAGE_CHECK_CONFIG," + @echo " BASHCOMPDIR, ZSHCOMPDIR, FISHCOMPDIR, DEB_MAINTAINER" check-go: @command -v $(GO) >/dev/null 2>&1 || { \ @@ -108,6 +119,10 @@ dev: check-go test: check-go $(GO) test ./... +# A test fails when the committed scripts differ from what the tables generate +completion: check-go + $(GO) test ./cmd/lw -run '^TestCompletionScriptsAreCurrent$$' -args -update + # gofmt checks the Go files changed since the branch forked from GOFMT_BASE, # exact renames aside, or the whole tree without git or that ref: older files # predate the gate, and AGENTS.md formats what a change touches. @@ -189,6 +204,9 @@ install: root='$(DESTDIR)'; etc="$$root$(SYSCONFDIR)/logwisp"; \ put 0755 $(BIN_DIR)/$(BINARY) "$$root$(BINDIR)/$(BINARY)"; \ put 0644 doc/lw.1 "$$root$(MANDIR)/man1/lw.1"; \ + put 0644 $(PKG_DIR)/completion/lw.bash "$$root$(BASHCOMPDIR)/lw"; \ + put 0644 $(PKG_DIR)/completion/_lw "$$root$(ZSHCOMPDIR)/_lw"; \ + put 0644 $(PKG_DIR)/completion/lw.fish "$$root$(FISHCOMPDIR)/lw.fish"; \ put 0644 LICENSE "$$root$(LICENSEDIR)/LICENSE"; \ for src in README.md doc/*.md; do put 0644 "$$src" "$$root$(DOCDIR)/$$src"; done; \ case '$(INSTALL_OS)' in \ @@ -207,6 +225,7 @@ install: uninstall: @set -eu; root='$(DESTDIR)'; \ for f in "$$root$(BINDIR)/$(BINARY)" "$$root$(MANDIR)/man1/lw.1" \ + "$$root$(BASHCOMPDIR)/lw" "$$root$(ZSHCOMPDIR)/_lw" "$$root$(FISHCOMPDIR)/lw.fish" \ "$$root$(PREFIX)/lib/systemd/system/logwisp.service" \ "$$root$(PREFIX)/lib/sysusers.d/logwisp.conf" "$$root$(PREFIX)/lib/tmpfiles.d/logwisp.conf" \ "$$root$(PREFIX)/etc/rc.d/logwisp" "$$root$(SYSCONFDIR)/logwisp/logwisp.toml.sample"; do \ @@ -218,6 +237,37 @@ uninstall: done; \ echo "keep $$root$(SYSCONFDIR)/logwisp (configuration and credentials)" +# deb stages install in Debian's layout and takes the architecture from the +# binary. An untagged or modified tree's version sorts below every release and +# by build time, so each build upgrades the last. +deb: release + @set -eu; stage=$(BIN_DIR)/deb; doc="$$stage/usr/share/doc/logwisp"; rm -rf "$$stage"; \ + $(MAKE) install DESTDIR="$$stage" PREFIX=/usr SYSCONFDIR=/etc INSTALL_OS=Linux \ + LICENSEDIR=/usr/share/doc/logwisp ZSHCOMPDIR=/usr/share/zsh/vendor-completions >/dev/null; \ + meta=$$($(GO) version -m $(BIN_DIR)/$(BINARY)); \ + case $$meta in *GOOS=linux*) ;; *) echo "$(BIN_DIR)/$(BINARY) is not a Linux build" >&2; exit 1 ;; esac; \ + arch=$$(echo "$$meta" | sed -n 's/^[[:space:]]*build[[:space:]]*GOARCH=//p'); \ + case $$arch in 386) arch=i386 ;; arm) arch=armhf ;; ppc64le) arch=ppc64el ;; \ + mipsle) arch=mipsel ;; mips64le) arch=mips64el ;; esac; \ + stamp=$$(echo '$(BUILD_TIME)' | tr -dc 0-9); \ + version=$$(echo '$(VERSION)' | sed -e 's/^v//' -e 's/-dirty$$/+dirty/' \ + -e 's/-\([0-9][0-9]*\)-g/+\1.g/' -e 's/-/~/g'); \ + case $$version in \ + [0-9]*.*+dirty) version="$$version.$$stamp" ;; \ + [0-9]*.*) ;; \ + *) version="0~$$stamp.$$version" ;; \ + esac; \ + mv "$$doc/LICENSE" "$$doc/copyright"; gzip -9n "$$stage/usr/share/man/man1/lw.1"; \ + install -d -m 0755 "$$stage/DEBIAN"; \ + for f in postinst prerm postrm; do install -m 0755 $(PKG_DIR)/deb/$$f "$$stage/DEBIAN/$$f"; done; \ + echo /etc/logwisp/logwisp.toml > "$$stage/DEBIAN/conffiles"; \ + (cd "$$stage" && find usr -type f -exec md5sum {} + | LC_ALL=C sort -k 2) > "$$stage/DEBIAN/md5sums"; \ + size=$$(du -sk --apparent-size "$$stage/usr" "$$stage/etc" | awk '{ s += $$1 } END { print s }'); \ + { printf 'Package: logwisp\nVersion: %s\nArchitecture: %s\nMaintainer: %s\nInstalled-Size: %s\n' \ + "$$version" "$$arch" "$(DEB_MAINTAINER)" "$$size"; cat $(PKG_DIR)/deb/control; \ + sed -e 's/^$$/./' -e 's/^/ /' $(PKG_DIR)/freebsd/pkg-descr; } > "$$stage/DEBIAN/control"; \ + dpkg-deb --root-owner-group --build "$$stage" "$(BIN_DIR)/logwisp_$${version}_$$arch.deb" + clean: rm -rf $(BIN_DIR) diff --git a/README.md b/README.md index f2cc115..d2f476d 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ authentication designs for the rationale and what is deliberately left out. exit codes, `lw auth`; also the `lw(1)` manual, [`doc/lw.1`](doc/lw.1) - [Operations](doc/operations.md): running, monitoring, tuning, troubleshooting -- [To Do](doc/todo.md): planned work: packaging +- [To Do](doc/todo.md): planned work: packaging, `lw --tui` and its shared configuration engine A fully annotated configuration covering every option ships as [`config/logwisp.toml`](config/logwisp.toml). @@ -185,9 +185,9 @@ docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ -v /etc/logwisp:/etc/logwisp:ro logwisp:dev -c /etc/logwisp/logwisp.toml ``` -`sudo make install` installs the binary, the manual, a sample configuration -and a systemd unit or FreeBSD rc.d script; see -[Installation](doc/installation.md). +`sudo make install` installs the binary, the manual, shell completion, a +sample configuration and a systemd unit or FreeBSD rc.d script; `make deb` +packages them for Debian and Ubuntu. See [Installation](doc/installation.md). ## System Requirements diff --git a/cmd/lw/cli_test.go b/cmd/lw/cli_test.go index 294f66b..aff70b3 100644 --- a/cmd/lw/cli_test.go +++ b/cmd/lw/cli_test.go @@ -136,18 +136,7 @@ func TestHelpAndManualListEveryOption(t *testing.T) { } var help bytes.Buffer printHelp(&help) - options := map[string]string{} // long -> its short, if lw takes one - for _, long := range config.SpecFlags() { - options[long] = "" - } - for long := range config.Settings() { - options[long] = "" - } - for letter, short := range shorts { - if short.top { - options[short.long] = letter - } - } + options := options() // mdoc's Fl writes the first dash, and the others as \- fl := func(flag string) string { return regexp.QuoteMeta("Fl " + strings.ReplaceAll(flag[1:], "-", `\-`)) } for long, letter := range options { @@ -271,3 +260,26 @@ func TestTLSCommandsIssueAVerifiableChain(t *testing.T) { t.Fatalf("second ca: exit %d: %s", code, stderr) } } + +var update = flag.Bool("update", false, "rewrite the completion scripts: make completion") + +// The installed completion scripts are what lw's tables generate +func TestCompletionScriptsAreCurrent(t *testing.T) { + for _, sh := range shells { + script := sh.script() + path := filepath.Join("../../deploy/package/completion", sh.file) + if *update { + if err := os.WriteFile(path, script, 0o644); err != nil { + t.Fatal(err) + } + continue + } + have, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(have, script) { + t.Errorf("%s is not what lw's tables generate: run make completion", path) + } + } +} diff --git a/cmd/lw/completion.go b/cmd/lw/completion.go new file mode 100644 index 0000000..f499862 --- /dev/null +++ b/cmd/lw/completion.go @@ -0,0 +1,321 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "io" + "maps" + "slices" + "strings" + + "github.com/lixenwraith/logwisp/internal/config" +) + +// compFlag is a flag as a shell completes it: value is its row in a shell's +// values, "" for a switch; repeat lets a shell offer it again. +type compFlag struct { + long, short, value, help string + repeat bool +} + +type compCommand struct { + name, summary string + subs []compSub +} + +type compSub struct { + name, summary string + flags []compFlag +} + +// shell is a completion script make completion writes under +// deploy/package/completion: values completes a flag's value by its kind, +// "text" as the shell would without lw's script, PRESETS the preset names. +type shell struct { + file string + values map[string]string + write func(w io.Writer, top []compFlag, cmds []compCommand, values map[string]string) +} + +var shells = []shell{ + {"lw.bash", map[string]string{ + "text": "COMPREPLY=()", + "dir": "_lw_reply -d", + "preset": "mapfile -t COMPREPLY < <(compgen -W 'PRESETS' -- \"$cur\")", + }, writeBash}, + {"_lw", map[string]string{ + "text": ":value:_default", + "dir": ":directory:_files -/", + "preset": ":preset:(PRESETS)", + }, writeZsh}, + {"lw.fish", map[string]string{ + "text": "-r -F", + "dir": "-x -a '(__fish_complete_directories)'", + "preset": "-x -a 'PRESETS'", + }, writeFish}, +} + +// valueOf is the kind of value an option of lw's own takes when it is not +// text: "" for none, as a bare --color means always. +var valueOf = map[string]string{"preset": "preset", "logging.file.directory": "dir", "color": "", "help": ""} + +const generated = "Generated by make completion from lw's own tables: edit cmd/lw, not this file." + +// script is the shell's completion for lw as its tables stand +func (s shell) script() []byte { + top, cmds, presets := completionModel() + values := map[string]string{} + for kind, action := range s.values { + values[kind] = strings.ReplaceAll(action, "PRESETS", presets) + } + var b bytes.Buffer + s.write(&b, top, cmds, values) + return b.Bytes() +} + +// options maps each long option lw itself takes to its short letter, if any +func options() map[string]string { + options := map[string]string{} + for _, long := range config.SpecFlags() { + options[long] = "" + } + for long := range config.Settings() { + options[long] = "" + } + for letter, short := range shorts { + if short.top { + options[short.long] = letter + } + } + return options +} + +// completionModel reads the parser's tables: lw's options, then each +// command's subcommands with the flags their define registers. +func completionModel() (top []compFlag, cmds []compCommand, presets string) { + settings, specFlags, shortOf := config.Settings(), config.SpecFlags(), options() + for _, long := range slices.Sorted(maps.Keys(shortOf)) { + f := compFlag{long: long, short: shortOf[long], value: "text", repeat: slices.Contains(specFlags, long)} + if v, ok := valueOf[long]; ok { + f.value = v + } else if settings[long] { + f.value = "" + } + top = append(top, f) + } + for _, c := range commands { + cmd := compCommand{name: c.name, summary: c.summary} + for _, s := range c.subcommands { + fs := flag.NewFlagSet(s.name, flag.ContinueOnError) + s.define(fs) + sub := compSub{name: s.name, summary: s.summary} + fs.VisitAll(func(f *flag.Flag) { + if f.Usage == "" { + return // an alias, unlisted like a short + } + arg, help := flag.UnquoteUsage(f) + value := arg // "" for a switch; a backquoted `dir` names its row + if _, known := shells[0].values[arg]; arg != "" && !known { + value = "text" + } + sub.flags = append(sub.flags, compFlag{long: f.Name, short: shortFor(f.Name), value: value, help: help}) + }) + // flag answers -h and --help itself + sub.flags = append(sub.flags, compFlag{long: "help", short: shortFor("help"), help: "print the flags"}) + cmd.subs = append(cmd.subs, sub) + } + cmds = append(cmds, cmd) + } + var names []string + for _, p := range config.Presets() { + names = append(names, p.Name) + } + return top, cmds, strings.Join(names, " ") +} + +func shortFor(long string) string { + for letter, short := range shorts { + if short.long == long { + return letter + } + } + return "" +} + +// words lists the flags as typed: --long, then -s for a short +func words(flags []compFlag, dashes ...string) string { + var w []string + for _, f := range flags { + for _, d := range dashes { + w = append(w, d+f.long) + } + if f.short != "" { + w = append(w, "-"+f.short) + } + } + return strings.Join(w, " ") +} + +// bashValues is a case statement completing each valued flag's argument +func bashValues(w io.Writer, indent string, flags []compFlag, values map[string]string, dashes ...string) { + byValue := map[string][]string{} + for _, f := range flags { + if f.value != "" { + byValue[f.value] = append(byValue[f.value], strings.Fields(words([]compFlag{f}, dashes...))...) + } + } + fmt.Fprintf(w, "%scase $prev in\n", indent) + for _, v := range slices.Sorted(maps.Keys(byValue)) { + fmt.Fprintf(w, "%s%s) %s; return ;;\n", indent, strings.Join(byValue[v], "|"), values[v]) + } + fmt.Fprintf(w, "%sesac\n", indent) +} + +func writeBash(w io.Writer, top []compFlag, cmds []compCommand, values map[string]string) { + var names []string + for _, c := range cmds { + names = append(names, c.name) + } + fmt.Fprintf(w, "# bash completion for lw. %s\n\n", generated) + fmt.Fprintf(w, "_lw_reply() { compopt -o filenames 2>/dev/null; mapfile -t COMPREPLY < <(compgen \"$@\" -- \"$cur\"); }\n\n") + fmt.Fprintf(w, "_lw() {\n") + fmt.Fprintf(w, "\tlocal cur=${COMP_WORDS[COMP_CWORD]} prev='' i=$COMP_CWORD words\n") + fmt.Fprintf(w, "\t((i > 0)) && prev=${COMP_WORDS[i-1]}\n") + fmt.Fprintf(w, "\t# bash splits --key=value at '=': complete the value of the key before it\n") + fmt.Fprintf(w, "\tif [[ $cur == = ]]; then cur=''\n\telif [[ $prev == = ]]; then prev=${COMP_WORDS[i-2]}\n\tfi\n") + fmt.Fprintf(w, "\tcase ${COMP_WORDS[1]}:$i in\n") + fmt.Fprintf(w, "\t*:1) words='%s %s help' ;;\n", words(top, "--"), strings.Join(names, " ")) + for _, c := range cmds { + var subs []string + for _, s := range c.subs { + subs = append(subs, s.name) + } + fmt.Fprintf(w, "\t%s:2) words='%s' ;;\n", c.name, strings.Join(subs, " ")) + fmt.Fprintf(w, "\t%s:*)\n\t\tcase ${COMP_WORDS[2]} in\n", c.name) + for _, s := range c.subs { + fmt.Fprintf(w, "\t\t%s)\n", s.name) + bashValues(w, "\t\t\t", s.flags, values, "--", "-") + fmt.Fprintf(w, "\t\t\twords='%s' ;;\n", words(s.flags, "--")) + } + fmt.Fprintf(w, "\t\tesac ;;\n") + } + fmt.Fprintf(w, "\t*)\n") + bashValues(w, "\t\t", top, values, "--") + fmt.Fprintf(w, "\t\twords='%s' ;;\n", words(top, "--")) + fmt.Fprintf(w, "\tesac\n") + fmt.Fprintf(w, "\tmapfile -t COMPREPLY < <(compgen -W \"$words\" -- \"$cur\")\n}\n\n") + fmt.Fprintf(w, "# An empty reply falls back to file names, as without this script\n") + fmt.Fprintf(w, "complete -o bashdefault -o default -F _lw lw\n") +} + +// zshQuote makes help text safe inside a single-quoted _arguments spec +func zshQuote(s string) string { + return strings.NewReplacer("'", `'\''`, "]", `\]`).Replace(s) +} + +// zshSpecs are _arguments specs for flags; a value follows '=' or as the next +// word, never attached to a short, the forms lw parses. +func zshSpecs(flags []compFlag, values map[string]string) []string { + var specs []string + for _, f := range flags { + help, action, eq := "", "", "" + if f.help != "" { + help = "[" + zshQuote(f.help) + "]" + } + if f.value != "" { + action, eq = values[f.value], "=" + } + names, repeat := fmt.Sprintf("'(-%s --%s)'{-%s%s,--%s%s}", f.short, f.long, f.short, eq, f.long, eq), "" + if f.repeat { + names, repeat = fmt.Sprintf("'*'{-%s%s,--%s%s}", f.short, eq, f.long, eq), "*" + } + switch tail := help + action; { + case f.short == "": + specs = append(specs, fmt.Sprintf("'%s--%s%s%s'", repeat, f.long, eq, tail)) + case tail == "": + specs = append(specs, names) + default: + specs = append(specs, names+"'"+tail+"'") + } + } + return specs +} + +func writeZsh(w io.Writer, top []compFlag, cmds []compCommand, values map[string]string) { + fmt.Fprintf(w, "#compdef lw\n# zsh completion for lw. %s\n\n", generated) + fmt.Fprintf(w, "local -a described\n") + fmt.Fprintf(w, "case ${words[2]}:$CURRENT in\n") + for _, c := range cmds { + fmt.Fprintf(w, "%s:2) ;;\n%s:3)\n\tdescribed=(\n", c.name, c.name) + for _, s := range c.subs { + fmt.Fprintf(w, "\t\t'%s:%s'\n", s.name, zshQuote(s.summary)) + } + fmt.Fprintf(w, "\t)\n\t_describe -t commands 'lw %s command' described\n\treturn ;;\n", c.name) + // _arguments reads words from the second: make that the subcommand's first flag + fmt.Fprintf(w, "%s:*)\n\tshift 2 words\n\t(( CURRENT -= 2 ))\n\tcase ${words[1]} in\n", c.name) + for _, s := range c.subs { + fmt.Fprintf(w, "\t%s) _arguments -S \\\n\t\t%s ;;\n", s.name, strings.Join(zshSpecs(s.flags, values), " \\\n\t\t")) + } + fmt.Fprintf(w, "\tesac\n\treturn ;;\n") + } + fmt.Fprintf(w, "esac\n") + fmt.Fprintf(w, "if (( CURRENT == 2 )) && [[ $PREFIX != -* ]]; then\n\tdescribed=(\n") + for _, c := range cmds { + fmt.Fprintf(w, "\t\t'%s:%s'\n", c.name, zshQuote(c.summary)) + } + fmt.Fprintf(w, "\t\t'help:Print the help and exit'\n\t)\n") + fmt.Fprintf(w, "\t_describe -t commands 'lw command' described\n\treturn\nfi\n") + fmt.Fprintf(w, "_arguments -S \\\n\t%s\n", strings.Join(zshSpecs(top, values), " \\\n\t")) +} + +// fishQuote makes text safe inside a single-quoted fish string +func fishQuote(s string) string { + return strings.NewReplacer(`\`, `\\`, "'", `\'`).Replace(s) +} + +// fishFlags completes a short as an old-style option (-o): lw takes no +// bundled shorts and no value attached to one. +func fishFlags(w io.Writer, cond string, flags []compFlag, values map[string]string) { + for _, f := range flags { + line := fmt.Sprintf("complete -c lw -n '%s' -l %s", cond, f.long) + if f.short != "" { + line += " -o " + f.short + } + if f.value != "" { + line += " " + values[f.value] + } + if f.help != "" { + line += " -d '" + fishQuote(f.help) + "'" + } + fmt.Fprintln(w, line) + } +} + +func writeFish(w io.Writer, top []compFlag, cmds []compCommand, values map[string]string) { + fmt.Fprintf(w, "# fish completion for lw. %s\n\n", generated) + fmt.Fprintf(w, "# __lw_in WORD...: the command line so far is lw WORD...\n") + fmt.Fprintf(w, "function __lw_in\n\tset -l w (commandline -opc)\n\tset -l n (math (count $argv) + 1)\n") + fmt.Fprintf(w, "\ttest (count $w) -ge $n; and test \"$w[2..$n]\" = \"$argv\"\nend\n\n") + fmt.Fprintf(w, "complete -c lw -f\n") + fmt.Fprintf(w, "set -l __lw_first 'test (count (commandline -opc)) -eq 1'\n") + for _, c := range cmds { + fmt.Fprintf(w, "complete -c lw -n $__lw_first -a %s -d '%s'\n", c.name, fishQuote(c.summary)) + } + fmt.Fprintf(w, "complete -c lw -n $__lw_first -a help -d 'Print the help and exit'\n") + var cond []string + for _, c := range cmds { + cond = append(cond, "__lw_in "+c.name) + } + fishFlags(w, "not "+strings.Join(cond, "; and not "), top, values) + for _, c := range cmds { + fmt.Fprintf(w, "\n") + for _, s := range c.subs { + fmt.Fprintf(w, "complete -c lw -n '__lw_in %s; and test (count (commandline -opc)) -eq 2' -a %s -d '%s'\n", + c.name, s.name, fishQuote(s.summary)) + } + for _, s := range c.subs { + fishFlags(w, "__lw_in "+c.name+" "+s.name, s.flags, values) + } + } +} diff --git a/deploy/package/completion/_lw b/deploy/package/completion/_lw new file mode 100644 index 0000000..0392ef9 --- /dev/null +++ b/deploy/package/completion/_lw @@ -0,0 +1,191 @@ +#compdef lw +# zsh completion for lw. Generated by make completion from lw's own tables: edit cmd/lw, not this file. + +local -a described +case ${words[2]}:$CURRENT in +auth:2) ;; +auth:3) + described=( + 'add-user:Add a user to a credentials file, or replace its password' + 'remove-user:Remove a user from a credentials file' + 'token:Log in to an http sink or http_chain source and print a bearer token' + 'stream:Log in to a tcp sink and copy its stream to stdout until interrupted' + ) + _describe -t commands 'lw auth command' described + return ;; +auth:*) + shift 2 words + (( CURRENT -= 2 )) + case ${words[1]} in + add-user) _arguments -S \ + '--credentials=[credentials file, created when missing]:value:_default' \ + '--generate[generate a new password, replacing any --password-file; needed to rotate without one]' \ + '--password-file=[read the password from file when it exists, else write a generated one there]:value:_default' \ + '(-u --user)'{-u=,--user=}'[user name]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + remove-user) _arguments -S \ + '--credentials=[credentials file]:value:_default' \ + '(-u --user)'{-u=,--user=}'[user name]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + token) _arguments -S \ + '--ca-file=[CA file that verifies the server (default: system roots)]:value:_default' \ + '--cert-file=[client certificate file, for listeners with tls.client_auth]:value:_default' \ + '--key-file=[client key file]:value:_default' \ + '--password-file=[file holding the password]:value:_default' \ + '--pin-sha256=[sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)]:value:_default' \ + '--server-name=[name the server certificate must carry (default: the host)]:value:_default' \ + '--unbound[log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)]' \ + '--url=[listener URL, https://HOST:PORT or https://[IPV6\]:PORT (with --unbound, a proxy'\''s mount URL may carry a path)]:value:_default' \ + '(-u --user)'{-u=,--user=}'[user name]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + stream) _arguments -S \ + '--addr=[tcp sink address, HOST:PORT or [IPV6\]:PORT]:value:_default' \ + '--ca-file=[CA file that verifies the server (default: system roots)]:value:_default' \ + '--cert-file=[client certificate file, for listeners with tls.client_auth]:value:_default' \ + '--key-file=[client key file]:value:_default' \ + '--password-file=[file holding the password]:value:_default' \ + '--pin-sha256=[sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)]:value:_default' \ + '--server-name=[name the server certificate must carry (default: the host)]:value:_default' \ + '(-u --user)'{-u=,--user=}'[user name]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + esac + return ;; +tls:2) ;; +tls:3) + described=( + 'ca:Create a certificate authority, DIR/ca.crt and DIR/ca.key, that signs certificates only' + 'cert:Issue a certificate and key, OUT/NAME.crt and OUT/NAME.key, from DIR/ca.crt and DIR/ca.key' + ) + _describe -t commands 'lw tls command' described + return ;; +tls:*) + shift 2 words + (( CURRENT -= 2 )) + case ${words[1]} in + ca) _arguments -S \ + '--days=[validity in n days]:value:_default' \ + '--dir=[directory to write ca.crt and ca.key in, created when missing]:directory:_files -/' \ + '--name=[subject common name]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + cert) _arguments -S \ + '--ca-dir=[directory holding ca.crt and ca.key (lw tls ca)]:directory:_files -/' \ + '--client[for dialers presenting a certificate (TLS client authentication)]' \ + '--days=[validity in n days, at most the CA'\''s]:value:_default' \ + '--hosts=[DNS names and IP addresses, '\'','\'' between them (default with --server: --name)]:value:_default' \ + '--name=[subject common name, the mtls identity, and the file names]:value:_default' \ + '--out=[directory to write in (default: --ca-dir)]:directory:_files -/' \ + '--server[for listeners (TLS server authentication)]' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + esac + return ;; +preset:2) ;; +preset:3) + described=( + 'pipe:Standard input to standard output, line for line: lw without a file' + 'tail:Follow files to standard output, like tail -F' + 'serve:Serve files or standard input live: a browser viewer at /, SSE at /stream' + 'edge:Forward files or standard input to an aggregator over TLS, authenticated' + 'aggregator:Receive from edges over TLS, authenticated, to files or standard output' + ) + _describe -t commands 'lw preset command' described + return ;; +preset:*) + shift 2 words + (( CURRENT -= 2 )) + case ${words[1]} in + pipe) _arguments -S \ + '--format=[raw|txt|json]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + tail) _arguments -S \ + '--format=[raw|txt|json]:value:_default' \ + '--from=[start|end: where reading a file starts]:value:_default' \ + '--path=[file, directory (its files) or glob to follow]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + serve) _arguments -S \ + '--allow=[addresses or CIDRs that may connect, of listen'\''s family (either behind proxy=); default: all; '\'','\'' between them]:value:_default' \ + '--cert=[certificate file, for tls=files]:value:_default' \ + '--deny=[addresses or CIDRs refused, allow or not; '\'','\'' between them]:value:_default' \ + '--format=[raw|txt|json]:value:_default' \ + '--from=[start|end: where reading a file starts]:value:_default' \ + '--hosts=[names and addresses, beyond this host'\''s, of a self or issuer certificate; '\'','\'' between them]:value:_default' \ + '--issuer_cert=[CA certificate file (lw tls ca), for tls=issuer]:value:_default' \ + '--issuer_key=[CA key file, for tls=issuer]:value:_default' \ + '--key=[key file, for tls=files]:value:_default' \ + '--listen=[HOST:PORT; [IPV6\]:PORT]:value:_default' \ + '--path=[file, directory (its files) or glob to follow; default: standard input]:value:_default' \ + '--proxy=[addresses or CIDRs of the TLS-terminating proxies browsers come through; '\'','\'' between them]:value:_default' \ + '--tls=[off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files]:value:_default' \ + '--users=[credentials file (lw auth add-user): readers log in with SCRAM]:value:_default' \ + '--viewer=[true: the login page and viewer for users, behind proxy; without users the viewer is always on]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + edge) _arguments -S \ + '--ca=[CA file that verifies the aggregator; default: system roots]:value:_default' \ + '--cert=[client certificate file, for an aggregator with client_ca]:value:_default' \ + '--from=[start|end: where reading a file starts]:value:_default' \ + '--key=[client key file]:value:_default' \ + '--node=[origin label; default: this host'\''s name]:value:_default' \ + '--password_file=[file holding the SCRAM password]:value:_default' \ + '--path=[file, directory (its files) or glob to follow; default: standard input]:value:_default' \ + '--pin=[sha256//BASE64 of the aggregator'\''s key, which it logs with tls=self]:value:_default' \ + '--server_name=[name the aggregator'\''s certificate carries; default: to'\''s host]:value:_default' \ + '--to=[the aggregator'\''s HOST:PORT; [IPV6\]:PORT]:value:_default' \ + '--transport=[tcp|http: tcp_chain or http_chain]:value:_default' \ + '(-u --user)'{-u=,--user=}'[SCRAM user (lw auth add-user on the aggregator)]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + aggregator) _arguments -S \ + '--allow=[addresses or CIDRs that may connect, of listen'\''s family (either behind proxy=); default: all; '\'','\'' between them]:value:_default' \ + '--cert=[certificate file, for tls=files]:value:_default' \ + '--client_ca=[CA file that verifies edge certificates (mTLS)]:value:_default' \ + '--deny=[addresses or CIDRs refused, allow or not; '\'','\'' between them]:value:_default' \ + '--format=[raw|txt|json]:value:_default' \ + '--hosts=[names and addresses, beyond this host'\''s, of a self or issuer certificate; '\'','\'' between them]:value:_default' \ + '--issuer_cert=[CA certificate file (lw tls ca), for tls=issuer]:value:_default' \ + '--issuer_key=[CA key file, for tls=issuer]:value:_default' \ + '--key=[key file, for tls=files]:value:_default' \ + '--listen=[HOST:PORT; [IPV6\]:PORT; [::\] for IPv6 only]:value:_default' \ + '--out=[directory for the received entries; default: standard output]:value:_default' \ + '--tls=[self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files]:value:_default' \ + '--transport=[tcp|http: tcp_chain or http_chain]:value:_default' \ + '--users=[credentials file (lw auth add-user): edges log in with SCRAM]:value:_default' \ + '(-h --help)'{-h,--help}'[print the flags]' ;; + esac + return ;; +esac +if (( CURRENT == 2 )) && [[ $PREFIX != -* ]]; then + described=( + 'auth:SCRAM users, bearer tokens, tcp sink viewing' + 'tls:A CA and certificates for TLS and mTLS' + 'preset:Show the pipeline a preset makes, as TOML' + 'help:Print the help and exit' + ) + _describe -t commands 'lw command' described + return +fi +_arguments -S \ + '--auto_reload' \ + '(-t --check)'{-t,--check} \ + '--color' \ + '(-c --config)'{-c=,--config=}':value:_default' \ + '--dump' \ + '*--filter=:value:_default' \ + '*--format=:value:_default' \ + '*--heartbeat=:value:_default' \ + '(-h --help)'{-h,--help} \ + '--logging.console.target=:value:_default' \ + '--logging.file.directory=:directory:_files -/' \ + '--logging.file.max_size_mb=:value:_default' \ + '--logging.file.max_total_size_mb=:value:_default' \ + '--logging.file.name=:value:_default' \ + '--logging.file.retention_hours=:value:_default' \ + '--logging.format=:value:_default' \ + '--logging.level=:value:_default' \ + '--logging.output=:value:_default' \ + '--logging.sanitization=:value:_default' \ + '*--pipeline=:value:_default' \ + '*'{-p=,--preset=}':preset:(pipe tail serve edge aggregator)' \ + '(-q --quiet)'{-q,--quiet} \ + '*--rate-limit=:value:_default' \ + '*--sink=:value:_default' \ + '*--source=:value:_default' \ + '--status_reporter' \ + '(-V --version)'{-V,--version} diff --git a/deploy/package/completion/lw.bash b/deploy/package/completion/lw.bash new file mode 100644 index 0000000..11339ec --- /dev/null +++ b/deploy/package/completion/lw.bash @@ -0,0 +1,95 @@ +# bash completion for lw. Generated by make completion from lw's own tables: edit cmd/lw, not this file. + +_lw_reply() { compopt -o filenames 2>/dev/null; mapfile -t COMPREPLY < <(compgen "$@" -- "$cur"); } + +_lw() { + local cur=${COMP_WORDS[COMP_CWORD]} prev='' i=$COMP_CWORD words + ((i > 0)) && prev=${COMP_WORDS[i-1]} + # bash splits --key=value at '=': complete the value of the key before it + if [[ $cur == = ]]; then cur='' + elif [[ $prev == = ]]; then prev=${COMP_WORDS[i-2]} + fi + case ${COMP_WORDS[1]}:$i in + *:1) words='--auto_reload --check -t --color --config -c --dump --filter --format --heartbeat --help -h --logging.console.target --logging.file.directory --logging.file.max_size_mb --logging.file.max_total_size_mb --logging.file.name --logging.file.retention_hours --logging.format --logging.level --logging.output --logging.sanitization --pipeline --preset -p --quiet -q --rate-limit --sink --source --status_reporter --version -V auth tls preset help' ;; + auth:2) words='add-user remove-user token stream' ;; + auth:*) + case ${COMP_WORDS[2]} in + add-user) + case $prev in + --credentials|-credentials|--password-file|-password-file|--user|-user|-u) COMPREPLY=(); return ;; + esac + words='--credentials --generate --password-file --user -u --help -h' ;; + remove-user) + case $prev in + --credentials|-credentials|--user|-user|-u) COMPREPLY=(); return ;; + esac + words='--credentials --user -u --help -h' ;; + token) + case $prev in + --ca-file|-ca-file|--cert-file|-cert-file|--key-file|-key-file|--password-file|-password-file|--pin-sha256|-pin-sha256|--server-name|-server-name|--url|-url|--user|-user|-u) COMPREPLY=(); return ;; + esac + words='--ca-file --cert-file --key-file --password-file --pin-sha256 --server-name --unbound --url --user -u --help -h' ;; + stream) + case $prev in + --addr|-addr|--ca-file|-ca-file|--cert-file|-cert-file|--key-file|-key-file|--password-file|-password-file|--pin-sha256|-pin-sha256|--server-name|-server-name|--user|-user|-u) COMPREPLY=(); return ;; + esac + words='--addr --ca-file --cert-file --key-file --password-file --pin-sha256 --server-name --user -u --help -h' ;; + esac ;; + tls:2) words='ca cert' ;; + tls:*) + case ${COMP_WORDS[2]} in + ca) + case $prev in + --dir|-dir) _lw_reply -d; return ;; + --days|-days|--name|-name) COMPREPLY=(); return ;; + esac + words='--days --dir --name --help -h' ;; + cert) + case $prev in + --ca-dir|-ca-dir|--out|-out) _lw_reply -d; return ;; + --days|-days|--hosts|-hosts|--name|-name) COMPREPLY=(); return ;; + esac + words='--ca-dir --client --days --hosts --name --out --server --help -h' ;; + esac ;; + preset:2) words='pipe tail serve edge aggregator' ;; + preset:*) + case ${COMP_WORDS[2]} in + pipe) + case $prev in + --format|-format) COMPREPLY=(); return ;; + esac + words='--format --help -h' ;; + tail) + case $prev in + --format|-format|--from|-from|--path|-path) COMPREPLY=(); return ;; + esac + words='--format --from --path --help -h' ;; + serve) + case $prev in + --allow|-allow|--cert|-cert|--deny|-deny|--format|-format|--from|-from|--hosts|-hosts|--issuer_cert|-issuer_cert|--issuer_key|-issuer_key|--key|-key|--listen|-listen|--path|-path|--proxy|-proxy|--tls|-tls|--users|-users|--viewer|-viewer) COMPREPLY=(); return ;; + esac + words='--allow --cert --deny --format --from --hosts --issuer_cert --issuer_key --key --listen --path --proxy --tls --users --viewer --help -h' ;; + edge) + case $prev in + --ca|-ca|--cert|-cert|--from|-from|--key|-key|--node|-node|--password_file|-password_file|--path|-path|--pin|-pin|--server_name|-server_name|--to|-to|--transport|-transport|--user|-user|-u) COMPREPLY=(); return ;; + esac + words='--ca --cert --from --key --node --password_file --path --pin --server_name --to --transport --user -u --help -h' ;; + aggregator) + case $prev in + --allow|-allow|--cert|-cert|--client_ca|-client_ca|--deny|-deny|--format|-format|--hosts|-hosts|--issuer_cert|-issuer_cert|--issuer_key|-issuer_key|--key|-key|--listen|-listen|--out|-out|--tls|-tls|--transport|-transport|--users|-users) COMPREPLY=(); return ;; + esac + words='--allow --cert --client_ca --deny --format --hosts --issuer_cert --issuer_key --key --listen --out --tls --transport --users --help -h' ;; + esac ;; + *) + case $prev in + --logging.file.directory) _lw_reply -d; return ;; + --preset|-p) mapfile -t COMPREPLY < <(compgen -W 'pipe tail serve edge aggregator' -- "$cur"); return ;; + --config|-c|--filter|--format|--heartbeat|--logging.console.target|--logging.file.max_size_mb|--logging.file.max_total_size_mb|--logging.file.name|--logging.file.retention_hours|--logging.format|--logging.level|--logging.output|--logging.sanitization|--pipeline|--rate-limit|--sink|--source) COMPREPLY=(); return ;; + esac + words='--auto_reload --check -t --color --config -c --dump --filter --format --heartbeat --help -h --logging.console.target --logging.file.directory --logging.file.max_size_mb --logging.file.max_total_size_mb --logging.file.name --logging.file.retention_hours --logging.format --logging.level --logging.output --logging.sanitization --pipeline --preset -p --quiet -q --rate-limit --sink --source --status_reporter --version -V' ;; + esac + mapfile -t COMPREPLY < <(compgen -W "$words" -- "$cur") +} + +# An empty reply falls back to file names, as without this script +complete -o bashdefault -o default -F _lw lw diff --git a/deploy/package/completion/lw.fish b/deploy/package/completion/lw.fish new file mode 100644 index 0000000..cb4ecbf --- /dev/null +++ b/deploy/package/completion/lw.fish @@ -0,0 +1,145 @@ +# fish completion for lw. Generated by make completion from lw's own tables: edit cmd/lw, not this file. + +# __lw_in WORD...: the command line so far is lw WORD... +function __lw_in + set -l w (commandline -opc) + set -l n (math (count $argv) + 1) + test (count $w) -ge $n; and test "$w[2..$n]" = "$argv" +end + +complete -c lw -f +set -l __lw_first 'test (count (commandline -opc)) -eq 1' +complete -c lw -n $__lw_first -a auth -d 'SCRAM users, bearer tokens, tcp sink viewing' +complete -c lw -n $__lw_first -a tls -d 'A CA and certificates for TLS and mTLS' +complete -c lw -n $__lw_first -a preset -d 'Show the pipeline a preset makes, as TOML' +complete -c lw -n $__lw_first -a help -d 'Print the help and exit' +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l auto_reload +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l check -o t +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l color +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l config -o c -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l dump +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l filter -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l format -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l heartbeat -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l help -o h +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.console.target -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.file.directory -x -a '(__fish_complete_directories)' +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.file.max_size_mb -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.file.max_total_size_mb -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.file.name -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.file.retention_hours -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.format -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.level -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.output -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l logging.sanitization -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l pipeline -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l preset -o p -x -a 'pipe tail serve edge aggregator' +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l quiet -o q +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l rate-limit -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l sink -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l source -r -F +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l status_reporter +complete -c lw -n 'not __lw_in auth; and not __lw_in tls; and not __lw_in preset' -l version -o V + +complete -c lw -n '__lw_in auth; and test (count (commandline -opc)) -eq 2' -a add-user -d 'Add a user to a credentials file, or replace its password' +complete -c lw -n '__lw_in auth; and test (count (commandline -opc)) -eq 2' -a remove-user -d 'Remove a user from a credentials file' +complete -c lw -n '__lw_in auth; and test (count (commandline -opc)) -eq 2' -a token -d 'Log in to an http sink or http_chain source and print a bearer token' +complete -c lw -n '__lw_in auth; and test (count (commandline -opc)) -eq 2' -a stream -d 'Log in to a tcp sink and copy its stream to stdout until interrupted' +complete -c lw -n '__lw_in auth add-user' -l credentials -r -F -d 'credentials file, created when missing' +complete -c lw -n '__lw_in auth add-user' -l generate -d 'generate a new password, replacing any --password-file; needed to rotate without one' +complete -c lw -n '__lw_in auth add-user' -l password-file -r -F -d 'read the password from file when it exists, else write a generated one there' +complete -c lw -n '__lw_in auth add-user' -l user -o u -r -F -d 'user name' +complete -c lw -n '__lw_in auth add-user' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in auth remove-user' -l credentials -r -F -d 'credentials file' +complete -c lw -n '__lw_in auth remove-user' -l user -o u -r -F -d 'user name' +complete -c lw -n '__lw_in auth remove-user' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in auth token' -l ca-file -r -F -d 'CA file that verifies the server (default: system roots)' +complete -c lw -n '__lw_in auth token' -l cert-file -r -F -d 'client certificate file, for listeners with tls.client_auth' +complete -c lw -n '__lw_in auth token' -l key-file -r -F -d 'client key file' +complete -c lw -n '__lw_in auth token' -l password-file -r -F -d 'file holding the password' +complete -c lw -n '__lw_in auth token' -l pin-sha256 -r -F -d 'sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)' +complete -c lw -n '__lw_in auth token' -l server-name -r -F -d 'name the server certificate must carry (default: the host)' +complete -c lw -n '__lw_in auth token' -l unbound -d 'log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)' +complete -c lw -n '__lw_in auth token' -l url -r -F -d 'listener URL, https://HOST:PORT or https://[IPV6]:PORT (with --unbound, a proxy\'s mount URL may carry a path)' +complete -c lw -n '__lw_in auth token' -l user -o u -r -F -d 'user name' +complete -c lw -n '__lw_in auth token' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in auth stream' -l addr -r -F -d 'tcp sink address, HOST:PORT or [IPV6]:PORT' +complete -c lw -n '__lw_in auth stream' -l ca-file -r -F -d 'CA file that verifies the server (default: system roots)' +complete -c lw -n '__lw_in auth stream' -l cert-file -r -F -d 'client certificate file, for listeners with tls.client_auth' +complete -c lw -n '__lw_in auth stream' -l key-file -r -F -d 'client key file' +complete -c lw -n '__lw_in auth stream' -l password-file -r -F -d 'file holding the password' +complete -c lw -n '__lw_in auth stream' -l pin-sha256 -r -F -d 'sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)' +complete -c lw -n '__lw_in auth stream' -l server-name -r -F -d 'name the server certificate must carry (default: the host)' +complete -c lw -n '__lw_in auth stream' -l user -o u -r -F -d 'user name' +complete -c lw -n '__lw_in auth stream' -l help -o h -d 'print the flags' + +complete -c lw -n '__lw_in tls; and test (count (commandline -opc)) -eq 2' -a ca -d 'Create a certificate authority, DIR/ca.crt and DIR/ca.key, that signs certificates only' +complete -c lw -n '__lw_in tls; and test (count (commandline -opc)) -eq 2' -a cert -d 'Issue a certificate and key, OUT/NAME.crt and OUT/NAME.key, from DIR/ca.crt and DIR/ca.key' +complete -c lw -n '__lw_in tls ca' -l days -r -F -d 'validity in n days' +complete -c lw -n '__lw_in tls ca' -l dir -x -a '(__fish_complete_directories)' -d 'directory to write ca.crt and ca.key in, created when missing' +complete -c lw -n '__lw_in tls ca' -l name -r -F -d 'subject common name' +complete -c lw -n '__lw_in tls ca' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in tls cert' -l ca-dir -x -a '(__fish_complete_directories)' -d 'directory holding ca.crt and ca.key (lw tls ca)' +complete -c lw -n '__lw_in tls cert' -l client -d 'for dialers presenting a certificate (TLS client authentication)' +complete -c lw -n '__lw_in tls cert' -l days -r -F -d 'validity in n days, at most the CA\'s' +complete -c lw -n '__lw_in tls cert' -l hosts -r -F -d 'DNS names and IP addresses, \',\' between them (default with --server: --name)' +complete -c lw -n '__lw_in tls cert' -l name -r -F -d 'subject common name, the mtls identity, and the file names' +complete -c lw -n '__lw_in tls cert' -l out -x -a '(__fish_complete_directories)' -d 'directory to write in (default: --ca-dir)' +complete -c lw -n '__lw_in tls cert' -l server -d 'for listeners (TLS server authentication)' +complete -c lw -n '__lw_in tls cert' -l help -o h -d 'print the flags' + +complete -c lw -n '__lw_in preset; and test (count (commandline -opc)) -eq 2' -a pipe -d 'Standard input to standard output, line for line: lw without a file' +complete -c lw -n '__lw_in preset; and test (count (commandline -opc)) -eq 2' -a tail -d 'Follow files to standard output, like tail -F' +complete -c lw -n '__lw_in preset; and test (count (commandline -opc)) -eq 2' -a serve -d 'Serve files or standard input live: a browser viewer at /, SSE at /stream' +complete -c lw -n '__lw_in preset; and test (count (commandline -opc)) -eq 2' -a edge -d 'Forward files or standard input to an aggregator over TLS, authenticated' +complete -c lw -n '__lw_in preset; and test (count (commandline -opc)) -eq 2' -a aggregator -d 'Receive from edges over TLS, authenticated, to files or standard output' +complete -c lw -n '__lw_in preset pipe' -l format -r -F -d 'raw|txt|json' +complete -c lw -n '__lw_in preset pipe' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in preset tail' -l format -r -F -d 'raw|txt|json' +complete -c lw -n '__lw_in preset tail' -l from -r -F -d 'start|end: where reading a file starts' +complete -c lw -n '__lw_in preset tail' -l path -r -F -d 'file, directory (its files) or glob to follow' +complete -c lw -n '__lw_in preset tail' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in preset serve' -l allow -r -F -d 'addresses or CIDRs that may connect, of listen\'s family (either behind proxy=); default: all; \',\' between them' +complete -c lw -n '__lw_in preset serve' -l cert -r -F -d 'certificate file, for tls=files' +complete -c lw -n '__lw_in preset serve' -l deny -r -F -d 'addresses or CIDRs refused, allow or not; \',\' between them' +complete -c lw -n '__lw_in preset serve' -l format -r -F -d 'raw|txt|json' +complete -c lw -n '__lw_in preset serve' -l from -r -F -d 'start|end: where reading a file starts' +complete -c lw -n '__lw_in preset serve' -l hosts -r -F -d 'names and addresses, beyond this host\'s, of a self or issuer certificate; \',\' between them' +complete -c lw -n '__lw_in preset serve' -l issuer_cert -r -F -d 'CA certificate file (lw tls ca), for tls=issuer' +complete -c lw -n '__lw_in preset serve' -l issuer_key -r -F -d 'CA key file, for tls=issuer' +complete -c lw -n '__lw_in preset serve' -l key -r -F -d 'key file, for tls=files' +complete -c lw -n '__lw_in preset serve' -l listen -r -F -d 'HOST:PORT; [IPV6]:PORT' +complete -c lw -n '__lw_in preset serve' -l path -r -F -d 'file, directory (its files) or glob to follow; default: standard input' +complete -c lw -n '__lw_in preset serve' -l proxy -r -F -d 'addresses or CIDRs of the TLS-terminating proxies browsers come through; \',\' between them' +complete -c lw -n '__lw_in preset serve' -l tls -r -F -d 'off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files' +complete -c lw -n '__lw_in preset serve' -l users -r -F -d 'credentials file (lw auth add-user): readers log in with SCRAM' +complete -c lw -n '__lw_in preset serve' -l viewer -r -F -d 'true: the login page and viewer for users, behind proxy; without users the viewer is always on' +complete -c lw -n '__lw_in preset serve' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in preset edge' -l ca -r -F -d 'CA file that verifies the aggregator; default: system roots' +complete -c lw -n '__lw_in preset edge' -l cert -r -F -d 'client certificate file, for an aggregator with client_ca' +complete -c lw -n '__lw_in preset edge' -l from -r -F -d 'start|end: where reading a file starts' +complete -c lw -n '__lw_in preset edge' -l key -r -F -d 'client key file' +complete -c lw -n '__lw_in preset edge' -l node -r -F -d 'origin label; default: this host\'s name' +complete -c lw -n '__lw_in preset edge' -l password_file -r -F -d 'file holding the SCRAM password' +complete -c lw -n '__lw_in preset edge' -l path -r -F -d 'file, directory (its files) or glob to follow; default: standard input' +complete -c lw -n '__lw_in preset edge' -l pin -r -F -d 'sha256//BASE64 of the aggregator\'s key, which it logs with tls=self' +complete -c lw -n '__lw_in preset edge' -l server_name -r -F -d 'name the aggregator\'s certificate carries; default: to\'s host' +complete -c lw -n '__lw_in preset edge' -l to -r -F -d 'the aggregator\'s HOST:PORT; [IPV6]:PORT' +complete -c lw -n '__lw_in preset edge' -l transport -r -F -d 'tcp|http: tcp_chain or http_chain' +complete -c lw -n '__lw_in preset edge' -l user -o u -r -F -d 'SCRAM user (lw auth add-user on the aggregator)' +complete -c lw -n '__lw_in preset edge' -l help -o h -d 'print the flags' +complete -c lw -n '__lw_in preset aggregator' -l allow -r -F -d 'addresses or CIDRs that may connect, of listen\'s family (either behind proxy=); default: all; \',\' between them' +complete -c lw -n '__lw_in preset aggregator' -l cert -r -F -d 'certificate file, for tls=files' +complete -c lw -n '__lw_in preset aggregator' -l client_ca -r -F -d 'CA file that verifies edge certificates (mTLS)' +complete -c lw -n '__lw_in preset aggregator' -l deny -r -F -d 'addresses or CIDRs refused, allow or not; \',\' between them' +complete -c lw -n '__lw_in preset aggregator' -l format -r -F -d 'raw|txt|json' +complete -c lw -n '__lw_in preset aggregator' -l hosts -r -F -d 'names and addresses, beyond this host\'s, of a self or issuer certificate; \',\' between them' +complete -c lw -n '__lw_in preset aggregator' -l issuer_cert -r -F -d 'CA certificate file (lw tls ca), for tls=issuer' +complete -c lw -n '__lw_in preset aggregator' -l issuer_key -r -F -d 'CA key file, for tls=issuer' +complete -c lw -n '__lw_in preset aggregator' -l key -r -F -d 'key file, for tls=files' +complete -c lw -n '__lw_in preset aggregator' -l listen -r -F -d 'HOST:PORT; [IPV6]:PORT; [::] for IPv6 only' +complete -c lw -n '__lw_in preset aggregator' -l out -r -F -d 'directory for the received entries; default: standard output' +complete -c lw -n '__lw_in preset aggregator' -l tls -r -F -d 'self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files' +complete -c lw -n '__lw_in preset aggregator' -l transport -r -F -d 'tcp|http: tcp_chain or http_chain' +complete -c lw -n '__lw_in preset aggregator' -l users -r -F -d 'credentials file (lw auth add-user): edges log in with SCRAM' +complete -c lw -n '__lw_in preset aggregator' -l help -o h -d 'print the flags' diff --git a/deploy/package/deb/control b/deploy/package/deb/control new file mode 100644 index 0000000..6881a83 --- /dev/null +++ b/deploy/package/deb/control @@ -0,0 +1,4 @@ +Section: admin +Priority: optional +Homepage: https://github.com/lixenwraith/logwisp +Description: Pipeline-based log transport with TLS and SCRAM authentication diff --git a/deploy/package/deb/postinst b/deploy/package/deb/postinst new file mode 100755 index 0000000..a38c9fb --- /dev/null +++ b/deploy/package/deb/postinst @@ -0,0 +1,13 @@ +#!/bin/sh +# sysusers and tmpfiles need no running systemd (a chroot); a first install +# leaves the service to systemctl enable --now logwisp, an upgrade restarts it +# if it runs. As in debhelper's scripts, only the account is fatal. +set -e +if [ "$1" = configure ]; then + if command -v systemd-sysusers >/dev/null; then systemd-sysusers logwisp.conf; fi + if command -v systemd-tmpfiles >/dev/null; then systemd-tmpfiles --create logwisp.conf || true; fi + if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true + if [ -n "$2" ]; then systemctl try-restart logwisp.service || true; fi + fi +fi diff --git a/deploy/package/deb/postrm b/deploy/package/deb/postrm new file mode 100755 index 0000000..d237bf6 --- /dev/null +++ b/deploy/package/deb/postrm @@ -0,0 +1,5 @@ +#!/bin/sh +# A purge removes logwisp.toml; credentials in /etc/logwisp, /var/lib/logwisp, +# /var/log/logwisp and the logwisp user stay, as make uninstall keeps them. +set -e +if [ -d /run/systemd/system ]; then systemctl daemon-reload || true; fi diff --git a/deploy/package/deb/prerm b/deploy/package/deb/prerm new file mode 100755 index 0000000..a2dda16 --- /dev/null +++ b/deploy/package/deb/prerm @@ -0,0 +1,6 @@ +#!/bin/sh +set -e +if [ "$1" = remove ]; then + if [ -d /run/systemd/system ]; then systemctl stop logwisp.service || true; fi + systemctl disable logwisp.service >/dev/null 2>&1 || true +fi diff --git a/deploy/package/freebsd/Makefile b/deploy/package/freebsd/Makefile index 9bcd957..c848032 100644 --- a/deploy/package/freebsd/Makefile +++ b/deploy/package/freebsd/Makefile @@ -33,6 +33,15 @@ PORTDOCS= * post-install: ${INSTALL_MAN} ${WRKSRC}/doc/lw.1 ${STAGEDIR}${PREFIX}/share/man/man1 + ${MKDIR} ${STAGEDIR}${PREFIX}/share/bash-completion/completions \ + ${STAGEDIR}${PREFIX}/share/fish/vendor_completions.d \ + ${STAGEDIR}${PREFIX}/share/zsh/site-functions + ${INSTALL_DATA} ${WRKSRC}/deploy/package/completion/lw.bash \ + ${STAGEDIR}${PREFIX}/share/bash-completion/completions/lw + ${INSTALL_DATA} ${WRKSRC}/deploy/package/completion/lw.fish \ + ${STAGEDIR}${PREFIX}/share/fish/vendor_completions.d + ${INSTALL_DATA} ${WRKSRC}/deploy/package/completion/_lw \ + ${STAGEDIR}${PREFIX}/share/zsh/site-functions ${MKDIR} ${STAGEDIR}${ETCDIR} ${INSTALL_DATA} ${WRKSRC}/config/logwisp.toml ${STAGEDIR}${ETCDIR}/logwisp.toml.sample diff --git a/deploy/package/freebsd/pkg-plist b/deploy/package/freebsd/pkg-plist index 76ebf2b..dc32b37 100644 --- a/deploy/package/freebsd/pkg-plist +++ b/deploy/package/freebsd/pkg-plist @@ -1,3 +1,6 @@ bin/lw @sample %%ETCDIR%%/logwisp.toml.sample +share/bash-completion/completions/lw +share/fish/vendor_completions.d/lw.fish share/man/man1/lw.1.gz +share/zsh/site-functions/_lw diff --git a/doc/README.md b/doc/README.md index 273d22f..c244e53 100644 --- a/doc/README.md +++ b/doc/README.md @@ -30,7 +30,7 @@ streams, or downstream LogWisp nodes. - [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)` manual, [`lw.1`](lw.1) - [Operations](operations.md): running, monitoring, tuning, troubleshooting -- [To Do](todo.md): planned work: packaging +- [To Do](todo.md): planned work: packaging, `lw --tui` and its shared configuration engine A fully annotated configuration covering every option lives at [`config/logwisp.toml`](../config/logwisp.toml). diff --git a/doc/installation.md b/doc/installation.md index 6015b2a..4c8bc78 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -27,6 +27,9 @@ The Makefile works with GNU make and BSD make alike. Targets: - `make release`: the same, static (`CGO_ENABLED=0`), `-trimpath`, stripped - `make dev`: built with the race detector - `make version`: the metadata a build would embed + - `make completion`: regenerate the bash, zsh and fish completion in + `deploy/package/completion/` from `cmd/lw`'s `shorts` and `commands` + tables; `go test ./cmd/lw` fails while they differ - `make clean`: remove `bin/` - Check - `make test`: the Go tests @@ -39,7 +42,7 @@ The Makefile works with GNU make and BSD make alike. Targets: - Container - `make image`, `make image-check`: see [Container Image](#container-image) - Install - - `make install`, `make uninstall`: see [Installing](#installing) + - `make install`, `make uninstall`, `make deb`: see [Installing](#installing) Variables: @@ -70,6 +73,11 @@ first. It honours `DESTDIR`, `PREFIX`, `SYSCONFDIR` and `BINDIR` (default - FreeBSD defaults: `PREFIX=/usr/local`, `SYSCONFDIR=/usr/local/etc`. - `INSTALL_OS` (default: `uname -s`) picks the layout, so `INSTALL_OS=FreeBSD DESTDIR=...` stages the FreeBSD one on another system. +- `BASHCOMPDIR`, `ZSHCOMPDIR`, `FISHCOMPDIR`: where the shell completion + goes, by default `$PREFIX/share/bash-completion/completions`, + `$PREFIX/share/zsh/site-functions` (`/usr/share/zsh/vendor-completions` + for `PREFIX=/usr` on Debian and Ubuntu, whose zsh reads only that) and + `$PREFIX/share/fish/vendor_completions.d`. ```bash make release @@ -79,6 +87,8 @@ sudo make install PREFIX=/usr/local Every system gets: - `$BINDIR/lw` and the manual `$PREFIX/share/man/man1/lw.1` +- completion for `lw`, its commands, flags, files and preset names: + `lw`, `_lw` and `lw.fish` in the three directories above - `$PREFIX/share/doc/logwisp/` (this documentation) and `$PREFIX/share/licenses/logwisp/LICENSE` @@ -146,6 +156,28 @@ rc.conf variables: - `logwisp_args`: extra `lw` options, e.g. `--logging.level=debug` (default empty); `logwisp_flags` goes to `daemon(8)`, not to `lw` +### Debian and Ubuntu (.deb) + +`make deb` builds a release and packages what `make install` stages, in +Debian's layout, as `bin/logwisp_VERSION_ARCH.deb`, the architecture taken +from the binary. A release tag gives the version; an untagged or modified +tree's sorts below every release and by build time, so each build upgrades +the last. `DEB_MAINTAINER` names the maintainer. + +```bash +make deb +sudo apt install ./bin/logwisp_*.deb +sudo systemctl enable --now logwisp +``` + +- Installing creates the `logwisp` account and its directories where + `systemd-sysusers` and `systemd-tmpfiles` exist; the service waits to be + enabled, and an upgrade restarts it if it runs. +- `/etc/logwisp/logwisp.toml` is a conffile: an upgrade keeps your edits, + and only a purge removes it. +- Removing stops and disables the service. The account, its directories + and any credentials in `/etc/logwisp` stay, as with `make uninstall`. + ### Uninstall `sudo make uninstall` (with the `PREFIX` used to install) removes everything @@ -235,18 +267,20 @@ docker run -d --name logwisp \ ## Packaging Status -The foundation for distribution packages is in place: +In place: -- the `lw` binary name, and `make install` with `DESTDIR`, `PREFIX` and - `SYSCONFDIR` -- the `doc/lw.1` manual -- service files in `deploy/package/`: `logwisp.service`, `logwisp.sysusers`, - `logwisp.tmpfiles` and the FreeBSD `logwisp.rc` +- `make install` with `DESTDIR`, `PREFIX` and `SYSCONFDIR`, the manual, + shell completion and the service files in `deploy/package/` +- `make deb` - skeletons: `deploy/package/arch/PKGBUILD` and a `sysutils/logwisp` port in `deploy/package/freebsd/` +- `.github/workflows/package.yml`, on changes to them: `makepkg` on the + PKGBUILD in an Arch container, the `.deb` installed, started, removed and + purged on Ubuntu, BSD make building, installing and running the rc.d + service on FreeBSD, and `make image image-check` -Still missing: release tags, which both skeletons download; finishing and -submitting the AUR package and the port; Debian packaging. +Still missing: release tags, which both skeletons download; submitting the +AUR package and the port; a Debian source package for a PPA or the archive. [To Do](todo.md) has the steps. ## Verification diff --git a/doc/todo.md b/doc/todo.md index 0a3a7a9..e6fc9b3 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -11,14 +11,18 @@ The foundation exists: 24.04 and 26.04, and the FreeBSD 15.1 ports tree. - The canonical module path `github.com/lixenwraith/logwisp`, which `go install`, FreeBSD's `USES=go:modules` and Debian's dh-golang expect. -- `make install` with `DESTDIR`, `PREFIX` and `SYSCONFDIR`. -- The `doc/lw.1` manual. -- Service files in `deploy/package/`: systemd unit, sysusers, tmpfiles and the - FreeBSD rc.d script. +- `make install` with `DESTDIR`, `PREFIX` and `SYSCONFDIR`: the binary, the + `doc/lw.1` manual, bash, zsh and fish completion generated from `cmd/lw`'s + `shorts` and `commands` tables, and the service files in `deploy/package/` + (systemd unit, sysusers, tmpfiles, FreeBSD rc.d script). +- `make deb`, a binary package built with `dpkg-deb`. - Skeletons in `deploy/package/arch/` and `deploy/package/freebsd/`. +- `.github/workflows/package.yml`: `makepkg` on the PKGBUILD, the `.deb` + installed, started and purged, BSD make and the rc.d service on FreeBSD + 15.1, and `make image image-check`. -What remains. Steps 1 to 3 are the maintainer's: they need push rights and an -identity, and both skeletons download the tagged source. +What remains is the maintainer's: it needs push rights, an identity or a +release tag, and both skeletons download the tagged source. 1. Tag the release on the merged main commit, `vX.Y.Z`: ``` @@ -52,11 +56,168 @@ identity, and both skeletons download the tagged source. - `portlint -AC`, `poudriere testport` on 14.x and 15.x jails, then a Bugzilla report with the port directory and the UIDs/GIDs diff. 4. Debian: - - Debian policy wants every Go dependency packaged; the four `lixenwraith` - libraries are not. - - Either package them too (dh-golang), or start with an `.deb` built by the - Makefile `install` target (nfpm or `dpkg-deb`) and a PPA, and move to the - archive later. -5. Shell completion for bash, zsh and fish, installed by `make install`. -6. A packaging CI job: build the AUR package in an Arch container and the port - in a FreeBSD VM, run `lw --version` and `make image-check`. + - `make deb` serves direct installs. A PPA builds from a signed source + package without network access: a `debian/` directory (changelog, + control, rules) and the Go modules vendored into the source tarball. + - The archive wants every Go dependency packaged (dh-golang); the four + `lixenwraith` libraries are not. + - `lintian` on the `make deb` package reports `no-changelog` (a source + package's `debian/changelog` provides it), `statically-linked-binary` + (lw is static by design) and `maintainer-script-calls-systemctl` (dh's + scripts use `deb-systemd-helper` instead). +5. With a tag, the packaging workflow's FreeBSD job can build the port itself + from a ports tree (`make stage check-plist` in a copy of + `deploy/package/freebsd/`), rather than BSD make on the checkout. + +## 2. `lw --tui` and the configuration engine it shares with lixen.com + +`lw --tui` composes pipelines in a full-screen terminal UI, then either runs +them at once or prints one form of them, chosen on exit: a command line, +environment variables, or a configuration file. Its logic is a pure engine that +lixen.com's planned Configure tab (lixencom `doc/todo.md`) runs as WebAssembly, +so no part of LogWisp is rewritten in JavaScript. + +What exists: +- Option structs with toml tags for every plugin and flow stage + (`internal/config/config.go`), presets as rows with typed parameters + (`preset.go`), the command-line and environment grammar (`spec.go`), + `ValidateConfig`, `--check` and `--dump`. +- `internal/config` already compiles for `GOOS=js`; it links no net/http, + crypto/tls or terminal code. +- `github.com/lixenwraith/terminal` (pinned) and its `tui` package: regions, + layout, boxes, List, Tree, TabBar, StatusBar, TextField, Form, Modal, + ConfirmDialog, scrolling, mouse. `github.com/lixenwraith/color`: RGB, + blending, RGBTo256, RGBTo16. + +What is missing: +- A machine-readable option schema. Defaults, enums and required checks live + inside the plugin constructors (about 50 `Default*` constants, 24 inline + validators); `doc/sources.md`, `doc/sinks.md`, `config/logwisp.toml` and the + site's catalogue copy them by hand, and the site's copy already lags. +- Offline plugin validation: the constructors mix their checks with reading + certificates, credentials and files. +- Typed command-line values: `lw --sink http,port=8080 --dump` prints + `port = "8080"`, a file `port = 8080`. +- A host-independent preset: `fileSource` (`preset.go`) decides directory or + pattern with `os.Stat`, so off the target host `path=/var/log` expands to + directory `/var`, pattern `log`. +- Emitters: only parsers exist for the three forms. + +Steps: +1. Schema, in `internal/config`, as the one source of truth. + - Tags on the option structs: `default:`, `help:`, and `lw:` for required, + enum, min and max, what zero means (default, none, unlimited), hints + (path, regex, file) and roles (listener, dialer) on the shared tls, auth + and acl tables. + - One catalogue table, one row per plugin type: role, network role, single + instance, summary, option struct. `RegisterSource`/`RegisterSink` panic on + a type without a row. + - `Decode[T](role, type, map)` applies defaults, unknown-key and tag checks, + then the struct's pure `Check()`. Every constructor decodes through it and + keeps only I/O; the `Default*` constants and inline validators go. + - Pure cross-field checks become methods that tlsx, authz and netacl call + first: TLS role rules, auth bindings without file reads, ACL parsing, the + http sink's paths, filter regexes. + - `Coerce` converts command-line and environment strings, and JSON floats, + to the declared kinds; `spec.go` uses it, so a command line dumps typed. + - `ValidateConfig` adds unique plugin ids per pipeline and decodes every + plugin, naming the path (`pipelines[0].plugin_sinks[view].config.port`). + It opens nothing; `lw --check` stays the authority on files. + - Keep under test what moves: file sink `min_disk_free_mb` (negative means + 100, 0 means none), a backoff maximum below the minimum, format `text`. +2. Engine, `internal/compose`. + - Imports internal/config, internal/version and lixenwraith/toml only; a + test fails when `go list -deps` shows net, crypto/tls, os/exec, os/signal, + x/term or the auth, log and terminal modules. + - Starts: empty; a preset, with a `stat` function that is nil off the host + (the UI then asks "directory or file pattern"); a loaded configuration; a + pasted command line (wrapping `cliSpecs` and `buildPipelines`). + - Operations: add or remove a source, sink or filter; set or unset an + option; move a filter; validate. + - Emitters, one form per call: + - Command line: `--pipeline NAME`, `id=`, the rate limit policy and + heartbeat `enabled` always explicit (their command-line defaults differ + from the file's); `--key=value` when a value starts with `-`; `,` `=` + `\` escaped; POSIX-quoted. + - Environment: `LOGWISP_SOURCE_1` and the rest, single-quoted + `KEY='value'` lines; an error for more than one pipeline, which the + environment form cannot hold. + - File: the TOML `lw --dump` prints for those pipelines. +3. `lw --schema`, a parser switch like `--help` (not a Config field, so no + file or environment can trigger it). It prints one JSON document: version; + settings with kind, default, enum, flag and variable; the pipeline flags; + flow stage order; the tls, auth and acl tables; sources, sinks, presets. + The site generates its catalogue from it. +4. In `lixenwraith/terminal`, released and pinned before the TUI: + - Full-screen 8- and 16-color output (SGR 30-37, 90-97, 40-47) and the + default colors 39/49; it emits only 256-color and truecolor today. + - Detection as `inline` does it: 16 colors on text consoles, none under + `NO_COLOR`, refusal on `TERM=dumb`, and an override for LogWisp's `color` + key. + - Drawing on `/dev/tty`, so `lw --tui > logwisp.toml` and `cmd | lw --tui` + keep stdin and stdout as data. + - Bracketed paste into text fields. + - `tui` widgets the site's controls have: segmented choice, filterable + option list, toggle, typed fields (default as placeholder, required mark, + error and help line, folded groups), a focus ring; every widget styled + from one `tui.Theme`. + - In `color`, optional: export the 16 xterm reference shades. RGBTo16 maps + the site's hues badly (green to cyan, red to bright black), so the + 8-color tier is chosen by hand. +5. `lw --tui`. + - A parser switch, no short form. With `-c FILE`, pipeline flags or + `LOGWISP_SOURCE` it starts from that composition, and never rewrites FILE + (TOML encoding drops its comments); bare, it opens the preset menu (pipe, + tail, serve, edge, aggregator, empty). + - The pipeline as the site draws it: SOURCES, FLOW, SINKS; a node is a + tinted bar, its type, listens or dials, and a summary line; the four flow + stages in order inside a dashed box, the ones off dimmed; wires drawn from + per-cell connectivity so every junction is right. The inspector sits + below at 80 to 109 columns, beside at 110 and over; under 64 columns the + stages stack, as on the site. + ``` + ● SOURCES ● FLOW ● SINKS + ▌ file ─┐ ╭╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╮ ┌─▌ http listens + ▌ /var/log *.log │ ╎ ▌ rate_limit 400/s ╎ │ ▌ :8080 /stream + █ tcp_chain listens─┴─►╎ ▌ filters -TRACE ╎─►┤ + █ :9000 tls scram ╎ ▌ format txt ╎ └─▌ console + ╎ ○ heartbeat off ╎ ▌ stdout + ╰╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯ + ── tcp_chain · source · id src-2 ─────────────────── ⏎ edit tab next ── + host 0.0.0.0 default format raw [txt] json + port * 9000▌ tls ▸ on (auth, acl folded) + ✓ valid a add d delete ␣ on/off J/K move p preset o output ? help + ``` + - Keys: arrows or hjkl move, Enter edits, `a` adds from the catalogue, `d` + deletes, Space switches a flow stage, `J`/`K` reorder filters, `p` + presets, `[` `]` switch pipelines, `c` lists errors, `o` output, `?` + help, `q` quits. + - Theme: the site's dark tokens as one role table in four tiers. Truecolor + and 256 colors paint the site's background; 8 colors and mono keep the + terminal's. Sources cyan, flow violet, sinks green, accent blue, muted + grey, errors red. A selection always changes a glyph (bar, `▸`, + underline), never only a shade. Glyphs: Unicode, a CP437 set on text + consoles, ASCII outside UTF-8. + - Output, `o`: Run hands `config.Args` built from the composition to the + usual `config.Load`, after the terminal is released and before anything + reads stdin; reloads keep the pipelines, and `--tui --check` and `--tui + --dump` follow. Or exactly one of command line, environment and file, + printed to stdout once the screen closes. +6. WebAssembly: `cmd/lwconf` (`js && wasm`) exposes schema, preset, parse, + validate and emit to the page as JSON strings. `make wasm` builds + `bin/lwconf.wasm` with the version stamped, copies the toolchain's + `wasm_exec.js`, and prints both checksums. A prototype measured about 6 MB, + 1.7 MB gzipped, ready in about 60 ms in Chromium; `wasm_exec.js` uses no + eval, so the page needs only `'wasm-unsafe-eval'` in its `script-src`. + +Verify: +- A Go test in `cmd/lw`: for every preset, an edge and aggregator pair, + escaped values and two pipelines, the pipelines loaded from the emitted + command line, file and environment (one pipeline) are equal. It runs in an + empty directory with the `LOGWISP_` environment cleared, since a discovered + file would add settings. +- A schema test: every option has a kind, every enum values, every catalogue + row a factory; `TestEveryPluginRejectsUnknownKeys` still passes. +- CI builds `cmd/lwconf` for `GOOS=js` and runs a node test of its calls. +- The diagram rendered into a cell buffer at 80x24 and 64x24, per tier; a + scripted session on a pseudo-terminal for Run and each output.