diff --git a/modules/mesh-cli/cmd/mesh-cli/commands.go b/modules/mesh-cli/cmd/mesh-cli/commands.go new file mode 100644 index 00000000..98a30c23 --- /dev/null +++ b/modules/mesh-cli/cmd/mesh-cli/commands.go @@ -0,0 +1,714 @@ +package main + +// Every command line nox takes, and the seat verb or module tool that owns its job now that the controller is +// unassigned (novox/hq issue 1126; the map is hq's .work/1126/nox-map.md). A line no seat or module owns says so and +// names what to use instead: it never fails silently, and it never pretends to have run. + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +// line is a command line's words after the command's own: positionals and --flags, in any order. +type line struct { + pos []string + flags map[string]string +} + +// boolFlags are the flags that take no value. +var boolFlags = map[string]bool{"json": true, "confirm": true, "collected": true, "brief": true, "all": true, + "reasoning": true, "clear": true} + +func parseLine(args []string) (line, error) { + l := line{flags: map[string]string{}} + for i := 0; i < len(args); i++ { + a := args[i] + if !strings.HasPrefix(a, "--") || a == "--" { + l.pos = append(l.pos, a) + continue + } + name, value, has := strings.Cut(strings.TrimPrefix(a, "--"), "=") + if boolFlags[name] { + if has { + return l, usageErr("--%s takes no value", name) + } + l.flags[name] = "true" + continue + } + if !has { + if i+1 >= len(args) { + return l, usageErr("--%s needs a value", name) + } + i++ + value = args[i] + } + l.flags[name] = value + } + return l, nil +} + +func (l line) has(flag string) bool { return l.flags[flag] != "" } + +// usageError is a line nox cannot read: nothing was called. +type usageError struct{ msg string } + +func (u usageError) Error() string { return u.msg } + +func usageErr(format string, a ...any) error { return usageError{fmt.Sprintf(format, a...)} } + +// call is what one line asks: an address and its arguments. +type call struct { + Address string + Args map[string]any +} + +// command is one of nox's lines. +type command struct { + words []string + usage string + does string + // build reads the line and answers the call; nil for a line no seat owns. + build func(l line) (call, error) + // allowed are the flags the line reads; any other is refused before anything is called. + allowed []string + // instead says, for a line with no owner, what to use; empty for one that is owned. + instead string + // render shapes a successful answer for reading; nil prints it as the console gave it. + render func(text string) string +} + +// gone is the reason every unowned line gives. +const gone = "this was the controller's, and the controller is unassigned (novox/hq issue 1126). Nothing was called" + +// commands is every line, longest words first within a word. +var commands = []command{ + // What runs where. + {words: []string{"status"}, does: "what is wrong, what is quiet, what is moving (the delivery view)", + build: fixed("mesh-delivery-view.view_status")}, + {words: []string{"nodes"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"), + render: renderNodes}, + {words: []string{"node", "list"}, does: "the nodes placement holds", build: fixed("mesh-placement.placement"), + render: renderNodes}, + {words: []string{"node", "show"}, usage: "", does: "one node's placement: what it runs and its decided fields", + build: nodeArg("mesh-placement.placement")}, + {words: []string{"node", "public-domain"}, usage: " [ | --clear] [--why ]", + does: "the domain a node composes its routed names under; with a domain or --clear, proposed to placement", + allowed: []string{"clear", "why"}, build: publicDomain}, + {words: []string{"node", "add"}, instead: "a new machine joins through the seat mesh-enrolment: nox enrol issue --why "}, + {words: []string{"node", "networks"}, instead: "no seat keeps a node's routed networks yet; mesh-placement.placement shows what a node decides"}, + {words: []string{"plan"}, usage: "", does: "what the assigner wants the node to run", build: optNode("mesh-delivery-view.assigner_wants")}, + {words: []string{"haves"}, usage: "[]", does: "what each node runs, as its node-engine said", build: optNode("mesh-delivery-view.node_haves")}, + {words: []string{"switch"}, does: "what each node runs, as its node-engine said", build: fixed("mesh-delivery-view.node_haves")}, + {words: []string{"composition"}, usage: " ", does: "one module's composition on a node beside what runs", + build: func(l line) (call, error) { + if len(l.pos) != 2 { + return call{}, usageErr("give the node and the module") + } + return call{l.pos[0] + "/node-engine.composition", map[string]any{"module": l.pos[1]}}, nil + }}, + {words: []string{"seats"}, does: "who holds each seat, and where", build: func(line) (call, error) { + return call{"mesh-placement.placement", map[string]any{"seats": "true"}}, nil + }}, + {words: []string{"seat"}, instead: "a seat is held by placing its module: nox assign --why (mesh-placement.propose)"}, + {words: []string{"assign"}, usage: " ... --why ", does: "propose placing modules on a node", + allowed: []string{"why"}, build: placeModules("place")}, + {words: []string{"unassign"}, usage: " ... --why ", does: "propose taking modules off a node", + allowed: []string{"why"}, build: placeModules("take-off")}, + {words: []string{"pin"}, usage: " --why ", does: "propose which provider a node gets a provision from", + allowed: []string{"why"}, build: func(l line) (call, error) { + if len(l.pos) != 4 { + return call{}, usageErr("give the node, the provision, the provider's node and its module") + } + return propose(l, map[string]any{"op": "pin", "node": l.pos[0], "provision": l.pos[1], + "provider": map[string]any{"node": l.pos[2], "module": l.pos[3]}}) + }}, + {words: []string{"unpin"}, usage: " --why ", does: "propose putting that question back", + allowed: []string{"why"}, build: func(l line) (call, error) { + if len(l.pos) != 2 { + return call{}, usageErr("give the node and the provision") + } + return propose(l, map[string]any{"op": "unpin", "node": l.pos[0], "provision": l.pos[1]}) + }}, + {words: []string{"proposals"}, usage: "[]", does: "the open proposals of placement", build: optArg("mesh-placement.proposals", "id")}, + {words: []string{"overlay", "show"}, does: "every node's placement, with its tunnel", build: fixed("mesh-placement.placement")}, + {words: []string{"overlay"}, instead: "a node's tunnel is a decided field of its placement: mesh-placement.propose with op decide"}, + + // Deliveries and builds. + {words: []string{"deliveries"}, usage: "[--state s] [--repository owner/repo] [--group g] [--all]", + does: "every delivery not final, and those that ended in the last day", + allowed: []string{"state", "repository", "group", "all"}, build: func(l line) (call, error) { + return call{"mesh-delivery.deliveries", pick(l, "state", "repository", "group", "all")}, nil + }}, + {words: []string{"delivery"}, usage: "", does: "one delivery or group whole", build: needArg("mesh-delivery.show", "id")}, + {words: []string{"groups"}, usage: "[--all]", does: "every delivery group", allowed: []string{"all"}, + build: func(l line) (call, error) { return call{"mesh-delivery.groups", pick(l, "all")}, nil }}, + {words: []string{"stalled"}, does: "every delivery held past its state's bound", build: fixed("mesh-delivery.stalled")}, + {words: []string{"checks"}, usage: " [--number n | --commit c]", does: "what the mesh's checks said of a head", + allowed: []string{"number", "commit"}, build: func(l line) (call, error) { + if len(l.pos) != 1 { + return call{}, usageErr("give the repository, owner/repo") + } + a := pick(l, "number", "commit") + a["repository"] = l.pos[0] + return call{"mesh-delivery.checks", a}, nil + }}, + {words: []string{"try"}, usage: " [--level l] [--trunk t] [--tests t]", + does: "try a pushed head before its pull request is opened", allowed: []string{"level", "trunk", "tests"}, + build: func(l line) (call, error) { + if len(l.pos) != 2 { + return call{}, usageErr("give the repository and the head") + } + a := pick(l, "level", "trunk", "tests") + a["repository"], a["head"] = l.pos[0], l.pos[1] + return call{"mesh-delivery-checker.try", a}, nil + }}, + {words: []string{"builds"}, usage: "[--since ]", does: "every build that ended since (the last day by default)", + allowed: []string{"since"}, build: func(l line) (call, error) { + since := l.flags["since"] + if since == "" { + since = now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) + } + return call{"mesh-delivery-builder.builder_builds_since", map[string]any{"since": since}}, nil + }}, + {words: []string{"upgrade"}, usage: "[] [--policy roll|together|record|default --why ]", + does: "whether each module's new builds wait for a person; with --policy, a person's choice", + allowed: []string{"policy", "why"}, build: func(l line) (call, error) { + if len(l.pos) > 1 { + return call{}, usageErr("give at most one module; rolling out is the delivery's: nox deliveries") + } + a := pick(l, "policy", "why") + if len(l.pos) == 1 { + a["module"] = l.pos[0] + } + if a["policy"] != nil && (a["module"] == nil || a["why"] == nil) { + return call{}, usageErr("a policy needs the module and --why") + } + return call{"mesh-wants.upgrade", a}, nil + }}, + {words: []string{"bus"}, does: "what a bus upgrade would do", build: fixed("mesh-wants.bus")}, + + // Conditions, the self-check, the healers and hand acts. + {words: []string{"conditions", "show"}, usage: "", does: "one condition whole", build: needArg("mesh-conditions.conditions", "key")}, + {words: []string{"conditions", "silence"}, usage: " --for <30m|4h|2d> --why [--cause ]", + does: "send no message for it a while; a hand act", allowed: []string{"for", "why", "cause"}, + build: func(l line) (call, error) { + if len(l.pos) != 1 || !l.has("for") || !l.has("why") { + return call{}, usageErr("give the key, --for and --why") + } + a := pick(l, "for", "why", "cause") + a["key"] = l.pos[0] + return call{"mesh-conditions.silence", a}, nil + }}, + {words: []string{"conditions", "history"}, usage: "[--days N] [--key K] [--limit N]", does: "every raising, change and clearing lately", + allowed: []string{"days", "key", "limit"}, build: func(l line) (call, error) { + if l.has("key") { + return call{"mesh-conditions.conditions_history", pick(l, "key", "limit")}, nil + } + a := pick(l, "days") + a["history"] = "true" + return call{"mesh-conditions.conditions", a}, nil + }}, + {words: []string{"conditions"}, usage: "[--scope S] [--severity S] [--machine M]", does: "what is wrong now, urgent first", + allowed: []string{"scope", "severity", "machine"}, build: func(l line) (call, error) { + if len(l.pos) > 0 { + return call{}, usageErr("%q is not a conditions line", l.pos[0]) + } + return call{"mesh-conditions.conditions", pick(l, "scope", "severity", "machine")}, nil + }}, + {words: []string{"doctor", "probes"}, does: "the self-check's probes", build: fixed("mesh-self-check.probes")}, + {words: []string{"doctor", "run"}, does: "run every probe now", build: func(line) (call, error) { + return call{"mesh-self-check.doctor", map[string]any{"run": true}}, nil + }}, + {words: []string{"doctor", "probe"}, usage: "", does: "one probe's verdict whole", build: needArg("mesh-self-check.doctor", "probe")}, + {words: []string{"doctor", "signals"}, does: "the watchman's signals and their ages", build: fixed("mesh-watchdogs.signals")}, + {words: []string{"doctor"}, does: "the self-check's last verdict", build: fixed("mesh-self-check.doctor")}, + {words: []string{"healers"}, usage: "[--days N]", does: "the healers, what they did lately, and their brake", + allowed: []string{"days"}, build: func(l line) (call, error) { + a, err := numbers(pick(l, "days"), "days") + return call{"mesh-healers.healers", a}, err + }}, + {words: []string{"hand-act", "record"}, usage: " --why [--cause ] [--condition ]", + does: "record an act done by hand", allowed: []string{"why", "cause", "condition"}, + build: func(l line) (call, error) { + if len(l.pos) == 0 || !l.has("why") { + return call{}, usageErr("give what was done and --why") + } + a := pick(l, "why", "cause", "condition") + a["what"] = strings.Join(l.pos, " ") + return call{"mesh-hand-acts.hand-act", a}, nil + }}, + {words: []string{"hand-acts"}, usage: "[--days N] [--cause c] [--who w]", does: "what was done by hand lately", + allowed: []string{"days", "cause", "who"}, build: func(l line) (call, error) { + a, err := numbers(pick(l, "days", "cause", "who"), "days") + return call{"mesh-hand-acts.hand-acts", a}, err + }}, + {words: []string{"stuck"}, does: "what stopped moving, as the watchman holds it", build: fixed("mesh-watchdogs.stuck")}, + {words: []string{"dead-letters"}, usage: "[--consumer c]", does: "what a consumer gave up on", allowed: []string{"consumer"}, + build: func(l line) (call, error) { return call{"mesh-watchdogs.dead-letters", pick(l, "consumer")}, nil }}, + + // Config, secrets, the vault, artifacts, licences, modules. + {words: []string{"settings", "show"}, usage: " [--node n]", does: "a module's config values", + allowed: []string{"node"}, build: func(l line) (call, error) { + if len(l.pos) != 1 { + return call{}, usageErr("give the module") + } + a := pick(l, "node") + a["action"], a["module"] = "list", l.pos[0] + return call{"mesh-config.config", a}, nil + }}, + {words: []string{"settings", "set"}, usage: " --node n --why ", + does: "set one of a module's config values on one node", allowed: []string{"node", "why"}, + build: func(l line) (call, error) { return configChange(l, "set") }}, + {words: []string{"settings", "clear"}, usage: " --node n --why ", + does: "clear one of a module's config values on one node", allowed: []string{"node", "why"}, + build: func(l line) (call, error) { return configChange(l, "clear") }}, + {words: []string{"config"}, usage: "[list|set|clear|history|propose|proposals] [--module m] [--node n] [--key k] [--value v] [--why w]", + does: "the mesh's config values, at any level", allowed: []string{"module", "node", "key", "value", "why", "limit", "proposal", "pull", "clear"}, + build: func(l line) (call, error) { + if len(l.pos) > 1 { + return call{}, usageErr("give at most one action") + } + a := pick(l, "module", "node", "key", "why", "proposal", "pull", "clear", "limit") + if len(l.pos) == 1 { + a["action"] = l.pos[0] + } + if v, ok := l.flags["value"]; ok { + a["value"] = jsonOrText(v) + } + if c, ok := a["clear"]; ok { + a["clear"] = c == "true" + } + a, err := numbers(a, "limit") + return call{"mesh-config.config", a}, err + }}, + {words: []string{"secret", "export"}, does: "every secret the vault holds, as its copy sealed to the operator key", + build: fixed("mesh-vault.secret_vault_export")}, + {words: []string{"secret", "accept"}, instead: "mesh-vault.accept takes a value from the controller alone, and no seat takes one from a person yet; a module's own secret is asked at a desk (mesh-vault.secret-ask)"}, + {words: []string{"secret", "recover"}, instead: "opening a sealed copy locally is not built yet (beside nox enrol open); nox secret export gives the sealed copies"}, + {words: []string{"secret"}, instead: "nox secret export; mesh-vault.rotate makes one anew"}, + {words: []string{"operator", "key", "show"}, does: "what needs a person's eye in the vault, the operator key among it", build: fixed("mesh-vault.vault_status")}, + {words: []string{"operator"}, instead: "no seat sets or makes the operator key yet; nox operator key show reads it (mesh-vault.vault_status)"}, + {words: []string{"rotate"}, usage: "[--node n --module m --name s | --provider n --provision p] --why ", + does: "make a secret anew", allowed: []string{"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "why"}, + build: func(l line) (call, error) { + if len(l.pos) == 1 { + l.flags["provision"] = l.pos[0] + } else if len(l.pos) > 1 { + return call{}, usageErr("give the secret by its flags") + } + if !l.has("why") { + return call{}, usageErr("give --why") + } + return call{"mesh-vault.rotate", pick(l, "node", "module", "name", "provider", "provision", "consumer", "consumer_module", "why")}, nil + }}, + {words: []string{"identity", "show"}, does: "the vault's signing key and authority", build: fixed("mesh-vault.vault_signers")}, + {words: []string{"retire", "approve"}, usage: "... --why ", does: "retire the set waiting for a person", + allowed: []string{"why"}, build: retirement("mesh-vault.provisioner_retire_approve")}, + {words: []string{"retire", "reject"}, usage: "... --why ", does: "keep the set waiting for a person active", + allowed: []string{"why"}, build: retirement("mesh-vault.provisioner_retire_reject")}, + {words: []string{"retire"}, does: "every retirement waiting for a person", build: fixed("mesh-vault.provisioner_retirement")}, + {words: []string{"cleanup", "delete"}, usage: " --why --confirm", does: "delete one retired holder from the ledger", + allowed: []string{"why", "confirm"}, build: func(l line) (call, error) { + if len(l.pos) != 1 || !l.has("why") { + return call{}, usageErr("give the consumer and --why") + } + a := pick(l, "why", "confirm") + a["consumer"] = l.pos[0] + return call{"mesh-vault.provisioner_delete", a}, nil + }}, + {words: []string{"cleanup"}, does: "every retired holder per provider", build: fixed("mesh-vault.provisioner_retirement")}, + {words: []string{"artifacts"}, usage: "[--collected]", does: "every artifact the mesh made: kept and why, or eligible", + allowed: []string{"collected"}, build: func(l line) (call, error) { + return call{"mesh-artifact-sweep.artifacts", pick(l, "collected")}, nil + }}, + {words: []string{"collection"}, does: "every artifact the mesh made: kept and why, or eligible", build: fixed("mesh-artifact-sweep.artifacts")}, + {words: []string{"collect"}, usage: "[--confirm --why ] [--most n]", does: "the sweep: a dry run, or with --confirm the sweep now", + allowed: []string{"confirm", "why", "most"}, build: func(l line) (call, error) { + if l.has("confirm") && !l.has("why") { + return call{}, usageErr("--confirm needs --why") + } + return call{"mesh-artifact-sweep.collect", pick(l, "confirm", "why", "most")}, nil + }}, + {words: []string{"images"}, usage: "", does: "every container image a machine's wants and haves name", build: needArg("mesh-artifact-sweep.images", "node")}, + {words: []string{"mirrors"}, does: "the bases the mesh copied into its store", build: fixed("mesh-artifact-sweep.mirrors")}, + {words: []string{"licence", "list"}, does: "every licence the manager holds", build: fixed("anthropic-licence-manager.licences")}, + {words: []string{"licence", "use"}, usage: " ", does: "move a node's agent to another licence", + build: func(l line) (call, error) { + if len(l.pos) != 2 { + return call{}, usageErr("give the consumer (a node) and the licence") + } + return call{"anthropic-licence-manager.switch", map[string]any{"consumer": l.pos[0], "licence": l.pos[1]}}, nil + }}, + {words: []string{"licence", "refresh"}, usage: "[]", does: "refresh a licence now, or every due one", build: optArg("anthropic-licence-manager.refresh", "licence")}, + {words: []string{"licence", "key"}, does: "the manager's public key", build: fixed("anthropic-licence-manager.public-key")}, + {words: []string{"licence"}, instead: "anthropic-licence-manager.adopt takes an API key from a file on the manager's node; nox licence list|use|refresh|key"}, + {words: []string{"module", "list"}, does: "every module the indexer holds", build: fixed("mesh-delivery-view.indexer_modules")}, + {words: []string{"module", "show"}, usage: "", does: "what the indexer holds of one module", build: needArg("mesh-delivery-view.indexer_module", "module")}, + {words: []string{"module", "moved"}, usage: " --why ", does: "take a registered module in now", allowed: []string{"why"}, + build: func(l line) (call, error) { + if len(l.pos) < 1 { + return call{}, usageErr("give the module") + } + return call{"mesh-delivery-indexer.indexer_take_in", map[string]any{"module": l.pos[0], "why": l.flags["why"]}}, nil + }}, + {words: []string{"module"}, instead: "the indexer takes modules in from their repositories; nox module list|show|moved, and nothing forgets one by hand yet"}, + {words: []string{"register"}, instead: "no seat registers a module from another repository yet (novox/hq ADR 0501 §4); nox module moved takes a registered one in"}, + {words: []string{"account", "issue"}, usage: "", does: "mint one bus account the grants part wants, now", build: needArg("mesh-vault.issue-account", "user")}, + + // Issues and records. + {words: []string{"issues"}, usage: "[] [--status s] [--limit n] [--owner o]", does: "the issues, newest first, or those that match", + allowed: []string{"status", "limit", "owner", "severity", "needs"}, build: func(l line) (call, error) { + a := pick(l, "status", "owner", "severity", "needs", "limit") + if len(l.pos) > 0 { + a["search"] = strings.Join(l.pos, " ") + } + a, err := numbers(a, "limit") + return call{"issue-tracker.list", a}, err + }}, + {words: []string{"issue"}, usage: " [--brief]", does: "one issue whole", allowed: []string{"brief"}, + build: func(l line) (call, error) { + if len(l.pos) != 1 { + return call{}, usageErr("give the issue's number") + } + n, err := strconv.Atoi(l.pos[0]) + if err != nil || n < 1 { + return call{}, usageErr("%q is not an issue's number", l.pos[0]) + } + a := map[string]any{"number": n} + if l.has("brief") { + a["brief"] = true + } + return call{"issue-tracker.get", a}, nil + }}, + {words: []string{"kb", "ask"}, usage: "", does: "what holds about something, and why", build: func(l line) (call, error) { + if len(l.pos) == 0 { + return call{}, usageErr("give the question") + } + return call{"knowledge-base.ask", map[string]any{"question": strings.Join(l.pos, " ")}}, nil + }}, + {words: []string{"kb", "get"}, usage: " [--kind research] [--reasoning]", does: "one record by its number", + allowed: []string{"kind", "reasoning"}, build: func(l line) (call, error) { + if len(l.pos) != 1 { + return call{}, usageErr("give the record's number") + } + n, err := strconv.Atoi(l.pos[0]) + if err != nil { + return call{}, usageErr("%q is not a record's number", l.pos[0]) + } + a := pick(l, "kind") + a["number"] = n + if l.has("reasoning") { + a["reasoning"] = true + } + return call{"knowledge-base.get", a}, nil + }}, + {words: []string{"progress"}, usage: "[|mesh] [--detail minimal|detailed]", does: "how far the designs have come", + allowed: []string{"detail", "since"}, build: func(l line) (call, error) { + a := pick(l, "detail", "since") + a["of"] = "mesh" + if len(l.pos) == 1 { + a["of"] = l.pos[0] + if _, err := strconv.Atoi(l.pos[0]); err == nil { + a["of"] = "design " + l.pos[0] + } + } + return call{"knowledge-base.progress", a}, nil + }}, + + // Enrolment (the seat mesh-enrolment; `enrol open` is local, in main.go). + {words: []string{"enrol", "issue"}, usage: " --why [--expires 1h]", does: "a one-time token for a new machine, on the operator's Approve", + allowed: []string{"why", "expires"}, build: enrolIssue}, + {words: []string{"token", "issue"}, usage: "--new --why ", does: "the same as nox enrol issue", + allowed: []string{"new", "node", "why", "expires", "adopted"}, build: func(l line) (call, error) { + node := l.flags["new"] + if node == "" { + node = l.flags["node"] + } + if node != "" { + l.pos = append([]string{node}, l.pos...) + } + return enrolIssue(l) + }}, + {words: []string{"enrol", "tokens"}, usage: "[]", does: "every open enrolment, or one", build: optArg("mesh-enrolment.tokens", "node")}, + {words: []string{"enrol", "revoke"}, usage: " --why ", does: "end an enrolment at any stage", allowed: []string{"why"}, + build: func(l line) (call, error) { + if len(l.pos) != 1 || !l.has("why") { + return call{}, usageErr("give the node and --why") + } + return call{"mesh-enrolment.revoke", map[string]any{"node": l.pos[0], "why": l.flags["why"]}}, nil + }}, + {words: []string{"enrol", "again"}, usage: "", does: "raise an enrolment's ask again", build: needArg("mesh-enrolment.again", "node")}, + + // Any address. + {words: []string{"call"}, usage: "
[]", does: "call any seat verb or module tool, e.g. ace/node-service-manager.status", + build: func(l line) (call, error) { + if len(l.pos) < 1 || len(l.pos) > 2 { + return call{}, usageErr("give the address and at most one JSON object of arguments") + } + return addressCall(l.pos[0], l.pos[1:]) + }}, + {words: []string{"ask"}, usage: " []", does: "call one of a module's tools", + build: func(l line) (call, error) { + if len(l.pos) < 2 || len(l.pos) > 3 { + return call{}, usageErr("give the module, the tool and at most one JSON object of arguments") + } + return addressCall(l.pos[0]+"."+l.pos[1], l.pos[2:]) + }}, +} + +// unowned are the controller's own lines that no seat or module took over, and what to use where there is anything. +var unowned = map[string]string{ + "migrate": "", "prepare": "", "serve": "", "declare": "", "broker": "", "board": "", "api": "", "facts": "", + "rehearse": "", "take": "", "converge": "", "adopt": "", "move-placement": "", "move-config": "", "durations": "", + "data": "each machine's backups: nox call /node-backup.backed-up", + "forget-checks": "nox checks ", "queue": "nox builds; nox stuck", "cancel": "nox deliveries", + "clear": "nox deliveries", "kill": "nox deliveries", "replay": "nox dead-letters", + "pause": "nox call /node-build-go.pause (or node-build-node, node-build-image)", + "resume": "nox call /node-build-go.resume (or node-build-node, node-build-image)", + "push": "a merge is delivered by mesh-delivery: nox deliveries; one version on one machine is mesh-delivery-assigner.assigner_put", + "deliver": "nox deliveries", "plans": "nox deliveries", "delivery-stop": "nox stalled", + "delivery-backups-again": "nox deliveries", "delivery-walks": "nox deliveries", "delivery-order": "nox deliveries", + "rollout": "nox upgrade ; delivery is mesh-delivery's: nox deliveries", +} + +// find answers the command whose words begin the line, the longest first, and what follows them. +func find(args []string) (*command, []string) { + var best *command + for i := range commands { + c := &commands[i] + if len(c.words) > len(args) || (best != nil && len(c.words) <= len(best.words)) { + continue + } + match := true + for j, w := range c.words { + if args[j] != w { + match = false + break + } + } + if match { + best = c + } + } + if best == nil { + return nil, nil + } + return best, args[len(best.words):] +} + +// read builds the call of a line for a command that has an owner. +func (c *command) read(args []string) (call, error) { + l, err := parseLine(args) + if err != nil { + return call{}, err + } + allowed := map[string]bool{"json": true} + for _, f := range c.allowed { + allowed[f] = true + } + for f := range l.flags { + if !allowed[f] { + return call{}, usageErr("--%s is not a flag of this line", f) + } + } + return c.build(l) +} + +// now is the clock; a variable so a test can say. +var now = time.Now + +func fixed(address string) func(line) (call, error) { + return func(l line) (call, error) { + if len(l.pos) > 0 { + return call{}, usageErr("this line takes no %q", l.pos[0]) + } + return call{address, map[string]any{}}, nil + } +} + +func needArg(address, name string) func(line) (call, error) { + return func(l line) (call, error) { + if len(l.pos) != 1 { + return call{}, usageErr("give the %s", name) + } + return call{address, map[string]any{name: l.pos[0]}}, nil + } +} + +func optArg(address, name string) func(line) (call, error) { + return func(l line) (call, error) { + switch len(l.pos) { + case 0: + return call{address, map[string]any{}}, nil + case 1: + return call{address, map[string]any{name: l.pos[0]}}, nil + } + return call{}, usageErr("give at most one %s", name) + } +} + +func nodeArg(address string) func(line) (call, error) { return needArg(address, "node") } + +func optNode(address string) func(line) (call, error) { return optArg(address, "node") } + +// pick is the line's flags named, as string arguments. +func pick(l line, names ...string) map[string]any { + a := map[string]any{} + for _, n := range names { + if v, ok := l.flags[n]; ok { + a[n] = v + } + } + return a +} + +// numbers turns the named arguments into numbers; the call's address is left to the caller. +func numbers(a map[string]any, names ...string) (map[string]any, error) { + for _, n := range names { + v, ok := a[n].(string) + if !ok { + continue + } + i, err := strconv.Atoi(v) + if err != nil { + return a, usageErr("--%s takes a number, not %q", n, v) + } + a[n] = i + } + return a, nil +} + +// propose is a change of placement with its why. +func propose(l line, changes ...map[string]any) (call, error) { + if !l.has("why") { + return call{}, usageErr("a change of placement needs --why; the operator is asked to approve it") + } + list := make([]any, len(changes)) + for i, c := range changes { + list[i] = c + } + return call{"mesh-placement.propose", map[string]any{"why": l.flags["why"], "changes": list}}, nil +} + +func placeModules(op string) func(line) (call, error) { + return func(l line) (call, error) { + if len(l.pos) < 2 { + return call{}, usageErr("give the node and at least one module") + } + var changes []map[string]any + for _, m := range l.pos[1:] { + changes = append(changes, map[string]any{"op": op, "node": l.pos[0], "module": m}) + } + return propose(l, changes...) + } +} + +func publicDomain(l line) (call, error) { + switch { + case len(l.pos) == 1 && !l.has("clear"): + return call{"mesh-placement.placement", map[string]any{"node": l.pos[0]}}, nil + case len(l.pos) == 1: + return propose(l, map[string]any{"op": "decide", "node": l.pos[0], "field": "public-domain", "value": nil}) + case len(l.pos) == 2 && !l.has("clear"): + return propose(l, map[string]any{"op": "decide", "node": l.pos[0], "field": "public-domain", "value": l.pos[1]}) + } + return call{}, usageErr("give the node, and a domain or --clear") +} + +func configChange(l line, action string) (call, error) { + want := 3 + if action == "clear" { + want = 2 + } + if len(l.pos) == 2 && action == "set" && strings.HasPrefix(strings.TrimSpace(l.pos[1]), "{") { + return call{}, usageErr("a whole settings file is not set at once any more: set each key, " + + "nox settings set --node --why (mesh-config.config)") + } + if len(l.pos) != want || !l.has("node") || !l.has("why") { + return call{}, usageErr("give the module, the key%s, --node and --why", map[bool]string{true: " and the value"}[action == "set"]) + } + a := map[string]any{"action": action, "module": l.pos[0], "key": l.pos[1], "node": l.flags["node"], "why": l.flags["why"]} + if action == "set" { + a["value"] = jsonOrText(l.pos[2]) + } + return call{"mesh-config.config", a}, nil +} + +// jsonOrText is a value as JSON when it reads as JSON (a number, true, an object), else the text itself. +func jsonOrText(v string) any { + var out any + if err := json.Unmarshal([]byte(v), &out); err == nil { + return out + } + return v +} + +func retirement(address string) func(line) (call, error) { + return func(l line) (call, error) { + if len(l.pos) == 0 || !l.has("why") { + return call{}, usageErr("give the consumers waiting and --why") + } + consumers := make([]any, len(l.pos)) + for i, c := range l.pos { + consumers[i] = c + } + return call{address, map[string]any{"consumers": consumers, "why": l.flags["why"]}}, nil + } +} + +func enrolIssue(l line) (call, error) { + if len(l.pos) != 1 || !l.has("why") { + return call{}, usageErr("give the new machine's name and --why") + } + a := pick(l, "why", "expires") + a["node"] = l.pos[0] + return call{"mesh-enrolment.issue", a}, nil +} + +func addressCall(address string, rest []string) (call, error) { + a := map[string]any{} + if len(rest) == 1 { + if err := json.Unmarshal([]byte(rest[0]), &a); err != nil { + return call{}, usageErr("the arguments are not a JSON object: %v", err) + } + } + return call{address, a}, nil +} + +// renderNodes shapes placement's keys as one line per node: its name, address and site. +func renderNodes(text string) string { + var p struct { + Keys map[string]struct { + Placement struct { + Decided map[string]any `json:"decided"` + Modules []any `json:"modules"` + } `json:"placement"` + } `json:"keys"` + } + if err := json.Unmarshal([]byte(text), &p); err != nil || len(p.Keys) == 0 { + return text + } + names := make([]string, 0, len(p.Keys)) + for n := range p.Keys { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + for _, n := range names { + k := p.Keys[n] + fmt.Fprintf(&b, "%-10s %-15v %-8v %d modules\n", n, orDash(k.Placement.Decided["address"]), + orDash(k.Placement.Decided["site"]), len(k.Placement.Modules)) + } + return b.String() +} + +func orDash(v any) any { + if v == nil || v == "" { + return "-" + } + return v +} diff --git a/modules/mesh-cli/cmd/mesh-cli/console.go b/modules/mesh-cli/cmd/mesh-cli/console.go new file mode 100644 index 00000000..a952784b --- /dev/null +++ b/modules/mesh-cli/cmd/mesh-cli/console.go @@ -0,0 +1,144 @@ +package main + +// How mesh-cli reaches the mesh now that the controller is unassigned (novox/hq issue 1126): through this machine's +// console, the mesh MCP server the module node-tools serves on loopback (provision mcp-endpoint, ADR 0152: the +// machine's login is the authority). mesh-cli calls `mesh_call` with a seat verb's or a module tool's address, and the +// console puts the call on the bus, the way every agent session on the machine does. mesh-cli holds no credential and +// keeps no bus account of its own. + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// consoleFile is where the module writes the console's address, from the mcp-endpoint it requires; a variable so a +// test can name its own. +var consoleFile = "/etc/mesh-cli/console" + +// defaultConsole is the console's address when the file is not there yet: the port node-tools declares it serves. +const defaultConsole = "http://127.0.0.1:4270/mcp" + +// callWithin bounds one call: the console's own bound on a call is 30 seconds, and a wait is longer. +var callWithin = 2 * time.Minute + +// maxAnswer bounds what is read of the console's answer. +const maxAnswer = 16 << 20 + +// consoleAddress is where the console listens, and where that was read from, for what is said when it does not answer. +func consoleAddress() (string, string) { + if v := strings.TrimSpace(os.Getenv("MESH_CONSOLE")); v != "" { + return v, "MESH_CONSOLE" + } + if raw, err := os.ReadFile(consoleFile); err == nil { + if v := strings.TrimSpace(string(raw)); v != "" && !strings.Contains(v, "${") { + return v, consoleFile + } + } + return defaultConsole, "the console's declared port (" + consoleFile + " is not there)" +} + +// answer is what the console said of one call: its text, and whether the console marked it an error (a refusal, a +// tool's failure, nothing serving, no answer in time). +type answer struct { + Text string + IsError bool +} + +// errNoConsole is a console that did not answer at all: nothing was called. +var errNoConsole = errors.New("no console") + +// consoleTool asks the console one of its own tools (mesh_call, mesh_search, mesh_describe) and answers what it said. +func consoleTool(ctx context.Context, tool string, arguments map[string]any) (answer, error) { + url, from := consoleAddress() + body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": tool, "arguments": arguments}}) + if err != nil { + return answer{}, err + } + ctx, cancel := context.WithTimeout(ctx, callWithin) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return answer{}, fmt.Errorf("the console's address %q (from %s) is not usable: %v", url, from, err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return answer{}, ctx.Err() + } + return answer{}, fmt.Errorf("%w: nothing answers at %s (from %s), so nothing was called. The console is the "+ + "module node-tools on this machine: `systemctl status` of its unit, or call from another machine: %v", + errNoConsole, url, from, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswer)) + if err != nil { + return answer{}, fmt.Errorf("the console's answer could not be read: %v; the call may have run", err) + } + if resp.StatusCode != http.StatusOK { + return answer{}, fmt.Errorf("the console at %s answered %s: %s", url, resp.Status, strings.TrimSpace(string(raw))) + } + return decodeAnswer(raw) +} + +// decodeAnswer reads the console's JSON-RPC answer, given as JSON or as a server-sent event's data. +func decodeAnswer(raw []byte) (answer, error) { + payload := bytes.TrimSpace(raw) + if !bytes.HasPrefix(payload, []byte("{")) { + sc := bufio.NewScanner(bytes.NewReader(raw)) + sc.Buffer(make([]byte, 0, 64<<10), maxAnswer) + payload = nil + for sc.Scan() { + if d, ok := strings.CutPrefix(sc.Text(), "data:"); ok { + payload = []byte(strings.TrimSpace(d)) + } + } + } + var rpc struct { + Result *struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(payload, &rpc); err != nil { + return answer{}, fmt.Errorf("the console's answer is not one mesh-cli reads (%v); the call may have run", err) + } + if rpc.Error != nil { + return answer{Text: rpc.Error.Message, IsError: true}, nil + } + if rpc.Result == nil { + return answer{}, errors.New("the console answered nothing; the call may have run") + } + var parts []string + for _, c := range rpc.Result.Content { + if c.Type == "text" || c.Type == "" { + parts = append(parts, c.Text) + } + } + return answer{Text: strings.Join(parts, "\n"), IsError: rpc.Result.IsError}, nil +} + +// meshCall calls one address through the console. +func meshCall(ctx context.Context, address string, args map[string]any) (answer, error) { + if args == nil { + args = map[string]any{} + } + return consoleTool(ctx, "mesh_call", map[string]any{"address": address, "arguments": args}) +} diff --git a/modules/mesh-cli/cmd/mesh-cli/enrol_test.go b/modules/mesh-cli/cmd/mesh-cli/enrol_test.go index 61cd7ea2..eeebd021 100644 --- a/modules/mesh-cli/cmd/mesh-cli/enrol_test.go +++ b/modules/mesh-cli/cmd/mesh-cli/enrol_test.go @@ -17,8 +17,8 @@ import ( // 774/S51/AC1, AC2 (novox/hq ADR 0587 §6, §8): a token sealed to a test operator key (as mesh-vault seals it: the text // of the token, anonymous box, base64) is opened with the private half and printed as a token the engine's parser reads -// (base64url of version 1 JSON with node, broker, fingerprint, signer and secret), and nothing is asked of the engine's -// socket, which here is a path nobody listens on; the wrong key prints nothing and says the token is not sealed to it. +// (base64url of version 1 JSON with node, broker, fingerprint, signer and secret), and nothing is asked of the console, which +// here is an address nobody listens on; the wrong key prints nothing and says the token is not sealed to it. func TestEnrolOpenOpensASealedTokenLocallyAndOnlyWithItsKey(t *testing.T) { t.Setenv("MESH_VERB", "") token := base64.RawURLEncoding.EncodeToString([]byte(`{"v":1,"node":"new1","broker":"tls://10.77.0.1:4222",` + @@ -37,9 +37,9 @@ func TestEnrolOpenOpensASealedTokenLocallyAndOnlyWithItsKey(t *testing.T) { } return p } - before, beforeIn := socket, standardInput - socket = filepath.Join(dir, "nobody-listens.sock") - t.Cleanup(func() { socket, standardInput = before, beforeIn }) + beforeIn := standardInput + t.Setenv("MESH_CONSOLE", "http://127.0.0.1:1/mcp") // nothing listens: enrol open asks nothing of the mesh + t.Cleanup(func() { standardInput = beforeIn }) for _, c := range []struct { name string args []string diff --git a/modules/mesh-cli/cmd/mesh-cli/main.go b/modules/mesh-cli/cmd/mesh-cli/main.go index d4b805e1..74d34709 100644 --- a/modules/mesh-cli/cmd/mesh-cli/main.go +++ b/modules/mesh-cli/cmd/mesh-cli/main.go @@ -1,11 +1,8 @@ -// Command mesh-cli is the operator's command on every node (novox/hq ADR 0272): `mesh-cli ` asks the -// controller to run one of its command lines, and prints what it said. In interactive zsh it is also `nox`. +// Command mesh-cli is the operator's command on every node: `mesh-cli `, in interactive zsh also `nox`. // -// It holds no credential and reads no file of the mesh. It asks the node-engine on this machine over a local -// socket; the engine reads which account is asking from the kernel and asks the controller. The controller decides: -// on the control-node, from the operator's own account, the line runs as the controller's terminal; elsewhere it is -// an ordinary call, and a change made at the terminal alone is refused with the reason; from any other account -// nothing runs. +// Each line calls the seat verb or the module tool that owns its job (novox/hq issue 1126: the controller that ran +// these lines before is unassigned), through this machine's console, the mesh MCP server on loopback (console.go). +// A line no seat or module owns says so and names what to use instead. `enrol open` runs here and asks nothing. package main import ( @@ -15,16 +12,14 @@ import ( "io" "os" "os/signal" + "runtime/debug" + "sort" + "strings" "syscall" - - "git.novox.be/novox/mesh-sdk/go/cli" ) -// socket is where the node-engine answers; a variable so a test can name its own. -var socket = cli.Socket - -// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null), -// which is never read; a variable so a test can say. +// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null); a +// variable so a test can say. var standardInput = func() (io.Reader, bool) { info, err := os.Stdin.Stat() if err != nil { @@ -33,39 +28,6 @@ var standardInput = func() (io.Reader, bool) { return os.Stdin, info.Mode()&os.ModeCharDevice != 0 } -// carried is what mesh-cli carries of its standard input for the line: what a pipe or a file held, at most -// cli.MaxStdin, for the controller's terminal alone (a secret given with `secret accept … --from -`). A terminal is -// never read: the line is asked without. More than the bound is refused, and nothing is asked. -func carried() ([]byte, error) { - in, terminal := standardInput() - if terminal { - return nil, nil - } - body, err := io.ReadAll(io.LimitReader(in, cli.MaxStdin+1)) - if err != nil { - return nil, fmt.Errorf("standard input could not be read (%v); nothing was asked", err) - } - if len(body) > cli.MaxStdin { - return nil, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", cli.MaxStdin) - } - return body, nil -} - -const usage = `mesh-cli — the controller's command lines, from this node (novox/hq ADR 0272) - - mesh-cli e.g. mesh-cli status, mesh-cli settings show zsh --node - nox the same, in interactive zsh - - nox enrol open --key opens a sealed enrolment token here, asking nothing of the mesh (ADR 0587) - -Run it as yourself, never under sudo: the node-engine tells the controller which account asks. -What a pipe or a file gives its standard input (at most 64 KiB) goes with the line to the controller's -terminal alone, e.g. a secret: mesh-cli secret accept --from - < file -On the control-node, from its operator's account, the line runs as the controller's terminal. -Anywhere else it is an ordinary call, and a terminal-only change is refused with the reason. -'mesh-cli help' asks the controller for its own list of command lines. -` - func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) code := run(ctx, os.Args[1:], os.Stdout, os.Stderr) @@ -74,56 +36,178 @@ func main() { } func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { - if len(args) == 0 || args[0] == "-h" || args[0] == "--help" { - fmt.Fprint(stderr, usage) - return 2 + if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" { + fmt.Fprint(stderr, usage()) + if len(args) == 0 { + return 2 + } + return 0 } - // A verb's own process carries the verb it runs for; a verb is never a person, so it is never the terminal. + // A verb's own process carries the verb it runs for; a verb is never a person. if verb := os.Getenv("MESH_VERB"); verb != "" { fmt.Fprintf(stderr, "mesh-cli: this process runs for the verb %q, and a verb is never the operator. "+ - "Nothing was asked\n", verb) + "Nothing was called\n", verb) return 1 } + switch args[0] { + case "version": + fmt.Fprintln(stdout, version()) + return 0 + case "search": + if len(args) < 2 { + fmt.Fprintln(stderr, "mesh-cli: give the words: nox search ") + return 2 + } + return show(ctx, "mesh_search", map[string]any{"query": strings.Join(args[1:], " ")}, nil, stdout, stderr) + case "describe": + if len(args) != 2 { + fmt.Fprintln(stderr, "mesh-cli: give one address: nox describe
") + return 2 + } + return show(ctx, "mesh_describe", map[string]any{"address": args[1]}, nil, stdout, stderr) + } // `enrol open` is local: it opens a sealed enrolment token with the operator's private key and asks nothing of the - // node-engine or the mesh (novox/hq ADR 0587 §8). + // mesh (novox/hq ADR 0587 §8). if len(args) >= 2 && args[0] == "enrol" && args[1] == "open" { in, terminal := standardInput() return enrolOpen(args[2:], in, terminal, stdout, stderr) } - in, err := carried() + c, rest := find(args) + if c == nil { + if instead, ok := unowned[args[0]]; ok { + return noOwner(args[0], instead, stderr) + } + fmt.Fprintf(stderr, "mesh-cli: %q is not a command. nox help lists them; nox search finds a seat verb "+ + "or a tool, and nox call
calls it\n", args[0]) + return 2 + } + if c.build == nil { + return noOwner(strings.Join(c.words, " "), c.instead, stderr) + } + k, err := c.read(rest) if err != nil { - fmt.Fprintf(stderr, "mesh-cli: %v\n", err) - return 1 + fmt.Fprintf(stderr, "mesh-cli: %v. Nothing was called\n nox %s %s\n", err, strings.Join(c.words, " "), c.usage) + return 2 } - a, err := cli.AskWith(ctx, socket, cli.Request{Line: args, Stdin: in}) - if err != nil { - if errors.Is(ctx.Err(), context.Canceled) { - fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the line may still be running on the controller") - return 130 - } - fmt.Fprintf(stderr, "mesh-cli: %v\n", err) - return 1 + if k.Address == "mesh-delivery-view.view_status" { + return status(ctx, stdout, stderr) } - _, _ = stdout.Write(a.Stdout) - _, _ = stderr.Write(a.Stderr) - if a.Cut { - fmt.Fprintln(stderr, "mesh-cli: the output was longer than one bus message, and was cut there") - } - if !a.Terminal && a.Why != "" { - fmt.Fprintln(stderr, "mesh-cli: "+a.Why) - } - if a.Unknown != "" { - // Asked, and what came of it not known: never said as nothing having run. - fmt.Fprintln(stderr, "mesh-cli: "+a.Unknown) - if a.Exit == 0 { - return 1 + return show(ctx, "mesh_call", map[string]any{"address": k.Address, "arguments": k.Args}, c.render, stdout, stderr) +} + +// noOwner says a line has no owner since the controller went, and what to use. +func noOwner(what, instead string, stderr io.Writer) int { + fmt.Fprintf(stderr, "mesh-cli: nox %s: %s.\n", what, gone) + if instead != "" { + fmt.Fprintf(stderr, " instead: %s\n", instead) + } else { + fmt.Fprintln(stderr, " no seat or module does this now; nox search finds what does nearby") + } + return 1 +} + +// show calls one of the console's tools and prints what it answered: an answer marked an error on standard error, with +// exit 1. +func show(ctx context.Context, tool string, args map[string]any, render func(string) string, stdout, stderr io.Writer) int { + a, err := consoleTool(ctx, tool, args) + if err != nil { + return failed(err, stderr) + } + if a.IsError { + fmt.Fprintln(stderr, "mesh-cli: "+strings.TrimRight(a.Text, "\n")) + return 1 + } + text := a.Text + if render != nil { + text = render(text) + } + fmt.Fprintln(stdout, strings.TrimRight(text, "\n")) + return 0 +} + +// failed says why a call did not come back, and never as nothing having run unless nothing answered. +func failed(err error, stderr io.Writer) int { + switch { + case errors.Is(err, context.Canceled): + fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the call may still run") + return 130 + case errors.Is(err, context.DeadlineExceeded): + fmt.Fprintln(stderr, "mesh-cli: no answer in time; the call may have run. Check before repeating a change") + return 1 + } + fmt.Fprintf(stderr, "mesh-cli: %v\n", err) + return 1 +} + +// status is the delivery view's status. When the view does not answer, that is said first and loudly, and what the +// condition keeper and mesh-delivery hold is shown in its place, each under its address, with exit 1: never shown as +// the status. +func status(ctx context.Context, stdout, stderr io.Writer) int { + a, err := meshCall(ctx, "mesh-delivery-view.view_status", nil) + if err == nil && !a.IsError { + fmt.Fprintln(stdout, strings.TrimRight(a.Text, "\n")) + return 0 + } + if errors.Is(err, errNoConsole) || errors.Is(err, context.Canceled) { + return failed(err, stderr) + } + why := a.Text + if err != nil { + why = err.Error() + } + fmt.Fprintf(stderr, "mesh-cli: the status (mesh-delivery-view.view_status) did not answer: %s\n"+ + "mesh-cli: what follows is not the status: the open conditions and the deliveries, read on their own\n", + strings.TrimRight(why, "\n")) + for _, address := range []string{"mesh-conditions.conditions", "mesh-delivery.deliveries"} { + fmt.Fprintf(stdout, "== %s\n", address) + show(ctx, "mesh_call", map[string]any{"address": address, "arguments": map[string]any{}}, nil, stdout, stderr) + } + return 1 +} + +// version is what this binary was built from. +func version() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "mesh-cli (unknown build)" + } + v := "mesh-cli " + info.Main.Version + for _, s := range info.Settings { + if s.Key == "vcs.revision" { + v += " " + s.Value } } - if a.Refused != "" { - fmt.Fprintln(stderr, "mesh-cli: "+a.Refused) - if a.Exit == 0 { - return 1 + return v +} + +// usage is every line nox takes, grouped by its first word. +func usage() string { + var b strings.Builder + b.WriteString(`mesh-cli: the operator's command (nox in interactive zsh) + +Each line calls the seat verb or module tool that owns the job, through this machine's console (the mesh MCP +server on loopback). The controller that ran these lines before is unassigned (novox/hq issue 1126). + +`) + lines := make([]string, 0, len(commands)) + for _, c := range commands { + if c.build == nil { + continue } + lines = append(lines, fmt.Sprintf(" nox %s %s\n %s", strings.Join(c.words, " "), c.usage, c.does)) } - return a.Exit + sort.Strings(lines) + b.WriteString(strings.Join(lines, "\n")) + b.WriteString(` + nox search + find a seat verb or a module tool + nox describe
+ one address's arguments + nox enrol open --key + open an enrolment token sealed to the operator key, here; asks nothing of the mesh + nox version + +Every answer is the owner's own, as JSON. A line the controller ran that no seat owns says so and what to use. +`) + return b.String() } diff --git a/modules/mesh-cli/cmd/mesh-cli/main_test.go b/modules/mesh-cli/cmd/mesh-cli/main_test.go index 123a5f5f..50d4dd82 100644 --- a/modules/mesh-cli/cmd/mesh-cli/main_test.go +++ b/modules/mesh-cli/cmd/mesh-cli/main_test.go @@ -3,153 +3,282 @@ package main import ( "bytes" "context" + "encoding/json" "io" - "net" + "net/http" + "net/http/httptest" + "os" "path/filepath" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/cli" + "time" ) -// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal unless a test -// says otherwise. +// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal. func init() { standardInput = func() (io.Reader, bool) { return nil, true } } -// lastStdin is the standard input the engine was last given. -var lastStdin []byte +// asked is one call the stand-in console took: the console's tool, and for mesh_call the address and its arguments. +type asked struct { + Tool string + Address string + Args string +} -// engine answers one connection on a socket of the test's own as the node-engine would, and says what it was asked. -func engine(t *testing.T, answer cli.Answer) <-chan []string { +// console is a stand-in for this machine's console: it answers MCP tools/call over HTTP as node-tools does, with the +// answer the test gives per address (an error when it starts with "!"), and records what it was asked. +func console(t *testing.T, answers map[string]string) *[]asked { t.Helper() - path := filepath.Join(t.TempDir(), "cli.sock") - l, err := net.Listen("unix", path) - if err != nil { - t.Skipf("no unix socket here: %v", err) - } - t.Cleanup(func() { l.Close() }) - before := socket - socket = path - t.Cleanup(func() { socket = before }) - asked := make(chan []string, 1) - lastStdin = nil - go func() { - conn, err := l.Accept() - if err != nil { + var got []asked + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/mcp" || r.Method != http.MethodPost { + http.NotFound(w, r) return } - defer conn.Close() - req, err := cli.ReadRequest(conn) - if err != nil { - return + var req struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments struct { + Address string `json:"address"` + Arguments map[string]any `json:"arguments"` + Query string `json:"query"` + } `json:"arguments"` + } `json:"params"` } - lastStdin = req.Stdin - asked <- req.Line - _ = cli.WriteAnswer(conn, answer) - }() - return asked -} - -// The line is asked as given, what the controller printed is printed, and its exit is mesh-cli's. -func TestTheLineIsAskedAndTheAnswerIsPrinted(t *testing.T) { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Method != "tools/call" { + t.Errorf("the console was asked %v %v", req.Method, err) + } + a := req.Params.Arguments + args, _ := json.Marshal(a.Arguments) + got = append(got, asked{req.Params.Name, a.Address, string(args)}) + key := a.Address + if req.Params.Name != "mesh_call" { + key = req.Params.Name + } + text, ok := answers[key] + if !ok { + text = `{"ok":true}` + } + isError := strings.HasPrefix(text, "!") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{ + "content": []any{map[string]any{"type": "text", "text": strings.TrimPrefix(text, "!")}}, "isError": isError}}) + })) + t.Cleanup(srv.Close) + t.Setenv("MESH_CONSOLE", srv.URL+"/mcp") t.Setenv("MESH_VERB", "") - asked := engine(t, cli.Answer{Stdout: []byte("out\n"), Stderr: []byte("err\n"), Exit: 3, Terminal: true, Why: "the terminal"}) + return &got +} + +func runLine(args ...string) (int, string, string) { var out, errOut bytes.Buffer - code := run(context.Background(), []string{"settings", "set", "zsh", `{"execute":"withhold"}`}, &out, &errOut) - if line := <-asked; strings.Join(line, "|") != `settings|set|zsh|{"execute":"withhold"}` { - t.Fatalf("asked %q", line) - } - if code != 3 || out.String() != "out\n" || errOut.String() != "err\n" { - t.Fatalf("exit %d, stdout %q, stderr %q", code, out.String(), errOut.String()) + code := run(context.Background(), args, &out, &errOut) + return code, out.String(), errOut.String() +} + +// Every owned line calls the seat verb or module tool that owns its job, with the arguments the owner reads +// (novox/hq issue 1126; hq .work/1126/nox-map.md), and prints its answer. +func TestEachLineCallsItsOwner(t *testing.T) { + now = func() time.Time { return time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC) } + t.Cleanup(func() { now = time.Now }) + for _, c := range []struct { + line string + address string + args string + }{ + {"node show ace", "mesh-placement.placement", `{"node":"ace"}`}, + {"node public-domain ace", "mesh-placement.placement", `{"node":"ace"}`}, + {"node public-domain ace example.org --why moved", "mesh-placement.propose", `{"changes":[{"field":"public-domain","node":"ace","op":"decide","value":"example.org"}],"why":"moved"}`}, + {"plan ace", "mesh-delivery-view.assigner_wants", `{"node":"ace"}`}, + {"haves", "mesh-delivery-view.node_haves", `{}`}, + {"composition ace plex", "ace/node-engine.composition", `{"module":"plex"}`}, + {"seats", "mesh-placement.placement", `{"seats":"true"}`}, + {"assign ace plex sonarr --why=media", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"place"},{"module":"sonarr","node":"ace","op":"place"}],"why":"media"}`}, + {"unassign ace plex --why gone", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"take-off"}],"why":"gone"}`}, + {"pin ace postgres-database novox postgres --why one", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"pin","provider":{"module":"postgres","node":"novox"},"provision":"postgres-database"}],"why":"one"}`}, + {"unpin ace postgres-database --why back", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"unpin","provision":"postgres-database"}],"why":"back"}`}, + {"deliveries --state merged --json", "mesh-delivery.deliveries", `{"state":"merged"}`}, + {"delivery novox/mesh-sdk@dff0ce2abb21", "mesh-delivery.show", `{"id":"novox/mesh-sdk@dff0ce2abb21"}`}, + {"checks novox/mesh-catalog --number 591", "mesh-delivery.checks", `{"number":"591","repository":"novox/mesh-catalog"}`}, + {"try novox/mesh-catalog abc123 --level unit", "mesh-delivery-checker.try", `{"head":"abc123","level":"unit","repository":"novox/mesh-catalog"}`}, + {"builds", "mesh-delivery-builder.builder_builds_since", `{"since":"2026-11-01T10:00:00Z"}`}, + {"upgrade nats --policy record --why bus", "mesh-wants.upgrade", `{"module":"nats","policy":"record","why":"bus"}`}, + {"bus", "mesh-wants.bus", `{}`}, + {"conditions --severity urgent", "mesh-conditions.conditions", `{"severity":"urgent"}`}, + {"conditions show machine.g14.root-not-free", "mesh-conditions.conditions", `{"key":"machine.g14.root-not-free"}`}, + {"conditions silence k1 --for 4h --why known", "mesh-conditions.silence", `{"for":"4h","key":"k1","why":"known"}`}, + {"conditions history --days 3", "mesh-conditions.conditions", `{"days":"3","history":"true"}`}, + {"conditions history --key k1", "mesh-conditions.conditions_history", `{"key":"k1"}`}, + {"doctor", "mesh-self-check.doctor", `{}`}, + {"doctor run", "mesh-self-check.doctor", `{"run":true}`}, + {"doctor probe D6", "mesh-self-check.doctor", `{"probe":"D6"}`}, + {"healers --days 2", "mesh-healers.healers", `{"days":2}`}, + {"hand-act record restarted the view --why stuck --cause view", "mesh-hand-acts.hand-act", `{"cause":"view","what":"restarted the view","why":"stuck"}`}, + {"hand-acts --days 3", "mesh-hand-acts.hand-acts", `{"days":3}`}, + {"settings show zsh --node g14", "mesh-config.config", `{"action":"list","module":"zsh","node":"g14"}`}, + {"settings set zsh execute withhold --node g14 --why test", "mesh-config.config", `{"action":"set","key":"execute","module":"zsh","node":"g14","value":"withhold","why":"test"}`}, + {"settings clear zsh execute --node g14 --why test", "mesh-config.config", `{"action":"clear","key":"execute","module":"zsh","node":"g14","why":"test"}`}, + {"config history --key k --limit 5", "mesh-config.config", `{"action":"history","key":"k","limit":5}`}, + {"secret export", "mesh-vault.secret_vault_export", `{}`}, + {"operator key show", "mesh-vault.vault_status", `{}`}, + {"rotate --node ace --module plex --name token --why leak", "mesh-vault.rotate", `{"module":"plex","name":"token","node":"ace","why":"leak"}`}, + {"retire approve c1 c2 --why done", "mesh-vault.provisioner_retire_approve", `{"consumers":["c1","c2"],"why":"done"}`}, + {"collect --confirm --why space", "mesh-artifact-sweep.collect", `{"confirm":"true","why":"space"}`}, + {"images ace", "mesh-artifact-sweep.images", `{"node":"ace"}`}, + {"licence use g14 work", "anthropic-licence-manager.switch", `{"consumer":"g14","licence":"work"}`}, + {"module show plex", "mesh-delivery-view.indexer_module", `{"module":"plex"}`}, + {"issues apostrophe bug --status open --limit 5", "issue-tracker.list", `{"limit":5,"search":"apostrophe bug","status":"open"}`}, + {"issue 1126 --brief", "issue-tracker.get", `{"brief":true,"number":1126}`}, + {"kb ask what holds about the merge window", "knowledge-base.ask", `{"question":"what holds about the merge window"}`}, + {"kb get 587 --reasoning", "knowledge-base.get", `{"number":587,"reasoning":true}`}, + {"progress 47", "knowledge-base.progress", `{"of":"design 47"}`}, + {"enrol issue new1 --why laptop --expires 2h", "mesh-enrolment.issue", `{"expires":"2h","node":"new1","why":"laptop"}`}, + {"token issue --new new1 --why laptop", "mesh-enrolment.issue", `{"node":"new1","why":"laptop"}`}, + {"enrol tokens", "mesh-enrolment.tokens", `{}`}, + {"call ace/node-service-manager.status {\"unit\":\"plex\"}", "ace/node-service-manager.status", `{"unit":"plex"}`}, + {"ask mesh-catalog catalog_modules", "mesh-catalog.catalog_modules", `{}`}, + } { + got := console(t, nil) + args := strings.Fields(c.line) + if strings.HasPrefix(c.line, "call ") { + args = []string{"call", "ace/node-service-manager.status", `{"unit":"plex"}`} + } + code, out, errOut := runLine(args...) + if code != 0 || len(*got) != 1 || (*got)[0].Tool != "mesh_call" || (*got)[0].Address != c.address || (*got)[0].Args != c.args { + t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want %s %s", c.line, code, *got, errOut, c.address, c.args) + continue + } + if !strings.Contains(out, `"ok":true`) { + t.Errorf("nox %s printed %q, not the owner's answer", c.line, out) + } } } -// Not the terminal, refused, or cut: each is said, and a refusal never exits 0. -func TestWhyARefusalAndACutAreSaid(t *testing.T) { - t.Setenv("MESH_VERB", "") - engine(t, cli.Answer{Why: "not the controller's terminal: agents on laptop may run as operator", - Refused: "set at the controller's terminal only", Cut: true}) - var out, errOut bytes.Buffer - code := run(context.Background(), []string{"settings", "set", "x", "{}"}, &out, &errOut) - said := errOut.String() - if code == 0 || !strings.Contains(said, "not the controller's terminal") || !strings.Contains(said, "terminal only") || - !strings.Contains(said, "cut") { - t.Fatalf("exit %d, said %q", code, said) +// A line the controller ran that no seat owns says so and what to use, exits 1 and calls nothing; a line nox cannot +// read says how it is written, exits 2 and calls nothing. +func TestALineWithNoOwnerOrNotReadCallsNothingAndSaysWhy(t *testing.T) { + for _, c := range []struct { + line string + code int + err string + }{ + {"push ace", 1, "mesh-delivery-assigner.assigner_put"}, + {"migrate", 1, "no seat or module does this now"}, + {"secret accept ace plex token", 1, "mesh-vault.secret-ask"}, + {"node add new1", 1, "nox enrol issue"}, + {"pause", 1, "node-build-go.pause"}, + {"operator key make", 1, "operator key show"}, + {"assign ace plex", 2, "needs --why"}, + {"issue twelve", 2, "not an issue's number"}, + {"conditions --bogus x", 2, "--bogus is not a flag"}, + {"settings set zsh {\"execute\":\"withhold\"} --node g14 --why x", 2, "set each key"}, + {"healers --days soon", 2, "takes a number"}, + {"frobnicate", 2, "is not a command"}, + } { + got := console(t, nil) + args := strings.Fields(c.line) + code, _, errOut := runLine(args...) + if code != c.code || len(*got) != 0 || !strings.Contains(errOut, c.err) { + t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want exit %d saying %q", c.line, code, *got, errOut, c.code, c.err) + } + if c.code == 1 && !strings.Contains(errOut, "issue 1126") { + t.Errorf("nox %s does not say the controller is unassigned: %q", c.line, errOut) + } } } -// A verb's own process is never the operator: refused before anything is asked. -func TestAVerbsProcessIsRefusedBeforeAsking(t *testing.T) { - t.Setenv("MESH_VERB", "command") - before := socket - socket = filepath.Join(t.TempDir(), "nothing-listens.sock") - t.Cleanup(func() { socket = before }) - var out, errOut bytes.Buffer - if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 || - !strings.Contains(errOut.String(), "never the operator") { - t.Fatalf("exit %d, said %q", code, errOut.String()) +// What the console says is printed as said: an answer it marks an error on standard error with exit 1, a console that +// does not answer as nothing called, a verb's process refused before anything is called; nodes are one line each. +func TestAnswersRefusalsAndSilenceAreSaid(t *testing.T) { + got := console(t, map[string]string{"mesh-enrolment.tokens": "!nothing serves seat:mesh-enrolment.tokens"}) + if code, out, errOut := runLine("enrol", "tokens"); code != 1 || out != "" || !strings.Contains(errOut, "nothing serves") { + t.Errorf("an error answer: exit %d, stdout %q, stderr %q", code, out, errOut) + } + placement := `{"keys":{"novox":{"placement":{"decided":{"address":"10.10.0.1","site":"hub"},"modules":[{},{}]}},` + + `"ace":{"placement":{"decided":{"address":"10.10.0.2"},"modules":[{}]}}}}` + console(t, map[string]string{"mesh-placement.placement": placement}) + if code, out, _ := runLine("nodes"); code != 0 || !strings.HasPrefix(out, "ace ") || + !strings.Contains(out, "10.10.0.1") || !strings.Contains(out, "2 modules") { + t.Errorf("nodes: exit %d, %q", code, out) + } + got = console(t, map[string]string{"mesh_search": `{"matches":[]}`}) + if code, out, _ := runLine("search", "restart", "a", "unit"); code != 0 || (*got)[0].Tool != "mesh_search" || !strings.Contains(out, "matches") { + t.Errorf("search: exit %d, asked %+v, %q", code, *got, out) + } + t.Setenv("MESH_CONSOLE", "http://127.0.0.1:1/mcp") + if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "nothing was called") { + t.Errorf("no console: exit %d, %q", code, errOut) + } + t.Setenv("MESH_VERB", "some.verb") + if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "a verb is never the operator") { + t.Errorf("a verb's process: exit %d, %q", code, errOut) } } -// An engine that does not answer is said, with where it was asked. -func TestAnEngineThatDoesNotAnswerIsSaid(t *testing.T) { - t.Setenv("MESH_VERB", "") - before := socket - socket = filepath.Join(t.TempDir(), "nothing-listens.sock") - t.Cleanup(func() { socket = before }) - var out, errOut bytes.Buffer - if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 || - !strings.Contains(errOut.String(), "does not answer") { - t.Fatalf("exit %d, said %q", code, errOut.String()) +// The status is the delivery view's. When the view does not answer, that is said first, the conditions and the +// deliveries are shown under their own addresses, and the exit is 1: they are never passed off as the status. +func TestStatusIsTheViewsAndItsSilenceIsSaid(t *testing.T) { + got := console(t, map[string]string{"mesh-delivery-view.view_status": `{"well":true}`}) + if code, out, _ := runLine("status"); code != 0 || len(*got) != 1 || !strings.Contains(out, `"well":true`) { + t.Errorf("status: exit %d, asked %+v, %q", code, *got, out) + } + got = console(t, map[string]string{"mesh-delivery-view.view_status": "!mesh-delivery-view.view_status did not answer within 30s", + "mesh-conditions.conditions": `{"conditions":[]}`, "mesh-delivery.deliveries": `{"deliveries":[]}`}) + code, out, errOut := runLine("status") + if code != 1 || len(*got) != 3 || !strings.Contains(errOut, "did not answer") || !strings.Contains(errOut, "not the status") || + !strings.Contains(out, "== mesh-conditions.conditions") || !strings.Contains(out, "== mesh-delivery.deliveries") { + t.Errorf("status, the view silent: exit %d, asked %+v, stdout %q, stderr %q", code, *got, out, errOut) } } -// An outcome not known is said as such — the line may have run — and never as nothing having run. -func TestAnOutcomeNotKnownIsSaid(t *testing.T) { - t.Setenv("MESH_VERB", "") - engine(t, cli.NotKnown("call-4", "no answer within 6m0s")) - var out, errOut bytes.Buffer - code := run(context.Background(), []string{"push", "laptop"}, &out, &errOut) - if code == 0 || !strings.Contains(errOut.String(), "may have run") || !strings.Contains(errOut.String(), "call-4") { - t.Fatalf("exit %d, said %q", code, errOut.String()) +// The console's address comes from the file the module writes from its mcp-endpoint; MESH_CONSOLE overrides it; with +// neither, the port node-tools declares. An answer given as a server-sent event is read like one given as JSON. +func TestTheConsoleIsFoundAndItsAnswerRead(t *testing.T) { + t.Setenv("MESH_CONSOLE", "") + dir := t.TempDir() + before := consoleFile + consoleFile = filepath.Join(dir, "console") + t.Cleanup(func() { consoleFile = before }) + if url, _ := consoleAddress(); url != defaultConsole { + t.Errorf("no file: %s", url) + } + if err := os.WriteFile(consoleFile, []byte("http://127.0.0.1:4999/mcp\n"), 0o644); err != nil { + t.Fatal(err) + } + if url, from := consoleAddress(); url != "http://127.0.0.1:4999/mcp" || from != consoleFile { + t.Errorf("the file: %s from %s", url, from) + } + t.Setenv("MESH_CONSOLE", "http://127.0.0.1:5000/mcp") + if url, _ := consoleAddress(); url != "http://127.0.0.1:5000/mcp" { + t.Errorf("the variable: %s", url) + } + a, err := decodeAnswer([]byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}],\"isError\":true}}\n\n")) + if err != nil || a.Text != "hi" || !a.IsError { + t.Errorf("an event: %+v %v", a, err) + } + if _, err := decodeAnswer([]byte("")); err == nil { + t.Error("a page that is no answer was read as one") } } -// A pipe's or a file's standard input goes with the line, whole; a terminal's is never read; more than the bound is -// refused and nothing is asked (novox/hq ADR 0259 §10). -func TestStandardInputGoesWithTheLineAndATerminalIsNeverRead(t *testing.T) { - t.Setenv("MESH_VERB", "") - was := standardInput - t.Cleanup(func() { standardInput = was }) - secret := "123456789:the-bot-token\n" - - standardInput = func() (io.Reader, bool) { return strings.NewReader(secret), false } - asked := engine(t, cli.Answer{Terminal: true}) - var out, errOut bytes.Buffer - if code := run(context.Background(), []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"}, - &out, &errOut); code != 0 { - t.Fatalf("exit %d: %s", code, errOut.String()) - } - <-asked - if string(lastStdin) != secret { - t.Fatalf("the engine was given %q", lastStdin) - } - if strings.Contains(out.String()+errOut.String(), "bot-token") { - t.Fatal("mesh-cli printed what it carried") - } - - standardInput = func() (io.Reader, bool) { return strings.NewReader("typed"), true } - asked = engine(t, cli.Answer{}) - _ = run(context.Background(), []string{"status"}, &out, &errOut) - <-asked - if len(lastStdin) != 0 { - t.Fatalf("a terminal's input was read: %q", lastStdin) - } - - standardInput = func() (io.Reader, bool) { return bytes.NewReader(make([]byte, cli.MaxStdin+1)), false } - errOut.Reset() - if code := run(context.Background(), []string{"secret", "accept", "a", "b", "c", "--from", "-"}, &out, &errOut); code == 0 || - !strings.Contains(errOut.String(), "nothing was asked") { - t.Fatalf("more than the bound was asked: %d %s", code, errOut.String()) +// Every command's line is in the help, and none is defined twice. +func TestEveryLineIsInTheHelpOnce(t *testing.T) { + seen := map[string]bool{} + help := usage() + for _, c := range commands { + w := strings.Join(c.words, " ") + if seen[w] { + t.Errorf("nox %s is defined twice", w) + } + seen[w] = true + if c.build != nil && !strings.Contains(help, "nox "+w+" ") { + t.Errorf("nox %s is not in the help", w) + } + if c.build == nil && c.instead == "" { + t.Errorf("nox %s has neither an owner nor an instead", w) + } + if _, dup := unowned[c.words[0]]; dup && len(c.words) == 1 { + t.Errorf("nox %s is both owned and unowned", w) + } } } diff --git a/modules/mesh-cli/go.mod b/modules/mesh-cli/go.mod index d1aea226..047231f4 100644 --- a/modules/mesh-cli/go.mod +++ b/modules/mesh-cli/go.mod @@ -2,14 +2,6 @@ module mesh-cli go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 - require golang.org/x/crypto v0.57.0 -require ( - github.com/klauspost/compress v1.20.0 // indirect - github.com/nats-io/nats.go v1.54.0 // indirect - github.com/nats-io/nkeys v0.4.16 // indirect - github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/sys v0.48.0 // indirect -) +require golang.org/x/sys v0.48.0 // indirect diff --git a/modules/mesh-cli/go.sum b/modules/mesh-cli/go.sum index a06c658d..7fd6abc1 100644 --- a/modules/mesh-cli/go.sum +++ b/modules/mesh-cli/go.sum @@ -1,15 +1,3 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= -github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= -github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= -github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=