diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go index ece2879..2f53572 100644 --- a/internal/dispatch/dispatch.go +++ b/internal/dispatch/dispatch.go @@ -17,6 +17,7 @@ import ( "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 @@ -103,8 +104,52 @@ func find(name string) (Builder, bool) { return Builder{}, false } -// Main is the `crucible` dispatcher entrypoint. -func Main(args []string) int { return run(args, os.Stdout, os.Stderr) } +// 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-` entrypoint. func RunBuilder(name string, args []string) int { diff --git a/internal/dispatch/dispatch_test.go b/internal/dispatch/dispatch_test.go index f5ce543..7315f21 100644 --- a/internal/dispatch/dispatch_test.go +++ b/internal/dispatch/dispatch_test.go @@ -135,3 +135,22 @@ func TestLegacyCommandsHaveExactlyOneHome(t *testing.T) { } } } + +func TestMenuItemsSkipsUnwiredIncludesWired(t *testing.T) { + items := menuItems() + if len(items) == 0 { + t.Fatal("expected menu items") + } + sawModuleBuild := false + for _, it := range items { + if it.Args[0] == "depot" { + t.Errorf("unwired builder %q must not appear in the menu", it.Args[0]) + } + if len(it.Args) == 2 && it.Args[0] == "module" && it.Args[1] == "build" { + sawModuleBuild = true + } + } + if !sawModuleBuild { + t.Error("expected 'module build' in the menu") + } +}