// Package dispatch is the Crucible command surface: a single registry of // builders shared by the `crucible` dispatcher and the standalone // `crucible-` 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 --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)", Commands: []Command{ {Name: "emit", Summary: "explode one artifact into blobs plus its own NSYM manifest", Usage: "crucible nwsync emit --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]"}, }, 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] ..."}, {Name: "convert", Summary: "convert textures to/from NWN DDS (flips vertically)", Usage: "crucible assets convert [--to dds|png|tga] [--backend PATH] [--non-recursive] ..."}, {Name: "upscale", Summary: "upscale textures through an installed backend", Usage: "crucible assets upscale [--scale N] [--backend PATH] [--non-recursive] ..."}, {Name: "check-mdl", Summary: "report uncompiled ASCII .mdl and model-name mismatches", Usage: "crucible assets check-mdl ..."}, {Name: "fix-mdl", Summary: "lowercase .mdl names and rewrite model identity to match", Usage: "crucible assets fix-mdl [--dry-run] ..."}, {Name: "check-dupes", Summary: "report runtime-name (basename) collisions across dirs", Usage: "crucible assets check-dupes ..."}, {Name: "clean-dupes", Summary: "delete clean-tree files whose basename collides with primary", Usage: "crucible assets clean-dupes [--dry-run] "}, }, 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 build selected configured HAKs", "--archive build selected archive members", "--source-manifest read a generated source manifest", "--content-addressed-root 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 ", "2da-to-module [flags] [output.json]", "json-to-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 read generated pages here", "--endpoint override the NodeBB endpoint", "--token authenticate with an API token", "--username/--password authenticate with credentials", "--version label the deployed revision", "--namespace deploy selected namespaces", "--category override a namespace category", "--manifest write deployment state here", "--stale-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 reset managed namespace state", }, 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-` 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 [args] dispatch to a builder\n") fmt.Fprintf(w, " crucible- [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: namebinsummary)\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 [args] (or: %s [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) } } }