The installer goes as far as it can, and asks where a human must choose

Twelve steps made a mesh that RUNS and then said "what remains is somebody
else's". The seven things that turn it into a mesh that WORKS — the shared base,
a database provider, the catalogue, the private network, the packet filter — were
typed afterwards, which is how they went missing for weeks without anything
complaining.

Six more steps now: base, store, catalogue, network, filter, extras. Everything
in them is module add, build, assign and push — the same verbs a person types,
through the same commands, so the installer and an operator remain one act.

Where a human must choose, the installer asks. A choice resolves in the order a
person expects: the flag wins; a lone option answers itself ALOUD, because "it
chose for me" and "there was nothing to choose" read identically afterwards
unless one speaks; a terminal is asked; a default fills in; and a required
choice nothing answered refuses naming its flag — a guessed packet filter is a
machine somebody else configured. The filter is required, so the question is
which, not whether. A run without a terminal (the lab, --json) is never left
waiting on a prompt nobody will answer.

Placement is part of the network step, not a separate act — a lesson paid for:
the module installed, the names file was written with no names in it, and
everything reported success because nobody had said where the machine IS. The
hub endpoint derives from the broker address when unsaid: the host other
machines dial is one fact, not two that drift.

Extras fail the run rather than soft-fail: somebody asked for them by name, and
a mesh reporting success minus one thing is reporting the wrong thing.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-15 21:56:23 +02:00
parent 7a223464e5
commit 21474b0144
7 changed files with 550 additions and 4 deletions
+94 -1
View File
@@ -28,9 +28,11 @@ import (
"syscall"
"time"
"bufio"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/bootstrap"
"github.com/novox/mesh-host/internal/store"
"strings"
)
// version is stamped at build time. Unset in a development build, and said so rather than
@@ -50,7 +52,7 @@ const (
const usage = `mesh-bootstrap — make a bare machine into a mesh
bootstrap the twelve steps below (the default)
bootstrap the eighteen steps below (the default)
version
1 preflight what has to be true before anything is changed
@@ -67,6 +69,20 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
12 builder publish the carried builder and install it, so this mesh can
make the rest of the catalogue rather than be handed it
Twelve make a mesh that RUNS. The rest make one that WORKS, asking where a
human must choose — a run without a terminal answers with the flags below:
13 base build the shared toolchain and runtime everything with code
stands on
14 store build and install postgres — a database provider, which the
substrate's own store is not
15 catalogue build and install the module graph
16 network choose the private network (--private-network), place this
machine as its hub (--endpoint, --site)
17 filter choose the packet filter (--packet-filter) — required, so the
question is which, not whether
18 extras anything beyond the floor (--extras)
--bundle the substrate template to build this machine's bundle from
(default ` + defaultTemplate + `)
--out where the produced bundle is written, for a person to read
@@ -96,6 +112,18 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
--dry-run everything that does not change the machine
--json machine-readable output
--tools-source the repository the shared base is built from
--tools-ref what of it to build (default main)
--catalog-source the catalogue REPOSITORY, for building its modules;
--catalog is the checkout that says what they are
--catalog-ref what of it to build (default main)
--private-network which private network to run (wireguard)
--endpoint host:port other machines dial for it; derived from the
broker address when unsaid
--site where this machine sits (default main)
--packet-filter which packet filter to run (nftables)
--extras catalogue modules beyond the floor, comma-separated
The installer carries a builder, not a control plane. What raises a mesh is therefore
the same thing that will maintain it, and the control plane a mesh ends up running is
one it built itself, from a repository and a commit it can name and build again.
@@ -209,9 +237,67 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
set.DurationVar(&opts.Wait, "wait", opts.Wait, "how long something merely starting is given")
set.BoolVar(&opts.DryRun, "dry-run", false, "everything that does not change the machine")
set.BoolVar(jsonOut, "json", false, "machine-readable output")
// Phase two — the installer goes as far as it can, and asks where a human must choose. A run
// without a terminal answers with these; a required choice nothing answered is a refusal.
set.StringVar(&opts.ToolsSource.Repository, "tools-source", opts.ToolsSource.Repository,
"the repository the shared base is built from")
set.StringVar(&opts.ToolsSource.Ref, "tools-ref", opts.ToolsSource.Ref,
"what of it to build (default main)")
set.StringVar(&opts.CatalogSource.Repository, "catalog-source", opts.CatalogSource.Repository,
"the catalogue REPOSITORY, for building its modules — --catalog is the checkout that says what they are")
set.StringVar(&opts.CatalogSource.Ref, "catalog-ref", opts.CatalogSource.Ref,
"what of it to build (default main)")
set.StringVar(&opts.Site, "site", "main", "where this machine sits, for the private network")
if opts.Answers == nil {
opts.Answers = map[string]string{}
}
answers := opts.Answers
set.Func("private-network", "which private network to run (wireguard)", func(v string) error {
answers["private-network"] = v
return nil
})
set.Func("packet-filter", "which packet filter to run (nftables)", func(v string) error {
answers["packet-filter"] = v
return nil
})
set.Func("endpoint", "host:port other machines dial for the private network (derived from the broker address if unsaid)", func(v string) error {
answers["endpoint"] = v
return nil
})
set.Func("extras", "catalogue modules beyond the floor, comma-separated", func(v string) error {
for _, e := range strings.Split(v, ",") {
if e = strings.TrimSpace(e); e != "" {
opts.Extras = append(opts.Extras, e)
}
}
return nil
})
return set
}
// askOn is how a person is asked a choice, when there is a person: the question, the options, a
// read line. Wired only when stdin is a terminal, so the lab and unattended runs are never left
// waiting on a prompt nobody will answer.
func askOn(in *bufio.Reader, out io.Writer) func(bootstrap.Choice) (string, error) {
return func(c bootstrap.Choice) (string, error) {
fmt.Fprintf(out, "\n%s\n", c.Question)
if len(c.Options) > 0 {
fmt.Fprintf(out, " options: %s\n", strings.Join(c.Options, ", "))
}
if c.Default != "" {
fmt.Fprintf(out, " [%s] ", c.Default)
} else {
fmt.Fprint(out, " > ")
}
line, err := in.ReadString('\n')
if err != nil {
return "", fmt.Errorf("the terminal went away mid-question: %w", err)
}
return strings.TrimSpace(line), nil
}
}
func run(ctx context.Context, command string, opts bootstrap.Options, jsonOut bool) error {
switch command {
case "bootstrap":
@@ -220,6 +306,13 @@ func run(ctx context.Context, command string, opts bootstrap.Options, jsonOut bo
fmt.Println(line)
}
}
// A person at a terminal is asked the choices; anything else answers with flags. `--json`
// counts as "anything else": a run whose output is being parsed has no one reading a
// question.
if info, err := os.Stdin.Stat(); err == nil &&
info.Mode()&os.ModeCharDevice != 0 && !jsonOut {
opts.Prompt = askOn(bufio.NewReader(os.Stdin), os.Stdout)
}
result, err := bootstrap.Run(ctx, opts, bootstrap.Deps{
Run: apply.ExecRunner,
Dial: dial,
+2
View File
@@ -108,6 +108,8 @@ func TestTheUsageTextAndTheFlagsAgree(t *testing.T) {
for _, promised := range []string{
"bundle", "out", "state", "system", "timeout", "wait", "dry-run", "json",
"catalog", "node", "registry", "host", "host-service", "host-in-background",
"tools-source", "tools-ref", "catalog-source", "catalog-ref",
"private-network", "endpoint", "site", "packet-filter", "extras",
} {
if !declared[promised] {
t.Errorf("the usage text promises --%s and no such flag exists", promised)