List every module's preferences in the settings verb (hq ADR 0262)

One verb is the interface to every preference, so no module builds a settings tool of its own: each
key, its default and why, and every assigned machine's value with its source.
This commit is contained in:
jochen
2026-10-08 17:24:32 +02:00
parent 76babaea52
commit f5680ba8da
4 changed files with 168 additions and 5 deletions
+96 -2
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net"
"os"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/broker"
@@ -396,7 +397,8 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
func settingsCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("settings show <module> [--node <node>] [--history], settings set <module> <file> " +
"[--node <node>] [--replace], or settings clear <module> [--node <node>]")
"[--node <node>] [--replace], settings clear <module> [--node <node>], or settings preferences " +
"[<module>] [--node <node>]")
}
open, err := openStores(ctx)
if err != nil {
@@ -536,6 +538,46 @@ func settingsCommand(ctx context.Context, args []string) error {
fmt.Print(describeEffective(positionals[0], where, catalogue.Effective(m, layers)))
return nil
case "preferences":
// Every module's preferences, and each machine's value with its source (novox/hq ADR 0262).
if len(positionals) > 1 {
return errors.New("settings preferences [<module>] [--node <node>]")
}
only := ""
if len(positionals) == 1 {
only = positionals[0]
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return err
}
var listed []preferencesOf
for _, e := range entries {
m := e.Manifest
if len(m.Settings) == 0 || (only != "" && m.Module != only) {
continue
}
p := preferencesOf{Manifest: m, On: map[string][]catalogue.SettingSource{}}
for _, n := range e.On {
if *node != "" && n != *node {
continue
}
p.Nodes = append(p.Nodes, n)
layers, err := inv.SettingsFor(ctx, n, m.Module)
if err != nil {
return err
}
p.On[n] = catalogue.Effective(m, layers)
}
listed = append(listed, p)
}
if only != "" && len(listed) == 0 {
fmt.Printf("%s declares no preferences\n", only)
return nil
}
fmt.Print(describePreferences(listed))
return nil
case "clear":
if len(positionals) != 1 {
return errors.New("settings clear <module> [--node <node>]")
@@ -547,7 +589,7 @@ func settingsCommand(ctx context.Context, args []string) error {
return nil
default:
return fmt.Errorf("settings has no %q; it has show, set and clear", args[0])
return fmt.Errorf("settings has no %q; it has show, set, clear and preferences", args[0])
}
}
@@ -568,6 +610,58 @@ func describeEffective(module, where string, values []catalogue.SettingSource) s
return b.String()
}
// preferencesOf is one module's preferences and its value on each machine it is assigned to.
type preferencesOf struct {
Manifest catalogue.Manifest
Nodes []string
On map[string][]catalogue.SettingSource
}
// describePreferences lists each module's preferences — key, default and why — and, per machine it is
// assigned to, the value and where it comes from (novox/hq ADR 0262).
func describePreferences(modules []preferencesOf) string {
if len(modules) == 0 {
return "no module declares a preference\n"
}
var b strings.Builder
for i, p := range modules {
if i > 0 {
b.WriteString("\n")
}
on := "assigned nowhere"
if len(p.Nodes) > 0 {
on = "on " + strings.Join(p.Nodes, ", ")
}
fmt.Fprintf(&b, "%s (%s)\n", p.Manifest.Module, on)
keys := make([]string, 0, len(p.Manifest.Settings))
for k := range p.Manifest.Settings {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
d := p.Manifest.Settings[k]
def, _ := json.Marshal(d.Default)
fmt.Fprintf(&b, " %s, default %s: %s\n", k, def, d.Why)
for _, n := range p.Nodes {
for _, s := range p.On[n] {
if s.Key != k {
continue
}
v, _ := json.Marshal(s.Value)
from := s.From
if s.FromDefault {
from = catalogue.DefaultLayer
} else if s.From != catalogue.MeshWideLayer {
from = "the node"
}
fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, from)
}
}
}
}
return b.String()
}
// nodeFlag is ` --node <node>` for a machine's layer, nothing for the whole mesh's.
func nodeFlag(node string) string {
if node == "" {
+16
View File
@@ -761,6 +761,22 @@ func (a *verbArguments) commandLine() ([]string, error) {
}
return argv, nil
case "settings":
// Every module's preferences, their defaults and each machine's value (novox/hq ADR 0262):
// the one interface for them, so no module builds a settings tool of its own. Asked for by
// name, or by naming no module, since a layer is always some module's.
if list := str("list"); list != "" || str("module") == "" {
if list != "" && list != "preferences" {
return nil, fmt.Errorf("settings lists %q only; %q is not a listing", "preferences", list)
}
argv := []string{"settings", "preferences"}
if m := str("module"); m != "" {
argv = append(argv, m)
}
if n := str("node"); n != "" {
argv = append(argv, "--node", n)
}
return argv, nil
}
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
// because a tool has no file to hand the command; the command reads either.
if err := need("module"); err != nil {
@@ -1,6 +1,7 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -19,3 +20,52 @@ func TestSettingsSayWhereEachValueComesFrom(t *testing.T) {
t.Fatalf("said\n%s\nwant\n%s", got, want)
}
}
// `settings` with list "preferences" is the one listing of every module's preferences; module and node
// narrow it, and no other listing is taken.
func TestSettingsListPreferences(t *testing.T) {
for _, c := range []struct {
args map[string]any
want string
}{
{map[string]any{"list": "preferences"}, "settings preferences"},
{map[string]any{"list": "preferences", "module": "dunst"}, "settings preferences dunst"},
{map[string]any{"list": "preferences", "node": "laptop"}, "settings preferences --node laptop"},
} {
argv, err := argvFor("settings", c.args)
if err != nil || strings.Join(argv, " ") != c.want {
t.Errorf("%v: %v %v, want %s", c.args, argv, err, c.want)
}
}
if _, err := argvFor("settings", map[string]any{"list": "everything"}); err == nil {
t.Error("a listing other than preferences was taken")
}
if argv, err := argvFor("settings", map[string]any{}); err != nil || strings.Join(argv, " ") != "settings preferences" {
t.Errorf("settings naming no module is the listing: %v %v", argv, err)
}
}
func TestPreferencesSayEachMachinesValueAndItsSource(t *testing.T) {
m := catalogue.Manifest{Module: "dunst", Settings: map[string]catalogue.SettingDeclaration{
"font-size": {Kind: catalogue.KindPreference, Default: float64(10), Why: "readable at 100 DPI"},
"width": {Kind: catalogue.KindPreference, Default: float64(250), Why: "forty characters"},
}}
on := map[string][]catalogue.SettingSource{
"laptop": catalogue.Effective(m, []catalogue.Layer{{From: "laptop", Values: map[string]any{"font-size": float64(16)}}}),
"desk": catalogue.Effective(m, []catalogue.Layer{{From: catalogue.MeshWideLayer, Values: map[string]any{"width": float64(300)}}}),
}
got := describePreferences([]preferencesOf{{Manifest: m, Nodes: []string{"desk", "laptop"}, On: on}})
want := "dunst (on desk, laptop)\n" +
" font-size, default 10: readable at 100 DPI\n" +
" desk: 10 (default)\n" +
" laptop: 16 (the node)\n" +
" width, default 250: forty characters\n" +
" desk: 300 (the mesh)\n" +
" laptop: 250 (default)\n"
if got != want {
t.Fatalf("said\n%s\nwant\n%s", got, want)
}
if describePreferences(nil) != "no module declares a preference\n" {
t.Fatal("an empty listing")
}
}
+6 -3
View File
@@ -251,19 +251,22 @@ var ControllerVerbs = []Verb{
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
"then every value the module gives a default or a layer sets, with where it comes from: the module's default, " +
"the mesh, or the machine (novox/hq ADR 0262); " +
"the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " +
"module's preferences and each machine's value; " +
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
"what its definition says; a cleared or replaced layer is kept in the history.",
Input: schema(map[string]string{
"module": "the module's name",
"module": "the module's name; with list, only that module's",
"values": "the settings as a JSON object, for set",
"node": "one machine; the whole mesh when absent",
"clear": "\"true\" to remove the layer instead of setting it; not with values",
"replace": "\"true\": with values, the set is meant to remove the keys the layer had and it does not name",
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
}, []string{"module"}, "clear", "replace", "history")},
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
}, nil, "clear", "replace", "history")},
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +