Phase 5: scaffold Crucible (sow-tools) — dispatcher + builder shims, container, PR-first CI, fail-closed (D11, D19).
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// Package dispatch is the Crucible command surface: a single registry of
|
||||
// builders shared by the `crucible` dispatcher and the standalone
|
||||
// `crucible-<name>` shims.
|
||||
//
|
||||
// Scaffold contract (Phase 5): every builder is registered but UNWIRED. Running
|
||||
// a builder fails closed (exit 70) with an operator-cutover message; it never
|
||||
// fakes an artifact. The internal pipeline/topdata/erf/wiki/music packages from
|
||||
// gitea/sow-tools are migrated by the operator at cutover (the workspace hard
|
||||
// rules forbid transplanting source here). Once a builder's handler is wired,
|
||||
// flip its Wired flag and register the handler.
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
|
||||
)
|
||||
|
||||
// Exit codes follow the sysexits(3) convention so CI can distinguish
|
||||
// "you called it wrong" from "the tool is not wired yet".
|
||||
const (
|
||||
exitOK = 0
|
||||
exitUsage = 64 // EX_USAGE — bad invocation / unknown builder
|
||||
exitUnwired = 70 // EX_SOFTWARE — builder registered but not yet implemented
|
||||
)
|
||||
|
||||
// Builder is one tool surface in the Crucible suite.
|
||||
type Builder struct {
|
||||
Name string // canonical dispatcher subcommand, e.g. "module"
|
||||
Bin string // standalone binary name, e.g. "crucible-module"
|
||||
Summary string // one-line description
|
||||
Legacy []string // nwn-tool commands this subsumes (migration aid; see docs/command-surface.md)
|
||||
}
|
||||
|
||||
// Registry is the single source of truth for the Crucible command surface.
|
||||
// Keep it in sync with docs/command-surface.md and the cmd/ directory.
|
||||
var Registry = []Builder{
|
||||
{
|
||||
Name: "depot",
|
||||
Bin: "crucible-depot",
|
||||
Summary: "verify/move content-addressed depot blobs (SeaweedFS)",
|
||||
Legacy: nil,
|
||||
},
|
||||
{
|
||||
Name: "hak",
|
||||
Bin: "crucible-hak",
|
||||
Summary: "pack/unpack ERF/HAK archives + hak manifests",
|
||||
Legacy: []string{"build-haks", "apply-hak-manifest"},
|
||||
},
|
||||
{
|
||||
Name: "module",
|
||||
Bin: "crucible-module",
|
||||
Summary: "build/extract/validate/compare the .mod",
|
||||
Legacy: []string{"build", "build-module", "extract", "validate", "compare"},
|
||||
},
|
||||
{
|
||||
Name: "topdata",
|
||||
Bin: "crucible-topdata",
|
||||
Summary: "compile 2da/tlk topdata + packages",
|
||||
Legacy: []string{"validate-topdata", "build-topdata", "build-top-package", "compare-topdata", "convert-topdata"},
|
||||
},
|
||||
{
|
||||
Name: "wiki",
|
||||
Bin: "crucible-wiki",
|
||||
Summary: "render + deploy mechanical wiki pages",
|
||||
Legacy: []string{"build-wiki", "deploy-wiki"},
|
||||
},
|
||||
}
|
||||
|
||||
func find(name string) (Builder, bool) {
|
||||
for _, b := range Registry {
|
||||
if b.Name == name {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
return Builder{}, false
|
||||
}
|
||||
|
||||
// Main is the `crucible` dispatcher entrypoint.
|
||||
func Main(args []string) int { return run(args, os.Stdout, os.Stderr) }
|
||||
|
||||
// RunBuilder is the standalone `crucible-<name>` entrypoint.
|
||||
func RunBuilder(name string, args []string) int {
|
||||
return runBuilder(name, args, os.Stdout, os.Stderr)
|
||||
}
|
||||
|
||||
func run(args []string, out, errw io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
usage(out)
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
usage(out)
|
||||
return exitOK
|
||||
case "-V", "--version", "version":
|
||||
fmt.Fprintln(out, buildinfo.String())
|
||||
return exitOK
|
||||
case "list":
|
||||
list(out)
|
||||
return exitOK
|
||||
}
|
||||
return runBuilder(args[0], args[1:], out, errw)
|
||||
}
|
||||
|
||||
func runBuilder(name string, args []string, out, errw io.Writer) int {
|
||||
b, ok := find(name)
|
||||
if !ok {
|
||||
fmt.Fprintf(errw, "crucible: unknown builder %q\n\n", name)
|
||||
usage(errw)
|
||||
return exitUsage
|
||||
}
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
builderHelp(out, b)
|
||||
return exitOK
|
||||
}
|
||||
}
|
||||
// Every builder is unwired in the Phase 5 scaffold: fail closed, never fake.
|
||||
fmt.Fprintf(errw, unwiredMsg, b.Name, b.Bin)
|
||||
return exitUnwired
|
||||
}
|
||||
|
||||
const unwiredMsg = `crucible %[1]s: not wired yet.
|
||||
|
||||
The Crucible suite is scaffolded (Phase 5). The %[2]s implementation is migrated
|
||||
from gitea/sow-tools internal packages at operator cutover; the workspace hard
|
||||
rules forbid transplanting that source into this tree automatically.
|
||||
|
||||
This command fails closed (exit 70) rather than producing a fake artifact.
|
||||
See docs/migration-from-nwn-tool.md.
|
||||
`
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintf(w, "crucible — Shadows Over Westgate build/conversion/sync toolchain\n")
|
||||
fmt.Fprintf(w, "%s\n\n", buildinfo.String())
|
||||
fmt.Fprintf(w, "usage:\n")
|
||||
fmt.Fprintf(w, " crucible <builder> [args] dispatch to a builder\n")
|
||||
fmt.Fprintf(w, " crucible-<builder> [args] equivalent standalone binary\n\n")
|
||||
fmt.Fprintf(w, "builders:\n")
|
||||
list(w)
|
||||
fmt.Fprintf(w, "\nglobal commands:\n")
|
||||
fmt.Fprintf(w, " help | -h show this help\n")
|
||||
fmt.Fprintf(w, " version | -V show the build version\n")
|
||||
fmt.Fprintf(w, " list list builders (one per line: name<TAB>bin<TAB>summary)\n")
|
||||
fmt.Fprintf(w, "\nNWN_ROOT must be passed explicitly (env or flag); crucible never guesses\n")
|
||||
fmt.Fprintf(w, "from $HOME. See docs/consumer-contract.md.\n")
|
||||
}
|
||||
|
||||
func list(w io.Writer) {
|
||||
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
|
||||
for _, b := range Registry {
|
||||
fmt.Fprintf(tw, " %s\t%s\t%s\n", b.Name, b.Bin, b.Summary)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func builderHelp(w io.Writer, b Builder) {
|
||||
fmt.Fprintf(w, "crucible %s (%s) — %s\n\n", b.Name, b.Bin, b.Summary)
|
||||
if len(b.Legacy) > 0 {
|
||||
fmt.Fprintf(w, "subsumes nwn-tool commands: ")
|
||||
for i, c := range b.Legacy {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(w, ", ")
|
||||
}
|
||||
fmt.Fprintf(w, "%s", c)
|
||||
}
|
||||
fmt.Fprintf(w, "\n\n")
|
||||
}
|
||||
fmt.Fprintf(w, "status: scaffolded, not wired (Phase 5). See docs/command-surface.md.\n")
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
var out, errw bytes.Buffer
|
||||
for _, arg := range []string{"version", "-V", "--version"} {
|
||||
out.Reset()
|
||||
errw.Reset()
|
||||
if code := run([]string{arg}, &out, &errw); code != exitOK {
|
||||
t.Fatalf("%s: exit=%d want %d", arg, code, exitOK)
|
||||
}
|
||||
if !strings.HasPrefix(out.String(), "crucible ") {
|
||||
t.Fatalf("%s: version line %q missing prefix", arg, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpAndNoArgs(t *testing.T) {
|
||||
var out, errw bytes.Buffer
|
||||
if code := run([]string{"help"}, &out, &errw); code != exitOK {
|
||||
t.Fatalf("help exit=%d want %d", code, exitOK)
|
||||
}
|
||||
if !strings.Contains(out.String(), "builders:") {
|
||||
t.Fatalf("help output missing builders section:\n%s", out.String())
|
||||
}
|
||||
// No args is a usage error (exit 64) but still prints help.
|
||||
out.Reset()
|
||||
if code := run(nil, &out, &errw); code != exitUsage {
|
||||
t.Fatalf("no-args exit=%d want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCoversRegistry(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
list(&out)
|
||||
for _, b := range Registry {
|
||||
if !strings.Contains(out.String(), b.Name) || !strings.Contains(out.String(), b.Bin) {
|
||||
t.Fatalf("list missing %s/%s:\n%s", b.Name, b.Bin, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownBuilderFailsUsage(t *testing.T) {
|
||||
var out, errw bytes.Buffer
|
||||
if code := run([]string{"frobnicate"}, &out, &errw); code != exitUsage {
|
||||
t.Fatalf("unknown builder exit=%d want %d", code, exitUsage)
|
||||
}
|
||||
if !strings.Contains(errw.String(), "unknown builder") {
|
||||
t.Fatalf("unknown builder stderr=%q", errw.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryBuilderFailsClosed(t *testing.T) {
|
||||
for _, b := range Registry {
|
||||
var out, errw bytes.Buffer
|
||||
// Via dispatcher.
|
||||
if code := run([]string{b.Name}, &out, &errw); code != exitUnwired {
|
||||
t.Errorf("crucible %s: exit=%d want %d (must fail closed)", b.Name, code, exitUnwired)
|
||||
}
|
||||
if !strings.Contains(errw.String(), "not wired") {
|
||||
t.Errorf("crucible %s: stderr missing fail-closed message: %q", b.Name, errw.String())
|
||||
}
|
||||
// Via standalone shim path.
|
||||
out.Reset()
|
||||
errw.Reset()
|
||||
if code := runBuilder(b.Name, nil, &out, &errw); code != exitUnwired {
|
||||
t.Errorf("%s: exit=%d want %d (must fail closed)", b.Bin, code, exitUnwired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderHelpIsOK(t *testing.T) {
|
||||
for _, b := range Registry {
|
||||
var out, errw bytes.Buffer
|
||||
if code := runBuilder(b.Name, []string{"--help"}, &out, &errw); code != exitOK {
|
||||
t.Errorf("%s --help: exit=%d want %d", b.Name, code, exitOK)
|
||||
}
|
||||
if !strings.Contains(out.String(), b.Summary) {
|
||||
t.Errorf("%s --help: missing summary", b.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user