diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 51640a5..a7d99ef 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -48,6 +48,21 @@ func argvFor(verb string, args map[string]any) ([]string, error) { return nil } switch verb { + case "command": + // The generic verb: the command line as given, split as a shell would split it, with + // nothing added — the named verbs add flags a caller cannot reach; this one is the whole + // binary and says so in its description (novox/hq ADR 0154, 0175). + if err := need("command"); err != nil { + return nil, err + } + argv, err := splitCommandLine(str("command")) + if err != nil { + return nil, err + } + if len(argv) == 0 { + return nil, errors.New("command names no command") + } + return argv, nil case "status": return []string{"status", "--json"}, nil case "nodes": @@ -289,3 +304,57 @@ func sampleArguments(v catalogue.Verb) map[string]any { } return sample } + +// splitCommandLine splits a command line into words the way a POSIX shell does for the simple +// cases a controller command needs: spaces separate, single or double quotes group, a backslash +// escapes the next character inside double quotes or outside any. No expansion of anything. +func splitCommandLine(line string) ([]string, error) { + var words []string + var cur strings.Builder + inWord := false + quote := rune(0) + runes := []rune(line) + for i := 0; i < len(runes); i++ { + r := runes[i] + switch { + case quote == '\'': + if r == '\'' { + quote = 0 + } else { + cur.WriteRune(r) + } + case quote == '"': + if r == '"' { + quote = 0 + } else if r == '\\' && i+1 < len(runes) { + i++ + cur.WriteRune(runes[i]) + } else { + cur.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + inWord = true + case r == '\\' && i+1 < len(runes): + i++ + cur.WriteRune(runes[i]) + inWord = true + case r == ' ' || r == '\t' || r == '\n': + if inWord { + words = append(words, cur.String()) + cur.Reset() + inWord = false + } + default: + cur.WriteRune(r) + inWord = true + } + } + if quote != 0 { + return nil, fmt.Errorf("command has an unclosed %c quote", quote) + } + if inWord { + words = append(words, cur.String()) + } + return words, nil +} diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index 9bbc6b7..a072ec1 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -171,3 +171,27 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) { t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output) } } + +// `command` is the generic verb: the command line as given, split as a shell would, nothing added — +// so an operator's `node account g14 jochen` is one call through the console rather than a shell on +// the control node (novox/hq ADR 0154, ADR 0175). +func TestCommandRunsTheLineAsGiven(t *testing.T) { + argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"}) + if err != nil || strings.Join(argv, " ") != "node account g14 jochen" { + t.Fatalf("a plain line: %v %v", argv, err) + } + argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`}) + if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` { + t.Fatalf("a quoted word stays one word: %q %v", argv, err) + } + argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`}) + if err != nil || len(argv) != 4 || argv[2] != "the box" { + t.Fatalf("double quotes group: %q %v", argv, err) + } + if _, err := argvFor("command", map[string]any{"command": " "}); err == nil { + t.Fatal("an empty line was accepted") + } + if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil { + t.Fatal("an unclosed quote was accepted") + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 61d77cb..f8f52bd 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -145,6 +145,13 @@ var ControllerVerbs = []Verb{ "node": "one machine; the whole mesh when absent", "clear": "\"true\" to remove the layer instead of setting it", }, []string{"module"})}, + {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 " + + "per command. Any node may call any tool (ADR 0175), so nothing is held back here.", + Input: schema(map[string]string{ + "command": "the command line, as the controller's binary takes it; quotes group a word with spaces", + }, []string{"command"})}, {Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " + "`builds` with that id follows it line by line, and the module is registered when the outcome comes.", Input: schema(map[string]string{