ci / ci (pull_request) Successful in 3m20s
`depot status` and `depot get` chose a local depot tree with `--target local`, resolving the root from `DEPOT_DIR`. That split one decision over two flags and left `--target local` valid with no directory anywhere. Adopt the nwsync convention instead: `--out DIR` names the tree on disk in the same breath as choosing it, and `--target` now names remote backends only. The two are mutually exclusive. `--target local` stays as a deprecated alias for `--out $DEPOT_DIR` so existing callers keep working, and `status --source` is now ignored with a warning instead of silently accepted. Also split positionals off by hand in `depot get`, so the documented `depot get <sha> <dest> --out DIR` form parses its flags at all — Go's flag package stops at the first positional. Closes #66
510 lines
17 KiB
Go
510 lines
17 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"
|
|
)
|
|
|
|
// 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: "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 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-<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 and assets 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)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|