diff --git a/docs/console/introduction.md b/docs/console/introduction.md index 63ba545a..323a823f 100644 --- a/docs/console/introduction.md +++ b/docs/console/introduction.md @@ -81,9 +81,9 @@ Every command of both programs accepts these options, before or after the comman | Option | Effect | |--------|--------| | `-v`, `-vv`, `-vvv` (`--verbose`) | More output: `-v` shows info logs of every component, `-vv` debug logs, `-vvv` also source locations and SQL traces. | -| `-q`, `--quiet` | Shows only warnings and errors in the console log. Implies `-n`. | -| `--ansi`, `--no-ansi` | Forces or turns off colors and styling; the last one given wins. Without either, colors follow the terminal, `NO_COLOR` and `FORCE_COLOR`. | -| `-n`, `--no-interaction` | Marks the run as not interactive. | +| `-q`, `--quiet` | Hides info and success lines, spinners and progress, and shows only warnings and errors in the console log; data a command prints (tokens, keys, JSON) and errors still appear. Implies `-n`. | +| `--ansi`, `--no-ansi` | Forces or turns off colors and styling; `--no-ansi` also stops animations. The last one given wins. Without either, colors follow the terminal, `NO_COLOR` and `FORCE_COLOR`. | +| `-n`, `--no-interaction` | Asks no questions: prompts take their defaults, and a password prompt reads piped stdin or fails with a hint. | | `--env` | The configuration environment, as `SUMMER_ENV` would set it: `--env staging` or `--env=staging`. | The application binary reads these options before it loads `config/` and starts plugins, so `--env` picks the configuration and `-vv` applies from the first log line: diff --git a/docs/console/writing-commands.md b/docs/console/writing-commands.md index 4f9578ae..6ce9d48c 100644 --- a/docs/console/writing-commands.md +++ b/docs/console/writing-commands.md @@ -57,8 +57,24 @@ A scaffolded plugin's `Commands` method returns the generated list of everything `bonfire.Output` is the console your command writes to. Besides `bonfire.Output.Printf` and `bonfire.Output.Println`, it provides: - status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream); -- widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar, which fall back to plain lines when the output is not a terminal; -- prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. Prompts return their defaults when input ends, so a command run from cron or a script never hangs. +- widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar. On a terminal they are SSU's live views on the charm stack: a spinner, a gradient bar and boxed tables; +- prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. On an interactive terminal they are huh fields and an arrow-key selector; otherwise they read lines from stdin, so a password can be piped in, and return their defaults when input ends, so a command run from cron or a script never hangs. + +How much of this appears depends on where the command runs and on the global options: + +| Run | Animation | Decoration | Data | Prompts | +|-----|-----------|------------|------|---------| +| Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector | +| Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default | +| `-q` | none | info, success and warning lines hidden; errors kept | printed | defaults, as with `-n` | +| `--no-ansi` | none | symbols, no colors | printed | plain line prompts | +| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint | + +Print the data your command exists to produce (a token, a key, JSON) with `bonfire.Output.Println` or `bonfire.Output.Printf`, never as a status line: `-q` keeps that output and drops only the decoration around it. `bonfire.IsQuiet`, `bonfire.VerbosityOf` and `bonfire.GlobalsOf` tell a command which options it runs under. + +Before a destructive step, ask with `bonfire.ConfirmProceed`. It asks only on an interactive terminal; off a terminal, with `-n` or `-q`, and when the scheduler calls the command, it returns true without asking, so deploys over SSH never wait for an answer. + +For a loop over many items, `bonfire.Process` runs a function per item and shows SSU's process view on a terminal, one row per item with its state, or prints one `✓ item` or `✗ item: error` line per item elsewhere; it returns the failures joined. Inside `bonfire.Output.Progress`, `bonfire.Current` names the item being worked on and `bonfire.Failed` records a failed one. Log lines and output printed while a live view runs appear above the view (`bonfire.PrintAbove`). Return an error from `Run` to fail the command. The binary prints it as an error block on stderr and exits with status 1; see [Errors and hints](#errors-and-hints). diff --git a/docs/services/logging.md b/docs/services/logging.md index 75f672b3..0b4469a6 100644 --- a/docs/services/logging.md +++ b/docs/services/logging.md @@ -24,6 +24,8 @@ A channel is a named destination with a driver: Each channel may set its own `level`; a record reaches a channel when it passes both its component's level and the channel's level. +While a command shows a live view on the terminal (a spinner, a progress bar or the process view of `bonfire.Process`), styled console lines print above the view through `bonfire.PrintAbove` instead of being written into it, so log records from workers and other goroutines never tear the animation. Without a live view, and on channels that are not styled, lines are written directly as usual. + ## config/log.yaml `summer new` writes this file, and an application without it gets the same values as framework defaults: diff --git a/go.mod b/go.mod index 3e838c6e..647e090a 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,15 @@ go 1.27.0 ignore ./admin/node_modules require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.1.0 charm.land/fang/v2 v2.0.1 + charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 github.com/alecthomas/chroma/v2 v2.27.0 github.com/alicebob/miniredis/v2 v2.40.0 github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.9 github.com/disintegration/imaging v1.6.2 github.com/fsnotify/fsnotify v1.10.1 github.com/go-gormigrate/gormigrate/v2 v2.1.7 @@ -50,6 +54,7 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect @@ -70,11 +75,14 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect github.com/aws/smithy-go v1.26.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -89,6 +97,7 @@ require ( github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect @@ -115,8 +124,9 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.30 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect @@ -148,7 +158,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/xo/terminfo v1.0.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index 43c36169..1a73a1e6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= +charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -32,6 +38,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -42,6 +50,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc= github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= @@ -88,26 +98,40 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= -github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= -github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= -github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw= +github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio= +github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM= +github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -138,6 +162,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= @@ -243,8 +269,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= +github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= @@ -255,6 +281,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -353,8 +381,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= -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= +github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= +github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index 21a3f0f0..f867f852 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -27,8 +27,18 @@ Use cases: - Usage strings and argument-count checks derived from the declared arguments (`` for required, `[name]` for optional). - Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`). - Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream). -- Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal. -- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive. +- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command. Log lines and output printed while a view runs appear above it (`bonfire.PrintAbove`). +- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm` and `bonfire.Output.Secret` use huh fields on an interactive terminal, and `bonfire.Output.Choice` an arrow-key selector with a confirmation screen. Off a terminal they read lines from stdin as before (a piped password still works), return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking. A cancelled prompt returns `bonfire.ErrAborted`. `bonfire.ConfirmProceed` asks before a destructive step only on an interactive terminal. +- Degradation: every widget follows the terminal and the global options, and the `bonfire.Output` interface is unchanged for plugins: + + | Run | Animation | Decoration | Data | Prompts | + |-----|-----------|------------|------|---------| + | Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector | + | Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors unless `FORCE_COLOR` | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default | + | `-q` | none | info, success and warning lines hidden; errors kept | printed (`Println`, `Printf`, tables) | defaults, as with `-n` | + | `--no-ansi` | none | symbols, no colors | printed | plain line prompts | + | `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint | +- Command state for helpers: `bonfire.GlobalsOf`, `bonfire.VerbosityOf`, `bonfire.IsQuiet` and `bonfire.Interactive` read the running command's global options from its `bonfire.Output`. - Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description. - Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions. - Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook). @@ -139,6 +149,17 @@ if catalog.Has("blog:import") { | `bonfire.Describe` | Builds the `bonfire.Listing` of a binary from its commands and `bonfire.Version`. | | `bonfire.CheckDocs` | Reports commands lacking a description, long help, examples or argument and option descriptions, as `bonfire.DocProblem` values. | | `bonfire.DocProblem` | One command's documentation gaps; `bonfire.DocProblem.String` reads ": missing description, examples". | +| `bonfire.ItemProgress` | The optional side of a `bonfire.Progress` that names the current item (`Current`) and records failures (`Fail`); `bonfire.Output.Progress` hands one over. | +| `bonfire.Current` | Names the current item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. | +| `bonfire.Failed` | Records a failed item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. | +| `bonfire.Process` | Runs a function per item with the process view on a terminal, or one `✓`/`✗` line per item; returns the joined failures. | +| `bonfire.PrintAbove` | Prints a line above the running live view and reports whether one was running. | +| `bonfire.GlobalsOf` | The `bonfire.Globals` of the command an Output belongs to (zero for other Output implementations). | +| `bonfire.VerbosityOf` | The `-v` level of the command an Output belongs to. | +| `bonfire.IsQuiet` | Whether the command runs with `-q`. | +| `bonfire.Interactive` | Whether the Output can ask a person: stdin and stdout are terminals and neither `-n` nor `-q` is set. | +| `bonfire.ConfirmProceed` | Asks before a destructive step on an interactive terminal; returns true without asking otherwise. | +| `bonfire.ErrAborted` | Returned by a prompt the person cancelled. | | `bonfire.ErrDuplicateCommand` | Returned by `bonfire.NewRoot` when two commands share a name or a command is named `list` or `help`. | | `bonfire.ErrReservedFlag` | Returned by `bonfire.NewRoot` when a command flag reuses a global option's name or shorthand. | | `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. | @@ -162,12 +183,12 @@ bonfire reads no config keys. Output color follows these environment variables: | `TERM` | The value `dumb` disables color. | | `FORCE_COLOR` | Any non-empty value enables color even when output is not a terminal (ignored when color is disabled by `NO_COLOR` or `TERM`). | -Without these variables, color is enabled only when stdout is a terminal. +Without these variables, color is enabled only when stdout is a terminal. `--ansi` forces color and `--no-ansi` turns it off whatever the environment says. ## Dependencies - SummerCMS modules: `sunscreen` (scrubs error messages). -- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`). +- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`). - Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`. ## Testing diff --git a/modules/bonfire/helpers.go b/modules/bonfire/helpers.go new file mode 100644 index 00000000..55adce3e --- /dev/null +++ b/modules/bonfire/helpers.go @@ -0,0 +1,142 @@ +package bonfire + +import ( + "context" + "errors" + + tea "charm.land/bubbletea/v2" +) + +// consoleState is the optional side of an Output that knows the running +// command's global options. The bonfire console implements it; other +// Output implementations give the zero values. +type consoleState interface { + consoleGlobals() Globals + consoleInteractive() bool +} + +func (c *console) consoleGlobals() Globals { return c.globals } + +func (c *console) consoleInteractive() bool { return c.canPrompt() } + +// GlobalsOf returns the global options of the command out belongs to, or +// the zero Globals when out is not a bonfire console. +func GlobalsOf(out Output) Globals { + if s, ok := out.(consoleState); ok { + return s.consoleGlobals() + } + return Globals{} +} + +// VerbosityOf returns the -v level (0 to 3) of the command out belongs to. +func VerbosityOf(out Output) int { + return GlobalsOf(out).Verbosity +} + +// IsQuiet reports whether the command runs with -q. +func IsQuiet(out Output) bool { + return GlobalsOf(out).Quiet +} + +// Interactive reports whether out can ask a person: stdin and stdout are +// terminals and neither -n nor -q is set. It is false for other Output +// implementations and inside bonfire.Call. +func Interactive(out Output) bool { + if s, ok := out.(consoleState); ok { + return s.consoleInteractive() + } + return false +} + +// ConfirmProceed asks question before a destructive step, defaulting to +// no, only when out is Interactive. Off a terminal, with -n or -q and in +// bonfire.Call it returns true without asking, so deploys over SSH and +// scheduled runs never block (D-28). +func ConfirmProceed(out Output, question string) (bool, error) { + if !Interactive(out) { + return true, nil + } + return out.Confirm(question, false) +} + +// Current tells p which item is being worked on, when p is an ItemProgress. +func Current(p Progress, item string) { + if ip, ok := p.(ItemProgress); ok { + ip.Current(item) + } +} + +// Failed records a failed item on p, when p is an ItemProgress. +func Failed(p Progress, item string, err error) { + if ip, ok := p.(ItemProgress); ok { + ip.Fail(item, err) + } +} + +// Process runs fn for each item in turn. On a live console it shows SSU's +// process view: one row per item (waiting, running, done or failed with its +// error) and a progress bar labelled operation. Otherwise it prints one line +// per item, "✓ item" through Output.Success or "✗ item: error" through +// Output.Error, so -q prints only the failures. It stops early when ctx is +// cancelled and returns errors.Join of the failures. +func Process(ctx context.Context, out Output, operation string, items []string, fn func(ctx context.Context, item string) error) error { + if ctx == nil { + ctx = context.Background() + } + if c, ok := out.(*console); ok && c.live() && len(items) > 0 { + var errs []error + _ = runLive(ctx, c.out, c, newProcessModel(c.palette, operation, items), func(send func(tea.Msg)) error { + for i, item := range items { + if err := ctx.Err(); err != nil { + errs = append(errs, err) + return nil + } + send(stepMsg{item: item, state: itemRunning, done: i}) + if err := fn(ctx, item); err != nil { + errs = append(errs, failure(item, err)) + send(stepMsg{item: item, state: itemFailed, err: err, done: i + 1}) + continue + } + send(stepMsg{item: item, state: itemDone, done: i + 1}) + } + return nil + }) + return errors.Join(errs...) + } + var errs []error + for _, item := range items { + if err := ctx.Err(); err != nil { + errs = append(errs, err) + break + } + if err := fn(ctx, item); err != nil { + errs = append(errs, failure(item, err)) + out.Error(failureLine(item, err)) + continue + } + out.Success(item) + } + return errors.Join(errs...) +} + +// failure wraps err with the item it belongs to. +func failure(item string, err error) error { + return &itemError{item: item, err: err} +} + +type itemError struct { + item string + err error +} + +func (e *itemError) Error() string { return failureLine(e.item, e.err) } + +func (e *itemError) Unwrap() error { return e.err } + +// PrintAbove prints line above the running live view (spinner, progress or +// process view) and reports whether one was running. diary's console +// channel uses it so log lines from other goroutines never tear a view; it +// writes directly when PrintAbove returns false. +func PrintAbove(line string) bool { + return printAboveLines(line) +} diff --git a/modules/bonfire/live.go b/modules/bonfire/live.go new file mode 100644 index 00000000..d220e80c --- /dev/null +++ b/modules/bonfire/live.go @@ -0,0 +1,387 @@ +package bonfire + +// Live views ported from SSU's terminal UI (internal/cli/tui spinner.go, +// process.go and progress.go) to bubbletea v2, without SSU's git and +// engine types. Each runs as a bubbletea program with input off (stdin +// stays in cooked mode, so Ctrl+C reaches the command's own signal +// handling), no signal handler of its own and the command's context. + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Progress bar colors: the cyan to green gradient of the earlier bonfire +// bar (256-color 37 and 34). +var ( + barFrom = lipgloss.Color("#00afaf") + barTo = lipgloss.Color("#00af00") +) + +const barWidth = 40 + +// Messages sent to the live models from the command's goroutine. +type ( + // itemMsg reports progress: done of total, the current item, and a + // failure of the current item when err is set. + itemMsg struct { + done, total int + item string + err error + } + // stepMsg reports one item of the process view changing state. + stepMsg struct { + item string + state itemState + err error + done int + } + // finishMsg ends a live view; err is the result of the work. + finishMsg struct{ err error } + // printAboveMsg is a line to print above the view. + printAboveMsg string +) + +// itemState is the display state of one item in the process view. +type itemState int + +const ( + itemWaiting itemState = iota + itemRunning + itemDone + itemFailed +) + +const symWaiting = "○" + +// newSpinner is SSU's MiniDot spinner in the current (cyan) style. +func newSpinner(p Palette) spinner.Model { + return spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(p.Current)) +} + +// newBar is the gradient progress bar without its own percentage. +func newBar() progress.Model { + bar := progress.New(progress.WithColors(barFrom, barTo), progress.WithoutPercentage()) + bar.SetWidth(barWidth) + return bar +} + +// spinnerModel shows a spinner, the label and, once known, a done/total +// counter with the current item; it ends with ✓ or ✗ and the label. +type spinnerModel struct { + palette Palette + spin spinner.Model + label string + done int + total int + current string + failed int + finished bool + err error +} + +func newSpinnerModel(p Palette, label string) spinnerModel { + return spinnerModel{palette: p, spin: newSpinner(p), label: label} +} + +func (m spinnerModel) Init() tea.Cmd { return m.spin.Tick } + +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case itemMsg: + m.done, m.total, m.current = msg.done, msg.total, msg.item + if msg.err != nil { + m.failed++ + } + return m, nil + case finishMsg: + m.finished, m.err = true, msg.err + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + } + return m, nil +} + +func (m spinnerModel) View() tea.View { + p := m.palette + if m.finished { + if m.err != nil { + return tea.NewView(p.Error.Render(symCross) + " " + m.label + "\n") + } + return tea.NewView(p.Success.Render(symCheck) + " " + m.label + "\n") + } + line := m.spin.View() + " " + m.label + if m.total > 0 { + line += fmt.Sprintf(" %d/%d", m.done, m.total) + } + if m.current != "" { + line += " " + p.Muted.Render(m.current+"…") + } + if m.failed > 0 { + line += " " + p.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed)) + } + return tea.NewView(line) +} + +// progressModel shows the gradient bar, done/total and the current item. +type progressModel struct { + palette Palette + bar progress.Model + done int + total int + current string + failed int + finished bool +} + +func newProgressModel(p Palette, total int) progressModel { + return progressModel{palette: p, bar: newBar(), total: total} +} + +func (m progressModel) Init() tea.Cmd { return nil } + +func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.bar.SetWidth(min(max(msg.Width-30, 10), barWidth)) + return m, nil + case itemMsg: + m.done, m.total = msg.done, msg.total + if msg.item != "" { + m.current = msg.item + } + if msg.err != nil { + m.failed++ + } + return m, nil + case finishMsg: + m.finished = true + return m, tea.Quit + } + return m, nil +} + +func (m progressModel) View() tea.View { + pct := 0.0 + if m.total > 0 { + pct = float64(m.done) / float64(m.total) + } + line := " " + m.bar.ViewAs(pct) + m.palette.Muted.Render(fmt.Sprintf(" %d/%d", m.done, m.total)) + if m.current != "" && !m.finished { + line += " " + m.palette.Muted.Render(m.current) + } + if m.failed > 0 { + line += " " + m.palette.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed)) + } + if m.finished { + line += "\n" + } + return tea.NewView(line) +} + +// processItem is one row of the process view. +type processItem struct { + name string + state itemState + err error +} + +// processModel is SSU's multi-row view: one row per item (waiting, running +// with a spinner, done or failed with its error) and the bar below. +type processModel struct { + palette Palette + items []processItem + spin spinner.Model + bar progress.Model + operation string + done int + height int + finished bool +} + +func newProcessModel(p Palette, operation string, items []string) processModel { + rows := make([]processItem, len(items)) + for i, name := range items { + rows[i] = processItem{name: name} + } + return processModel{palette: p, items: rows, spin: newSpinner(p), bar: newBar(), operation: operation} +} + +func (m processModel) Init() tea.Cmd { return m.spin.Tick } + +func (m processModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.height = msg.Height + return m, nil + case stepMsg: + m.done = msg.done + for i := range m.items { + if m.items[i].name == msg.item && m.items[i].state < msg.state { + m.items[i].state, m.items[i].err = msg.state, msg.err + break + } + } + return m, nil + case finishMsg: + m.finished = true + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + } + return m, nil +} + +// window is the range of rows that fits the terminal: around the running +// item, or the last finished ones. +func (m processModel) window() (int, int) { + visible := len(m.items) + if m.height > 0 && visible > m.height-3 { + visible = max(m.height-3, 3) + } + start := 0 + if visible < len(m.items) { + active := -1 + for i, it := range m.items { + if it.state == itemRunning { + active = i + break + } + } + if active >= 0 { + start = min(max(active-visible/2, 0), len(m.items)-visible) + } else { + start = max(m.done-visible, 0) + } + } + return start, min(start+visible, len(m.items)) +} + +func (m processModel) View() tea.View { + p := m.palette + var b strings.Builder + start, end := m.window() + for _, it := range m.items[start:end] { + switch it.state { + case itemWaiting: + b.WriteString(p.Muted.Render(" "+symWaiting) + " " + p.Muted.Render(it.name)) + case itemRunning: + b.WriteString(" " + m.spin.View() + " " + it.name) + case itemDone: + b.WriteString(p.Success.Render(" "+symCheck) + " " + it.name) + case itemFailed: + b.WriteString(p.Error.Render(" "+symCross) + " " + it.name) + if it.err != nil { + b.WriteString(" " + p.Error.Render(it.err.Error())) + } + } + b.WriteString("\n") + } + total := len(m.items) + pct := 0.0 + if total > 0 { + pct = float64(m.done) / float64(total) + } + op := m.operation + if op == "" { + op = "Processing" + } + b.WriteString(fmt.Sprintf("\n %s %d/%d %s %s", op, m.done, total, m.bar.ViewAs(pct), p.Muted.Render(fmt.Sprintf("%d%%", int(pct*100))))) + if m.finished { + b.WriteString("\n") + } + return tea.NewView(b.String()) +} + +// liveModel wraps a view so lines from PrintAbove print above it. +type liveModel struct { + inner tea.Model +} + +func (m liveModel) Init() tea.Cmd { return m.inner.Init() } + +func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if line, ok := msg.(printAboveMsg); ok { + return m, tea.Println(string(line)) + } + inner, cmd := m.inner.Update(msg) + m.inner = inner + return m, cmd +} + +func (m liveModel) View() tea.View { return m.inner.View() } + +// The running live program, for PrintAbove. Only one live view runs at a +// time; widgets started while one runs print plain lines. +var ( + liveMu sync.Mutex + activeProg *tea.Program +) + +// liveActive reports whether a live view is running. +func liveActive() bool { + liveMu.Lock() + defer liveMu.Unlock() + return activeProg != nil +} + +// runLive runs model as a bubbletea program on out while work runs on the +// calling goroutine. work gets a send function for progress messages. When +// work returns, the program is unregistered (so PrintAbove stops sending to +// it), told to finish and waited for; it never receives lines after that. +func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error { + if ctx == nil { + ctx = context.Background() + } + prog := tea.NewProgram(liveModel{inner: model}, + tea.WithInput(nil), + tea.WithOutput(out), + tea.WithoutSignalHandler(), + tea.WithContext(ctx), + tea.WithColorProfile(c.profile()), + ) + finished := make(chan struct{}) + go func() { + defer close(finished) + _, _ = prog.Run() + }() + liveMu.Lock() + activeProg = prog + liveMu.Unlock() + + err := work(prog.Send) + + liveMu.Lock() + activeProg = nil + liveMu.Unlock() + prog.Send(finishMsg{err: err}) + <-finished + return err +} + +// printAboveLines prints s, one or more lines, above the running live view. +// It reports false when no view runs. +func printAboveLines(s string) bool { + liveMu.Lock() + defer liveMu.Unlock() + if activeProg == nil { + return false + } + for _, line := range strings.Split(strings.TrimSuffix(s, "\n"), "\n") { + activeProg.Send(printAboveMsg(line)) + } + return true +} diff --git a/modules/bonfire/output.go b/modules/bonfire/output.go index 31cf0b6a..5b098876 100644 --- a/modules/bonfire/output.go +++ b/modules/bonfire/output.go @@ -4,18 +4,14 @@ import ( "fmt" "io" "os" + "strings" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" "golang.org/x/term" ) const ( - ansiReset = "\x1b[0m" - ansiRed = "\x1b[31m" - ansiGreen = "\x1b[32m" - ansiYellow = "\x1b[33m" - ansiCyan = "\x1b[36m" - ansiDim = "\x1b[90m" - symCheck = "✓" symCross = "✗" symWarn = "⚠" @@ -48,16 +44,25 @@ type Progress interface { } type console struct { - in io.Reader - out io.Writer - err io.Writer - lookup func(string) string - tty bool + in io.Reader + out io.Writer + err io.Writer + lookup func(string) string + // tty is true when stdout is a terminal; it picks animated widgets + // and boxed tables. + tty bool + // interactive is true when stdin and stdout are terminals. interactive bool - color bool + // inTTY is true when stdin is a terminal; Secret then reads a hidden + // value instead of a line. + inTTY bool + // color is the environment's color policy (NO_COLOR, TERM=dumb, + // FORCE_COLOR, terminal) for ColorAuto. + color bool // globals are the parsed global options of the running command, set // by the root before the command runs. globals Globals + palette Palette } // NewOutput builds Output from injected streams and the process color policy. @@ -78,16 +83,17 @@ func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, t if lookup == nil { lookup = os.Getenv } - c := &console{in: in, out: out, err: errW, lookup: lookup} + c := &console{in: in, out: out, err: errW, lookup: lookup, palette: DefaultPalette()} if tty != nil { c.tty = *tty } else { c.tty = isTerminal(out) } + c.inTTY = isTerminal(in) if interactive != nil { c.interactive = *interactive } else { - c.interactive = isTerminal(in) && c.tty + c.interactive = c.inTTY && c.tty } c.color = supportsColor(lookup, c.tty) return c @@ -115,6 +121,67 @@ func isTerminal(rw any) bool { return term.IsTerminal(int(f.Fd())) } +// setGlobals records the parsed global options of the running command. +func (c *console) setGlobals(g Globals) { + c.globals = g +} + +// live reports whether widgets animate: stdout is a terminal and none of +// -q, --no-ansi and -n is set (D-03). A live view that is already running +// makes nested widgets print plain lines. +func (c *console) live() bool { + g := c.globals + return c.tty && !g.Quiet && g.Color != ColorNever && !g.NoInteraction && !liveActive() +} + +// canPrompt reports whether prompts may wait for input from a person: +// stdin and stdout are terminals and -n (or -q) is not set. +func (c *console) canPrompt() bool { + return c.interactive && !c.globals.NoInteraction +} + +// profile is the color profile of styled output: none for --no-ansi or +// when the environment turns color off, at least 256 colors otherwise. +func (c *console) profile() colorprofile.Profile { + switch c.globals.Color { + case ColorNever: + return colorprofile.NoTTY + case ColorAuto: + if !c.color { + return colorprofile.NoTTY + } + } + return DetectProfile(c.out, ColorAlways, c.environ()) +} + +// environ rebuilds the color-related environment from lookup. +func (c *console) environ() []string { + var env []string + for _, k := range []string{"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "CLICOLOR", "CLICOLOR_FORCE"} { + if v := c.lookup(k); v != "" { + env = append(env, k+"="+v) + } + } + return env +} + +// styled wraps w so palette styles follow the color profile. +func (c *console) styled(w io.Writer) io.Writer { + return &colorprofile.Writer{Forward: w, Profile: c.profile()} +} + +// emit writes s, a styled line or block, to w. While a live view runs on +// stdout, lines for stdout print above it instead of tearing it. +func (c *console) emit(w io.Writer, s string) { + if w == nil { + return + } + if w == c.out && printAboveLines(s) { + return + } + _, _ = io.WriteString(c.styled(w), s) +} + func (c *console) Write(p []byte) (int, error) { if c.out == nil { return 0, io.ErrClosedPipe @@ -126,22 +193,38 @@ func (c *console) Println(a ...any) { if c.out == nil { return } - fmt.Fprintln(c.out, a...) + s := fmt.Sprintln(a...) + if printAboveLines(s) { + return + } + _, _ = io.WriteString(c.out, s) } func (c *console) Printf(format string, a ...any) { if c.out == nil { return } - fmt.Fprintf(c.out, format, a...) + s := fmt.Sprintf(format, a...) + if strings.HasSuffix(s, "\n") && printAboveLines(s) { + return + } + _, _ = io.WriteString(c.out, s) +} + +// status prints a status line unless -q is set. +func (c *console) status(style lipgloss.Style, symbol, message string) { + if c.globals.Quiet { + return + } + c.emit(c.out, style.Render(symbol)+" "+message+"\n") } func (c *console) Info(message string) { - c.Println(c.style(ansiCyan, symInfo) + " " + message) + c.status(c.palette.Current, symInfo, message) } func (c *console) Success(message string) { - c.Println(c.style(ansiGreen, symCheck) + " " + message) + c.status(c.palette.Success, symCheck, message) } func (c *console) Error(message string) { @@ -149,30 +232,11 @@ func (c *console) Error(message string) { if w == nil { w = c.out } - if w == nil { - return - } - fmt.Fprintln(w, c.style(ansiRed, symCross)+" "+message) + c.emit(w, c.palette.Error.Render(symCross)+" "+message+"\n") } func (c *console) Warning(message string) { - c.Println(c.style(ansiYellow, symWarn) + " " + message) -} - -func (c *console) style(code, s string) string { - if !c.color { - return s - } - return code + s + ansiReset -} - -func (c *console) dim(s string) string { - return c.style(ansiDim, s) -} - -// setGlobals records the parsed global options of the running command. -func (c *console) setGlobals(g Globals) { - c.globals = g + c.status(c.palette.Warning, symWarn, message) } func boolPtr(v bool) *bool { return &v } diff --git a/modules/bonfire/prompts.go b/modules/bonfire/prompts.go index f5cef952..63872f13 100644 --- a/modules/bonfire/prompts.go +++ b/modules/bonfire/prompts.go @@ -2,16 +2,60 @@ package bonfire import ( "bufio" + "errors" "fmt" "io" "os" "strconv" "strings" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" "golang.org/x/term" ) +// ErrAborted is returned by a prompt that the person cancelled (esc or +// ctrl+c on a terminal). +var ErrAborted = errors.New("bonfire: prompt aborted") + +// errSecretNoInteraction is the error of Secret under -n when stdin is a +// terminal: a secret has no default. +var errSecretNoInteraction = errors.New("bonfire: a secret is needed and -n turned prompts off") + +// richPrompts reports whether prompts use huh and the selector: the run is +// interactive and --no-ansi is not set (with --no-ansi a terminal gets the +// plain line prompts). +func (c *console) richPrompts() bool { + return c.canPrompt() && c.globals.Color != ColorNever +} + +// runForm runs a huh form on the console's streams. +func (c *console) runForm(field huh.Field) error { + err := huh.NewForm(huh.NewGroup(field)).WithInput(c.in).WithOutput(c.out).Run() + if errors.Is(err, huh.ErrUserAborted) { + return ErrAborted + } + return err +} + func (c *console) Ask(question, def string) (string, error) { + if c.globals.NoInteraction { + return def, nil + } + if c.richPrompts() { + var answer string + input := huh.NewInput().Title(question).Value(&answer) + if def != "" { + input = input.Placeholder(def) + } + if err := c.runForm(input); err != nil { + return "", err + } + if strings.TrimSpace(answer) == "" { + return def, nil + } + return strings.TrimSpace(answer), nil + } c.writeQuestion(question, def) line, err := c.readLine() if err != nil { @@ -27,9 +71,16 @@ func (c *console) Ask(question, def string) (string, error) { } func (c *console) Confirm(question string, def bool) (bool, error) { - if !c.interactive { + if c.globals.NoInteraction || !c.interactive { return def, nil } + if c.richPrompts() { + answer := def + if err := c.runForm(huh.NewConfirm().Title(question).Value(&answer)); err != nil { + return false, err + } + return answer, nil + } hint := "y/N" if def { hint = "Y/n" @@ -38,9 +89,6 @@ func (c *console) Confirm(question string, def bool) (bool, error) { if err != nil { return false, err } - if line == "" { - return def, nil - } switch strings.ToLower(line) { case "y", "yes": return true, nil @@ -58,7 +106,13 @@ func (c *console) Choice(question string, choices []string, def int) (string, er if def < 0 || def >= len(choices) { def = 0 } - c.Println(c.style(ansiCyan, symPrompt) + " " + question) + if c.globals.NoInteraction { + return choices[def], nil + } + if c.richPrompts() { + return c.selectOne(question, choices, def) + } + c.Println(c.styledText(c.palette.Current.Render(symPrompt)) + " " + question) for i, choice := range choices { mark := " " if i == def { @@ -83,11 +137,47 @@ func (c *console) Choice(question string, choices []string, def int) (string, er return choices[n-1], nil } -// Secret reads a hidden password on a TTY via term.ReadPassword. -// When stdin is not a terminal it reads a plain line and never logs the value. +// selectOne runs the ported selector on the terminal. +func (c *console) selectOne(question string, choices []string, def int) (string, error) { + prog := tea.NewProgram(newSelectorModel(c.palette, question, choices, def), + tea.WithInput(c.in), + tea.WithOutput(c.out), + tea.WithoutSignalHandler(), + tea.WithColorProfile(c.profile()), + ) + final, err := prog.Run() + if err != nil { + return "", err + } + m, ok := final.(selectorModel) + if !ok || m.cancelled || m.chosen < 0 { + return "", ErrAborted + } + return choices[m.chosen], nil +} + +// Secret reads a hidden value: through a password field on an interactive +// terminal, through term.ReadPassword when only stdin is a terminal, and as +// a plain line when stdin is not a terminal (piped input). Under -n a +// terminal stdin fails with a hint, because a secret has no default. func (c *console) Secret(question string) (string, error) { + if c.globals.NoInteraction { + if c.inTTY { + return "", WithHint(errSecretNoInteraction, + "This command reads the value from a prompt or from stdin.", + `printf '%s\n' "$VALUE" | {bin} -n`) + } + return c.readSecretLine() + } + if c.richPrompts() { + var answer string + if err := c.runForm(huh.NewInput().Title(question).EchoMode(huh.EchoModePassword).Value(&answer)); err != nil { + return "", err + } + return answer, nil + } c.writeQuestion(question, "") - if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + if f, ok := c.in.(*os.File); ok && c.inTTY { b, err := term.ReadPassword(int(f.Fd())) c.Println() if err != nil { @@ -95,6 +185,11 @@ func (c *console) Secret(question string) (string, error) { } return string(b), nil } + return c.readSecretLine() +} + +// readSecretLine reads one line of piped input; the value is never logged. +func (c *console) readSecretLine() (string, error) { line, err := c.readLine() if err != nil { if err == io.EOF { @@ -105,12 +200,19 @@ func (c *console) Secret(question string) (string, error) { return line, nil } +// styledText applies the color profile to s for output written with Printf. +func (c *console) styledText(s string) string { + var b strings.Builder + _, _ = io.WriteString(c.styled(&b), s) + return b.String() +} + func (c *console) writeQuestion(question, def string) { suffix := "" if def != "" { - suffix = " " + c.dim("("+def+")") + suffix = " " + c.palette.Muted.Render("("+def+")") } - c.Printf("%s %s%s %s ", c.style(ansiCyan, symPrompt), question, suffix, symArrow) + c.Printf("%s", c.styledText(c.palette.Current.Render(symPrompt)+" "+question+suffix+" "+symArrow+" ")) } func (c *console) readLine() (string, error) { diff --git a/modules/bonfire/selector.go b/modules/bonfire/selector.go new file mode 100644 index 00000000..2f12b2f0 --- /dev/null +++ b/modules/bonfire/selector.go @@ -0,0 +1,140 @@ +package bonfire + +// A single-select port of SSU's selector (internal/cli/tui selector.go, +// selector_keys.go and confirm.go) on bubbletea v2: arrow keys or k/j move, +// enter selects and opens SSU's confirmation screen, enter there confirms +// and esc goes back; esc or ctrl+c in the list cancels. Multi-select, the +// filter and the detail pane are not ported (D-30). + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// selectorKeys are the selector's key bindings. +type selectorKeys struct { + Up key.Binding + Down key.Binding + Confirm key.Binding + Back key.Binding + Quit key.Binding +} + +func defaultSelectorKeys() selectorKeys { + return selectorKeys{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Quit: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "cancel")), + } +} + +// selectorModel picks one item from a list and asks for confirmation. +type selectorModel struct { + palette Palette + keys selectorKeys + title string + items []string + cursor int + height int + confirming bool + chosen int + cancelled bool +} + +func newSelectorModel(p Palette, title string, items []string, def int) selectorModel { + if def < 0 || def >= len(items) { + def = 0 + } + return selectorModel{palette: p, keys: defaultSelectorKeys(), title: title, items: items, cursor: def, chosen: -1} +} + +func (m selectorModel) Init() tea.Cmd { return nil } + +func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.height = msg.Height + return m, nil + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + m.cancelled = true + return m, tea.Quit + } + if m.confirming { + switch { + case key.Matches(msg, m.keys.Confirm): + m.chosen = m.cursor + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + m.confirming = false + } + return m, nil + } + switch { + case key.Matches(msg, m.keys.Up): + if m.cursor > 0 { + m.cursor-- + } + case key.Matches(msg, m.keys.Down): + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case key.Matches(msg, m.keys.Confirm): + if len(m.items) > 0 { + m.confirming = true + } + case key.Matches(msg, m.keys.Quit): + m.cancelled = true + return m, tea.Quit + } + } + return m, nil +} + +// visible is the range of items that fits the terminal around the cursor. +func (m selectorModel) visible() (int, int) { + rows := len(m.items) + if m.height > 0 { + rows = min(rows, max(m.height-4, 3)) + } + start := 0 + if m.cursor >= rows { + start = m.cursor - rows + 1 + } + return start, min(start+rows, len(m.items)) +} + +func (m selectorModel) View() tea.View { + p := m.palette + var b strings.Builder + if m.chosen >= 0 || m.cancelled { + // Leave one summary line once the program ends. + b.WriteString(p.Current.Render(symPrompt) + " " + m.title) + if m.chosen >= 0 { + b.WriteString(" " + p.Success.Render(m.items[m.chosen])) + } + b.WriteString("\n") + return tea.NewView(b.String()) + } + if m.confirming { + b.WriteString(p.Header.Render("Selected:") + "\n\n") + b.WriteString(" " + m.items[m.cursor] + "\n\n") + b.WriteString(p.Muted.Render("enter confirm • esc back")) + return tea.NewView(b.String()) + } + b.WriteString(p.Current.Render(symPrompt) + " " + p.Header.Render(m.title) + "\n") + start, end := m.visible() + for i := start; i < end; i++ { + if i == m.cursor { + b.WriteString(p.Current.Render("> ") + m.items[i] + "\n") + continue + } + b.WriteString(" " + m.items[i] + "\n") + } + b.WriteString(p.Muted.Render("↑/k up • ↓/j down • enter select • esc cancel")) + return tea.NewView(b.String()) +} diff --git a/modules/bonfire/widgets.go b/modules/bonfire/widgets.go index 2509cffd..3ff0c574 100644 --- a/modules/bonfire/widgets.go +++ b/modules/bonfire/widgets.go @@ -1,85 +1,60 @@ package bonfire import ( - "fmt" + "context" "strings" - "sync" - "time" - "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" ) -var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} - -const ( - boxTopLeft = "┌" - boxTopRight = "┐" - boxBottomLeft = "└" - boxBottomRight = "┘" - boxHorizontal = "─" - boxVertical = "│" - boxTeeRight = "├" - boxTeeLeft = "┤" - boxTeeDown = "┬" - boxTeeUp = "┴" - boxCross = "┼" -) +// ItemProgress is the optional side of a Progress that knows about items: +// Current names the item being worked on, Fail records a failed item. The +// Progress handed to Output.Progress implements it; Current and Failed call +// it when present. +type ItemProgress interface { + Progress + Current(item string) + Fail(item string, err error) +} func (c *console) Spinner(message string, fn func() error) error { if fn == nil { fn = func() error { return nil } } - if !c.tty { + if c.globals.Quiet { + return fn() + } + if !c.live() { c.Printf("[...] %s\n", message) return fn() } - - done := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - i := 0 - ticker := time.NewTicker(80 * time.Millisecond) - defer ticker.Stop() - c.Printf("\r%s %s", c.style(ansiCyan, string(spinnerFrames[0])), message) - for { - select { - case <-done: - return - case <-ticker.C: - i++ - frame := spinnerFrames[i%len(spinnerFrames)] - c.Printf("\r%s %s", c.style(ansiCyan, string(frame)), message) - } - } - }() - - err := fn() - close(done) - wg.Wait() - if err != nil { - c.Printf("\r%s %s\n", c.style(ansiRed, symCross), message) - return err - } - c.Printf("\r%s %s\n", c.style(ansiGreen, symCheck), message) - return nil + return runLive(context.Background(), c.out, c, newSpinnerModel(c.palette, message), func(func(tea.Msg)) error { + return fn() + }) } func (c *console) Progress(total int, fn func(Progress) error) error { if total < 1 { total = 1 } - bar := &progressBar{c: c, total: total, lastPct: -1} if fn == nil { return nil } - err := fn(bar) - if c.tty && bar.current >= bar.total { - c.Println() + if c.globals.Quiet { + return fn(&quietProgress{c: c}) } - return err + if !c.live() { + return fn(&progressBar{c: c, total: total, lastPct: -1}) + } + return runLive(context.Background(), c.out, c, newProgressModel(c.palette, total), func(send func(tea.Msg)) error { + return fn(&liveProgress{send: send, total: total}) + }) } +// progressBar prints a line at every tenth of the work when widgets do not +// animate. type progressBar struct { c *console total int @@ -99,10 +74,6 @@ func (p *progressBar) Advance(step int) { if p.total > 0 { pct = p.current * 100 / p.total } - if p.c.tty { - p.renderTTY(pct) - return - } if p.lastPct >= 0 && pct/10 == p.lastPct/10 && pct != 100 { return } @@ -116,29 +87,56 @@ func (p *progressBar) Advance(step int) { } } -func (p *progressBar) renderTTY(pct int) { - width := 40 - filled := 0 - if p.total > 0 { - filled = p.current * width / p.total - } - if filled > width { - filled = width - } - bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled) - color := progressColor(pct) - p.c.Printf("\r %s %5.1f%% %d/%d", p.c.style(color, bar), float64(pct), p.current, p.total) +func (p *progressBar) Current(string) {} + +func (p *progressBar) Fail(item string, err error) { + p.c.Error(failureLine(item, err)) } -func progressColor(pct int) string { - switch { - case pct >= 100: - return "\x1b[38;5;34m" - case pct >= 50: - return "\x1b[38;5;36m" - default: - return "\x1b[38;5;37m" +// quietProgress prints nothing but failures (-q). +type quietProgress struct { + c *console +} + +func (p *quietProgress) Advance(int) {} + +func (p *quietProgress) Current(string) {} + +func (p *quietProgress) Fail(item string, err error) { + p.c.Error(failureLine(item, err)) +} + +// liveProgress drives the live progress model. +type liveProgress struct { + send func(tea.Msg) + total int + current int + item string +} + +func (p *liveProgress) Advance(step int) { + if step < 1 { + step = 1 } + p.current = min(p.current+step, p.total) + p.send(itemMsg{done: p.current, total: p.total, item: p.item}) +} + +func (p *liveProgress) Current(item string) { + p.item = item + p.send(itemMsg{done: p.current, total: p.total, item: item}) +} + +func (p *liveProgress) Fail(item string, err error) { + p.send(itemMsg{done: p.current, total: p.total, item: item, err: err}) + p.send(printAboveMsg(failureLine(item, err))) +} + +func failureLine(item string, err error) string { + if err == nil { + return item + } + return item + ": " + err.Error() } func (c *console) Table(headers []string, rows [][]string) { @@ -149,27 +147,22 @@ func (c *console) Table(headers []string, rows [][]string) { } return } - cols := len(headers) - all := make([][]string, 0, 1+len(rows)) - all = append(all, headers) + padded := make([][]string, 0, len(rows)) for _, row := range rows { - all = append(all, padRow(row, cols)) + padded = append(padded, padRow(row, len(headers))) } - widths := make([]int, cols) - for _, row := range all { - for i, cell := range row { - if n := utf8.RuneCountInString(cell); n > widths[i] { - widths[i] = n + header := c.palette.Header + t := table.New(). + Border(lipgloss.NormalBorder()). + Headers(headers...). + Rows(padded...). + StyleFunc(func(row, _ int) lipgloss.Style { + if row == table.HeaderRow { + return header.Padding(0, 1) } - } - } - c.Println(boxLine(widths, boxTopLeft, boxTeeDown, boxTopRight)) - c.Println(boxRow(headers, widths, true, c)) - c.Println(boxLine(widths, boxTeeRight, boxCross, boxTeeLeft)) - for _, row := range rows { - c.Println(boxRow(padRow(row, cols), widths, false, c)) - } - c.Println(boxLine(widths, boxBottomLeft, boxTeeUp, boxBottomRight)) + return lipgloss.NewStyle().Padding(0, 1) + }) + c.emit(c.out, t.String()+"\n") } func padRow(row []string, n int) []string { @@ -181,30 +174,3 @@ func padRow(row []string, n int) []string { } return out } - -func boxLine(widths []int, left, mid, right string) string { - var b strings.Builder - b.WriteString(left) - for i, w := range widths { - if i > 0 { - b.WriteString(mid) - } - b.WriteString(strings.Repeat(boxHorizontal, w+2)) - } - b.WriteString(right) - return b.String() -} - -func boxRow(cells []string, widths []int, header bool, c *console) string { - var b strings.Builder - b.WriteString(boxVertical) - for i, cell := range cells { - pad := widths[i] - utf8.RuneCountInString(cell) - text := cell - if header { - text = c.style("\x1b[1m", cell) - } - fmt.Fprintf(&b, " %s%s %s", text, strings.Repeat(" ", pad), boxVertical) - } - return b.String() -} diff --git a/modules/bonfire/widgets_smoke_test.go b/modules/bonfire/widgets_smoke_test.go new file mode 100644 index 00000000..892cfb62 --- /dev/null +++ b/modules/bonfire/widgets_smoke_test.go @@ -0,0 +1,184 @@ +package bonfire + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +// frame is a model's view without escape codes. +func frame(m tea.Model) string { return ansi.Strip(m.View().Content) } + +// TestWidgetMatrixSmoke: -q keeps data and errors but drops decoration, -n +// makes prompts return their defaults without reading stdin (Secret with a +// terminal stdin fails with a hint), ConfirmProceed never asks off a +// terminal, and the ported live models render frames from Update and View. +func TestWidgetMatrixSmoke(t *testing.T) { + t.Run("quiet", func(t *testing.T) { + var stdout, stderr bytes.Buffer + c := newConsole(strings.NewReader(""), &stdout, &stderr, func(string) string { return "" }, boolPtr(true), boolPtr(false)) + c.setGlobals(Globals{Quiet: true, NoInteraction: true}) + c.Info("info") + c.Success("success") + c.Warning("warning") + if err := c.Spinner("spinning", func() error { return nil }); err != nil { + t.Fatal(err) + } + if err := c.Progress(4, func(p Progress) error { + for range 4 { + p.Advance(1) + } + Failed(p, "item-2", errors.New("broken")) + return nil + }); err != nil { + t.Fatal(err) + } + c.Println("data line") + c.Table([]string{"key"}, [][]string{{"value"}}) + c.Error("boom") + out := stdout.String() + for _, gone := range []string{"info", "success", "warning", "spinning", "[4/4]"} { + if strings.Contains(out, gone) { + t.Fatalf("-q printed %q:\n%s", gone, out) + } + } + if !strings.Contains(out, "data line\n") || !strings.Contains(out, "value") { + t.Fatalf("-q dropped data:\n%s", out) + } + if !strings.Contains(stderr.String(), "boom") || !strings.Contains(stderr.String(), "item-2: broken") { + t.Fatalf("-q dropped errors: %q", stderr.String()) + } + }) + + t.Run("no-interaction", func(t *testing.T) { + in := strings.NewReader("typed\n") + var buf bytes.Buffer + c := newConsole(in, &buf, &buf, func(string) string { return "" }, boolPtr(true), boolPtr(true)) + c.setGlobals(Globals{NoInteraction: true}) + if got, err := c.Ask("name", "bob"); err != nil || got != "bob" { + t.Fatalf("Ask = %q, %v", got, err) + } + if got, err := c.Confirm("sure?", true); err != nil || !got { + t.Fatalf("Confirm = %v, %v", got, err) + } + if got, err := c.Choice("pick", []string{"a", "b", "c"}, 2); err != nil || got != "c" { + t.Fatalf("Choice = %q, %v", got, err) + } + if in.Len() != len("typed\n") || buf.Len() != 0 { + t.Fatalf("-n prompts read stdin or printed: left %d, out %q", in.Len(), buf.String()) + } + if ok, err := ConfirmProceed(c, "Drop everything?"); err != nil || !ok { + t.Fatalf("ConfirmProceed = %v, %v", ok, err) + } + if Interactive(c) || !GlobalsOf(c).NoInteraction { + t.Fatal("console with -n reports interactive") + } + + c.inTTY = true + _, err := c.Secret("password") + var hint *HintError + if !errors.As(err, &hint) || !strings.Contains(hint.Try, "-n") { + t.Fatalf("Secret with a terminal stdin under -n: %v", err) + } + c.inTTY = false + if got, err := c.Secret("password"); err != nil || got != "typed" { + t.Fatalf("Secret with piped stdin under -n = %q, %v", got, err) + } + }) + + t.Run("not interactive", func(t *testing.T) { + in := strings.NewReader("y\n") + var buf bytes.Buffer + out := NewOutput(in, &buf, &buf) + if ok, err := ConfirmProceed(out, "Drop everything?"); err != nil || !ok || in.Len() != 2 { + t.Fatalf("ConfirmProceed off a terminal = %v, %v (stdin left %d)", ok, err, in.Len()) + } + if VerbosityOf(out) != 0 || IsQuiet(out) { + t.Fatal("zero globals expected") + } + var lines bytes.Buffer + errOut := NewOutput(strings.NewReader(""), &lines, &lines) + err := Process(context.Background(), errOut, "Importing", []string{"a", "b"}, func(_ context.Context, item string) error { + if item == "b" { + return errors.New("bad row") + } + return nil + }) + if err == nil || !strings.Contains(err.Error(), "b: bad row") { + t.Fatalf("Process err = %v", err) + } + if got := lines.String(); got != "✓ a\n✗ b: bad row\n" { + t.Fatalf("Process lines = %q", got) + } + }) + + size := tea.WindowSizeMsg{Width: 80, Height: 24} + p := DefaultPalette() + + t.Run("spinner model", func(t *testing.T) { + var m tea.Model = newSpinnerModel(p, "Importing posts") + m, _ = m.Update(size) + m, _ = m.Update(itemMsg{done: 2, total: 5, item: "post-2"}) + if v := frame(m); !strings.Contains(v, "Importing posts 2/5") || !strings.Contains(v, "post-2") { + t.Fatalf("spinner frame = %q", v) + } + m, cmd := m.Update(finishMsg{}) + if cmd == nil || !strings.Contains(frame(m), symCheck+" Importing posts") { + t.Fatalf("spinner final frame = %q", frame(m)) + } + }) + + t.Run("progress model", func(t *testing.T) { + var m tea.Model = newProgressModel(p, 10) + m, _ = m.Update(size) + m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"}) + if v := frame(m); !strings.Contains(v, "4/10") || !strings.Contains(v, "row 4") { + t.Fatalf("progress frame = %q", v) + } + }) + + t.Run("process model", func(t *testing.T) { + var m tea.Model = newProcessModel(p, "Pushing", []string{"blog", "shop", "forum"}) + m, _ = m.Update(size) + m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1}) + m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2}) + m, _ = m.Update(stepMsg{item: "forum", state: itemRunning, done: 2}) + v := frame(m) + for _, want := range []string{symCheck + " blog", symCross + " shop", "rejected", "forum", "Pushing 2/3"} { + if !strings.Contains(v, want) { + t.Fatalf("process frame missing %q:\n%s", want, v) + } + } + }) + + t.Run("selector model", func(t *testing.T) { + var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 0) + m, _ = m.Update(size) + if v := frame(m); !strings.Contains(v, "> blog") { + t.Fatalf("selector frame = %q", v) + } + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "shop") { + t.Fatalf("confirmation frame = %q", v) + } + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m, _ = m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + sel := m.(selectorModel) + if cmd == nil || sel.chosen != 2 || sel.items[sel.chosen] != "forum" { + t.Fatalf("selector chose %d (cmd %v)", sel.chosen, cmd != nil) + } + var c tea.Model = newSelectorModel(p, "Pick", []string{"a"}, 0) + c, _ = c.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if !c.(selectorModel).cancelled { + t.Fatal("esc did not cancel") + } + }) +} diff --git a/modules/diary/README.md b/modules/diary/README.md index 9bffa32f..64d9432a 100644 --- a/modules/diary/README.md +++ b/modules/diary/README.md @@ -29,7 +29,7 @@ Use cases: - Component levels: `log.components` sets a minimum level per component (`river: warn`, `blog: debug`); components without one use `log.level`. - Environment overrides: `SUMMER_LOG__` variables overlay the defaults key by key, and string values parse where YAML gives numbers or lists (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`). - Validation: an unknown driver, format or level, a stack naming a missing channel or containing itself, and a relative daily path that leaves the application root fail `diary.Setup` with an error naming the key. -- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences. +- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences. While a bonfire live view (spinner, progress bar, process view) runs, styled lines print above it through `bonfire.PrintAbove`. - Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days (only files of that name pattern). A relative path resolves against the application root. A directory that cannot be written logs one warning from component `diary` on the console and disables the channel; it never fails the start-up. - Redaction: every channel leaf is wrapped by `sunscreen.Wrap`. - Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn. diff --git a/modules/diary/console.go b/modules/diary/console.go index 11e21aa4..3f936b11 100644 --- a/modules/diary/console.go +++ b/modules/diary/console.go @@ -39,7 +39,8 @@ type ConsoleOptions struct { // as dotted key prefixes. Control characters other than tab are removed from // the message and attribute values, so logged data cannot inject terminal // escape sequences. Each line is written with one Write call, serialized -// across the handler and its WithAttrs and WithGroup clones. +// across the handler and its WithAttrs and WithGroup clones; while a bonfire +// live view runs, lines print above it through bonfire.PrintAbove. func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler { source := new(atomic.Bool) source.Store(opts.AddSource) @@ -154,6 +155,11 @@ func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error { b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line))) } } + // While a bonfire live view (spinner, progress, process view) runs, + // the line prints above it instead of tearing it. + if bonfire.PrintAbove(b.String()) { + return nil + } b.WriteByte('\n') h.mu.Lock()