280 lines
8.9 KiB
Go
280 lines
8.9 KiB
Go
// Package dispatch is the Crucible command surface: a single registry of
|
|
// builders shared by the `crucible` dispatcher and the standalone
|
|
// `crucible-<name>` shims.
|
|
//
|
|
// Cutover state (Phase 5 → 6): the internal pipeline/topdata/erf/wiki/music
|
|
// packages migrated from gitea/sow-tools now live in this tree, so wired
|
|
// builders delegate to internal/app's legacy command surface (mapped per
|
|
// docs/command-surface.md). A builder with no migrated logic yet (depot) keeps
|
|
// the Wired=false fail-closed path: exit 70, never a faked artifact.
|
|
package dispatch
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"text/tabwriter"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/app"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/menu"
|
|
)
|
|
|
|
// 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)
|
|
Extra []string // additional callable subcommands beyond Legacy (e.g. cross-listed "music")
|
|
Wired bool // true once the builder delegates to migrated internal/app logic
|
|
}
|
|
|
|
// subcommands returns every legacy command a wired builder accepts: the
|
|
// canonical Legacy set plus any cross-listed Extra commands.
|
|
func (b Builder) subcommands() []string { return append(append([]string{}, b.Legacy...), b.Extra...) }
|
|
|
|
func (b Builder) accepts(sub string) bool {
|
|
for _, s := range b.subcommands() {
|
|
if s == sub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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,
|
|
Wired: false, // no migrated logic yet; fails closed (exit 70)
|
|
},
|
|
{
|
|
Name: "hak",
|
|
Bin: "crucible-hak",
|
|
Summary: "pack/unpack ERF/HAK archives + hak manifests",
|
|
Legacy: []string{"build-haks", "apply-hak-manifest"},
|
|
Extra: []string{"music"}, // music BMUs are packed into HAKs (command-surface.md)
|
|
Wired: true,
|
|
},
|
|
{
|
|
Name: "module",
|
|
Bin: "crucible-module",
|
|
Summary: "build/extract/validate/compare the .mod",
|
|
Legacy: []string{"build", "build-module", "extract", "validate", "compare"},
|
|
// apply-hak-manifest is canonically a hak command but reachable here too;
|
|
// music is folded into the module build pipeline (command-surface.md).
|
|
Extra: []string{"apply-hak-manifest", "music"},
|
|
Wired: true,
|
|
},
|
|
{
|
|
Name: "topdata",
|
|
Bin: "crucible-topdata",
|
|
Summary: "compile 2da/tlk topdata + packages",
|
|
Legacy: []string{"validate-topdata", "build-topdata", "build-top-package", "compare-topdata", "convert-topdata"},
|
|
Wired: true,
|
|
},
|
|
{
|
|
Name: "wiki",
|
|
Bin: "crucible-wiki",
|
|
Summary: "render + deploy mechanical wiki pages",
|
|
Legacy: []string{"build-wiki", "deploy-wiki"},
|
|
Wired: true,
|
|
},
|
|
}
|
|
|
|
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. With no arguments on an
|
|
// interactive terminal it launches the menu; otherwise (pipes, CI) it prints
|
|
// usage, preserving scriptable behavior.
|
|
func Main(args []string) int {
|
|
if len(args) == 0 && interactive(os.Stdout) {
|
|
return runMenu(os.Stdout, os.Stderr, os.Stdin)
|
|
}
|
|
return run(args, os.Stdout, os.Stderr)
|
|
}
|
|
|
|
func interactive(f *os.File) bool {
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return fi.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
func menuItems() []menu.Item {
|
|
var items []menu.Item
|
|
for _, b := range Registry {
|
|
if !b.Wired {
|
|
continue
|
|
}
|
|
for _, sub := range b.subcommands() {
|
|
items = append(items, menu.Item{
|
|
Label: b.Name + " " + sub,
|
|
Desc: b.Summary,
|
|
Args: []string{b.Name, sub},
|
|
})
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func runMenu(out, errw io.Writer, in io.Reader) int {
|
|
chosen, err := menu.Select(out, in, menuItems())
|
|
if err != nil {
|
|
fmt.Fprintln(errw, "crucible:", err)
|
|
return exitUsage
|
|
}
|
|
if chosen == nil {
|
|
return exitOK
|
|
}
|
|
return run(chosen, out, errw)
|
|
}
|
|
|
|
// 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
|
|
case "config":
|
|
// Global concern shared by every builder (command-surface.md): the legacy
|
|
// `config` command group, surfaced verbatim.
|
|
return delegateLegacy(args, errw)
|
|
case "changelog":
|
|
// Release tooling: global alias for the legacy `build-changelog` command.
|
|
return delegateLegacy(append([]string{"build-changelog"}, args[1:]...), errw)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if !b.Wired {
|
|
// No migrated logic yet (depot): fail closed, never fake an artifact.
|
|
fmt.Fprintf(errw, unwiredMsg, b.Name, b.Bin)
|
|
return exitUnwired
|
|
}
|
|
if len(args) == 0 {
|
|
fmt.Fprintf(errw, "crucible %s: a subcommand is required\n\n", b.Name)
|
|
builderHelp(errw, b)
|
|
return exitUsage
|
|
}
|
|
sub := args[0]
|
|
if !b.accepts(sub) {
|
|
fmt.Fprintf(errw, "crucible %s: unknown subcommand %q\n\n", b.Name, sub)
|
|
builderHelp(errw, b)
|
|
return exitUsage
|
|
}
|
|
// Delegate to the migrated legacy command surface. args[0] is already the
|
|
// legacy command name, so it is forwarded unchanged.
|
|
return delegateLegacy(args, errw)
|
|
}
|
|
|
|
// delegateLegacy runs a migrated nwn-tool command via internal/app and maps its
|
|
// (code, err) result onto the dispatcher's exit code. app writes its own output
|
|
// to os.Stdout/os.Stderr (the writers the entrypoints pass through), so only the
|
|
// returned error is surfaced here.
|
|
func delegateLegacy(args []string, errw io.Writer) int {
|
|
code, err := app.Run(args)
|
|
if err != nil {
|
|
fmt.Fprintln(errw, err)
|
|
}
|
|
return code
|
|
}
|
|
|
|
const unwiredMsg = `crucible %[1]s: not wired yet.
|
|
|
|
The %[2]s builder has no migrated logic in this tree yet, so it fails closed
|
|
(exit 70) rather than producing a fake artifact. See docs/command-surface.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, " config [args] inspect/validate effective configuration\n")
|
|
fmt.Fprintf(w, " changelog [args] generate the release changelog\n")
|
|
fmt.Fprintf(w, "\nBuilders read all inputs from the project tree (nwn-tool.yaml + data/); no\n")
|
|
fmt.Fprintf(w, "NWN install is required. If a builder ever needs an NWN root, it must be\n")
|
|
fmt.Fprintf(w, "passed explicitly via NWN_ROOT; crucible never guesses from $HOME. See\n")
|
|
fmt.Fprintf(w, "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 !b.Wired {
|
|
fmt.Fprintf(w, "status: not wired — no migrated logic yet; fails closed (exit 70).\n")
|
|
fmt.Fprintf(w, "See docs/command-surface.md.\n")
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "usage: crucible %s <subcommand> [args] (or: %s <subcommand> [args])\n\n", b.Name, b.Bin)
|
|
fmt.Fprintf(w, "subcommands:\n")
|
|
for _, c := range b.subcommands() {
|
|
fmt.Fprintf(w, " %s\n", c)
|
|
}
|
|
fmt.Fprintf(w, "\nstatus: wired to migrated nwn-tool logic. See docs/command-surface.md.\n")
|
|
}
|