Files
sow-tools/internal/dispatch/dispatch.go
T
archvillainetteandClaude Opus 5 9e52a62e66
ci / ci (pull_request) Successful in 3m52s
fix(wiki): purge through the plugin page actions and count reset deletions
deploy-wiki purged pages with DELETE /api/v3/topics/{tid}. A NodeBB
running nodebb-plugin-westgate-wiki refuses that for topics in wiki
categories, because wiki revision history is plugin-owned, so every
purge failed with HTTP 400 and the deploy exited 1. Purge now goes
through the plugin's own page actions: tombstone the page, then
hard-purge it, since a page must be tombstoned before it can be purged.

Archiving is unaffected: it rewrites the page through the ordinary post
edit the plugin allows, rather than deleting anything.

Every fake NodeBB in the deploy tests now goes through one constructor
that refuses native topic mutation the way the plugin does. The old
fakes answered the core API, which is why this shipped green.

A namespace reset can reach pages NodeBB will not delete at all - the
wiki home topic above all - so those are skipped rather than aborting
the reset. NodeBB answers 403 for that and for a token without purge
privileges alike, and the response cannot tell them apart; what can is
scope, so a category where nothing at all could be deleted still fails
the run.

Under --reset-managed-namespaces the preview reported stale: 0 on a run
that would delete every topic in the managed categories, because the
reset purge never went through stale computation. The reset deletions
are now counted as stale, which is the number callers word their
destructive-policy warning around, and the summary names how many of
them the manifest has no record of writing - the deletions a re-seed
cannot undo.

Closes #99
Closes #100

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:44:09 +02:00

525 lines
18 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
// 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). depot is wired but bypasses that legacy surface
// entirely, delegating straight to internal/depot.Run. A builder with no
// migrated logic yet keeps the Wired=false fail-closed path: exit 70, never a
// faked artifact.
package dispatch
import (
"fmt"
"io"
"os"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/app"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/assets"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/depot"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/menu"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/nwsync"
)
// 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
Commands []Command // visible commands plus hidden compatibility aliases
Wired bool // true once the builder delegates to migrated internal/app logic
}
// Command is one public builder action. AppCommand names the unchanged
// internal/app implementation command. Aliases remain callable for compatibility
// but are omitted from routine help and the interactive menu.
type Command struct {
Name string
Summary string
AppCommand string
Usage string
Options []string
Aliases []CommandAlias
}
type CommandAlias struct {
Name string
AppCommand string
}
func (b Builder) subcommands() []string {
commands := make([]string, 0, len(b.Commands))
for _, command := range b.Commands {
commands = append(commands, command.Name)
}
return commands
}
func (b Builder) command(name string) (Command, string, bool) {
for _, command := range b.Commands {
if command.Name == name {
return command, command.AppCommand, true
}
for _, alias := range command.Aliases {
if alias.Name == name {
target := alias.AppCommand
if target == "" {
target = command.AppCommand
}
return command, target, true
}
}
}
return Command{}, "", false
}
func (b Builder) accepts(sub string) bool {
_, _, ok := b.command(sub)
return ok
}
// 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: "content-addressed asset depot (disk/cdn/bunny)",
Commands: []Command{
{Name: "status", Summary: "report referenced-vs-present drift against a backend", Usage: "crucible depot status [--manifests DIR] --target bunny|cdn | --out DIR"},
{Name: "push", Summary: "upload referenced-but-absent blobs from a local depot", Usage: "crucible depot push [--manifests DIR] --source DIR --target bunny"},
{Name: "verify", Summary: "existence sweep plus sampled download re-hash", Usage: "crucible depot verify [--manifests DIR] --target bunny|cdn [--sample N]"},
{Name: "get", Summary: "fetch one blob with sha re-verify", Usage: "crucible depot get <sha> <dest> --target cdn|bunny | --out DIR"},
{Name: "pull", Summary: "incremental verified pull of every referenced blob", Usage: "crucible depot pull [--manifests DIR] --dest DIR --target cdn|bunny"},
},
Wired: true,
},
{
Name: "nwsync",
Bin: "crucible-nwsync",
Summary: "publish NWSync blobs and manifests (emit/assemble/verify)",
Commands: []Command{
{Name: "emit", Summary: "explode one artifact into blobs plus its own NSYM manifest", Usage: "crucible nwsync emit <artifact> --out DIR"},
{Name: "assemble", Summary: "merge per-artifact NSYM manifests into one", Usage: "crucible nwsync assemble --order NAMES --entries DIR --out DIR [--group-id N]"},
{Name: "verify", Summary: "read a published manifest's blobs back through the pull zone and hash them", Usage: "crucible nwsync verify <manifest-sha1> [--sample N]"},
},
Wired: true,
},
{
Name: "assets",
Bin: "crucible-assets",
Summary: "compile/convert/upscale NWN assets + mdl/dupe integrity",
Commands: []Command{
{Name: "compile", Summary: "compile ASCII .mdl models to binary in place", Usage: "crucible assets compile [--nwn INSTALL] [--non-recursive] <dir>..."},
{Name: "convert", Summary: "convert textures to/from NWN DDS (flips vertically)", Usage: "crucible assets convert [--to dds|png|tga] [--backend PATH] [--non-recursive] <dir>..."},
{Name: "upscale", Summary: "upscale textures through an installed backend", Usage: "crucible assets upscale [--scale N] [--backend PATH] [--non-recursive] <dir>..."},
{Name: "check-mdl", Summary: "report uncompiled ASCII .mdl and model-name mismatches", Usage: "crucible assets check-mdl <path>..."},
{Name: "fix-mdl", Summary: "lowercase .mdl names and rewrite model identity to match", Usage: "crucible assets fix-mdl [--dry-run] <path>..."},
{Name: "check-dupes", Summary: "report runtime-name (basename) collisions across dirs", Usage: "crucible assets check-dupes <dir>..."},
{Name: "clean-dupes", Summary: "delete clean-tree files whose basename collides with primary", Usage: "crucible assets clean-dupes [--dry-run] <primary> <clean>"},
},
Wired: true,
},
{
Name: "hak",
Bin: "crucible-hak",
Summary: "pack/unpack ERF/HAK archives + hak manifests",
Commands: []Command{
{
Name: "build",
Summary: "build configured HAK archives and their manifest",
AppCommand: "build-haks",
Usage: "crucible hak build [options]",
Options: []string{
"--hak <name> build selected configured HAKs",
"--archive <name> build selected archive members",
"--source-manifest <path> read a generated source manifest",
"--content-addressed-root <path> resolve source-manifest blobs here",
"--plan-only plan outputs without writing archives",
"--quiet | --verbose | --debug select output detail",
},
Aliases: []CommandAlias{{Name: "build-haks"}},
},
{
Name: "manifest",
Summary: "apply a generated HAK list to the module source",
AppCommand: "apply-hak-manifest",
Usage: "crucible hak manifest [manifest-path]",
Aliases: []CommandAlias{{Name: "apply-hak-manifest"}},
},
},
Wired: true,
},
{
Name: "module",
Bin: "crucible-module",
Summary: "build/extract/validate/compare the .mod",
Commands: []Command{
{
Name: "build",
Summary: "build the module and configured project outputs",
AppCommand: "build",
Usage: "crucible module build [--quiet|--verbose|--debug]",
Aliases: []CommandAlias{{Name: "build-module", AppCommand: "build-module"}},
},
{
Name: "extract",
Summary: "extract built archives into configured source trees",
AppCommand: "extract",
Usage: "crucible module extract [resource ...]",
},
{
Name: "validate",
Summary: "validate project layout, config, and source inventory",
AppCommand: "validate",
Usage: "crucible module validate",
},
{
Name: "compare",
Summary: "compare source content with built module and HAK archives",
AppCommand: "compare",
Usage: "crucible module compare",
},
{
Name: "manifest",
Summary: "apply a generated HAK list to the module source",
AppCommand: "apply-hak-manifest",
Usage: "crucible module manifest [manifest-path]",
Aliases: []CommandAlias{{Name: "apply-hak-manifest"}},
},
},
Wired: true,
},
{
Name: "topdata",
Bin: "crucible-topdata",
Summary: "compile 2da/tlk topdata + packages",
Commands: []Command{
{
Name: "validate",
Summary: "validate topdata layout and canonical JSON compatibility",
AppCommand: "validate-topdata",
Usage: "crucible topdata validate",
Aliases: []CommandAlias{{Name: "validate-topdata"}},
},
{
Name: "build",
Summary: "compile topdata and refresh the configured package",
AppCommand: "build-topdata",
Usage: "crucible topdata build [--force] [--wiki] [--skip-lfs]",
Options: []string{
"--force rebuild outputs even when current",
"--wiki build wiki drafts after compilation",
"--skip-lfs skip Git LFS materialization",
},
Aliases: []CommandAlias{{Name: "build-topdata"}},
},
{
Name: "package",
Summary: "package cached topdata outputs into HAK and TLK files",
AppCommand: "build-top-package",
Usage: "crucible topdata package [--force] [--skip-lfs]",
Options: []string{
"--force rebuild outputs even when current",
"--skip-lfs skip Git LFS materialization",
},
Aliases: []CommandAlias{{Name: "build-top-package"}},
},
{
Name: "compare",
Summary: "compare current output with a fresh native build",
AppCommand: "compare-topdata",
Usage: "crucible topdata compare",
Aliases: []CommandAlias{{Name: "compare-topdata"}},
},
{
Name: "convert",
Summary: "convert between 2DA and native JSON/module formats",
AppCommand: "convert-topdata",
Usage: "crucible topdata convert <2da-to-json|2da-to-module|json-to-2da> ...",
Options: []string{
"2da-to-json <input.2da> <output.json>",
"2da-to-module [flags] <input.2da> [output.json]",
"json-to-2da <input.json> <output.2da>",
},
Aliases: []CommandAlias{{Name: "convert-topdata"}},
},
},
Wired: true,
},
{
Name: "wiki",
Bin: "crucible-wiki",
Summary: "render + deploy mechanical wiki pages",
Commands: []Command{
{
Name: "build",
Summary: "render wiki page drafts from compiled topdata",
AppCommand: "build-wiki",
Usage: "crucible wiki build [--force]",
Options: []string{"--force rebuild drafts even when current"},
Aliases: []CommandAlias{{Name: "build-wiki"}},
},
{
Name: "deploy",
Summary: "deploy generated wiki pages to NodeBB",
AppCommand: "deploy-wiki",
Usage: "crucible wiki deploy [options]",
Options: []string{
"--source-dir <path> read generated pages here",
"--endpoint <url> override the NodeBB endpoint",
"--token <token> authenticate with an API token",
"--username/--password <value> authenticate with credentials",
"--version <version> label the deployed revision",
"--namespace <name> deploy selected namespaces",
"--category <namespace=id> override a namespace category",
"--manifest <path> write deployment state here",
"--stale-policy <policy> report, archive, or purge",
"--dry-run report changes without writing",
"--create allow missing pages to be created",
"--force update unchanged pages",
"--reset-managed-namespaces delete every page in the managed categories, including",
" pages this deployer never wrote, then recreate from source",
},
Aliases: []CommandAlias{{Name: "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() {
command, _, _ := b.command(sub)
items = append(items, menu.Item{
Label: b.Name + " " + sub,
Desc: command.Summary,
Args: []string{b.Name, sub},
Usage: command.Usage,
Options: append([]string(nil),
command.Options...),
})
}
}
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 {
// depot, assets and nwsync are self-contained builders: they parse their own
// subcommands and own their exit contract, bypassing the
// b.Commands/delegateLegacy legacy routing entirely.
switch b.Name {
case "depot":
return depot.Run(args, out, errw, os.Getenv)
case "assets":
return assets.Run(args, out, errw, os.Getenv)
case "nwsync":
return nwsync.Run(args, out, errw)
}
}
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]
command, appCommand, ok := b.command(sub)
if !ok {
fmt.Fprintf(errw, "crucible %s: unknown subcommand %q\n\n", b.Name, sub)
builderHelp(errw, b)
return exitUsage
}
if len(args) > 1 {
switch args[1] {
case "-h", "--help", "help":
commandHelp(out, command)
return exitOK
}
}
legacyArgs := append([]string{appCommand}, args[1:]...)
return delegateLegacy(legacyArgs, 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. A builder that one day needs NWN game data\n")
fmt.Fprintf(w, "auto-detects the install; it never requires a hand-set path. See\n")
fmt.Fprintf(w, "docs/consumer-contract.md.\n")
}
func list(w io.Writer) {
for _, b := range Registry {
fmt.Fprintf(w, "%s\t%s\t%s\n", b.Name, b.Bin, b.Summary)
}
}
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")
width := 0
for _, command := range b.Commands {
if len(command.Name) > width {
width = len(command.Name)
}
}
for _, command := range b.Commands {
fmt.Fprintf(w, " %-*s %s\n", width, command.Name, command.Summary)
}
fmt.Fprintf(w, "\nstatus: wired to migrated nwn-tool logic. See docs/command-surface.md.\n")
}
func commandHelp(w io.Writer, command Command) {
fmt.Fprintf(w, "%s\n", command.Summary)
if command.Usage != "" {
fmt.Fprintf(w, "\nusage: %s\n", command.Usage)
}
if len(command.Options) > 0 {
fmt.Fprintln(w, "\noptions:")
for _, option := range command.Options {
fmt.Fprintf(w, " %s\n", option)
}
}
}