Cross-Platform Crucible (#2)
Crucible groundwork for cross-platform compatibility. Reviewed-on: #2 Reviewed-by: xtul <mpiasecki720@protonmail.com> Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #2.
This commit is contained in:
@@ -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-<name>` entrypoint.
|
||||
func RunBuilder(name string, args []string) int {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package menu renders Crucible's interactive launcher: a numbered list of
|
||||
// commands for users who don't want to memorize subcommands. It holds no
|
||||
// builder logic — it returns the chosen dispatcher argument slice for the
|
||||
// caller to run. This is the seed of a future GUI front-end over the same core.
|
||||
package menu
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Item is one selectable command.
|
||||
type Item struct {
|
||||
Label string // e.g. "module build"
|
||||
Desc string // e.g. "build/extract/validate/compare the .mod"
|
||||
Args []string // dispatcher args, e.g. {"module", "build"}
|
||||
}
|
||||
|
||||
// ANSI styling; terminals that don't support it simply ignore the codes.
|
||||
const (
|
||||
cReset = "\033[0m"
|
||||
cBold = "\033[1m"
|
||||
cCyan = "\033[36m"
|
||||
cDim = "\033[2m"
|
||||
)
|
||||
|
||||
// Select prints the numbered menu to out, reads a selection plus optional extra
|
||||
// arguments from in, and returns the full dispatcher argument slice. It returns
|
||||
// (nil, nil) when the user quits or enters an empty choice.
|
||||
func Select(out io.Writer, in io.Reader, items []Item) ([]string, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("no commands available")
|
||||
}
|
||||
fmt.Fprintf(out, "%s%sCrucible%s — pick a command:\n\n", cBold, cCyan, cReset)
|
||||
for i, it := range items {
|
||||
fmt.Fprintf(out, " %s%2d%s %s%-24s%s %s%s%s\n",
|
||||
cCyan, i+1, cReset, cBold, it.Label, cReset, cDim, it.Desc, cReset)
|
||||
}
|
||||
fmt.Fprintf(out, " %sq%s quit\n\nselection: ", cCyan, cReset)
|
||||
|
||||
r := bufio.NewReader(in)
|
||||
line, _ := r.ReadString('\n')
|
||||
switch choice := strings.TrimSpace(line); choice {
|
||||
case "", "q", "Q":
|
||||
return nil, nil
|
||||
default:
|
||||
n, err := strconv.Atoi(choice)
|
||||
if err != nil || n < 1 || n > len(items) {
|
||||
return nil, fmt.Errorf("invalid selection %q", choice)
|
||||
}
|
||||
it := items[n-1]
|
||||
fmt.Fprintf(out, "extra args for %q (optional): ", it.Label)
|
||||
argLine, _ := r.ReadString('\n')
|
||||
extra := strings.Fields(strings.TrimSpace(argLine))
|
||||
return append(append([]string{}, it.Args...), extra...), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var sample = []Item{
|
||||
{Label: "module build", Desc: "build the .mod", Args: []string{"module", "build"}},
|
||||
{Label: "topdata validate-topdata", Desc: "validate topdata", Args: []string{"topdata", "validate-topdata"}},
|
||||
}
|
||||
|
||||
func TestSelectReturnsChosenArgs(t *testing.T) {
|
||||
got, err := Select(io.Discard, strings.NewReader("1\n\n"), sample)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := []string{"module", "build"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectAppendsExtraArgs(t *testing.T) {
|
||||
got, err := Select(io.Discard, strings.NewReader("2\n--dry-run foo\n"), sample)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"topdata", "validate-topdata", "--dry-run", "foo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectQuitReturnsNil(t *testing.T) {
|
||||
got, err := Select(io.Discard, strings.NewReader("q\n"), sample)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("quit should return (nil,nil); got (%v,%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectInvalidErrors(t *testing.T) {
|
||||
if _, err := Select(io.Discard, strings.NewReader("99\n"), sample); err == nil {
|
||||
t.Fatal("out-of-range selection should error")
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -1,6 +1,7 @@
|
||||
package music
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -104,7 +105,7 @@ func ResolveFFmpegWithConfig(configuredPath string) (string, error) {
|
||||
}
|
||||
path, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", ffmpegMissingErr("ffmpeg")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -122,11 +123,23 @@ func ResolveFFprobeWithConfig(configuredPath string) (string, error) {
|
||||
}
|
||||
path, err := exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", ffmpegMissingErr("ffprobe")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ffmpegMissingErr renders an actionable, cross-platform "tool not found"
|
||||
// message. Only the music builder needs ffmpeg/ffprobe; every other builder
|
||||
// runs with zero external dependencies.
|
||||
func ffmpegMissingErr(tool string) error {
|
||||
return fmt.Errorf(`%[1]s not found on PATH.
|
||||
Windows: winget install Gyan.FFmpeg
|
||||
macOS: brew install ffmpeg
|
||||
Linux: apt install ffmpeg (or your distro's package manager)
|
||||
Only the music builder needs %[1]s; set SOW_%[2]s to a full path to override.`,
|
||||
tool, strings.ToUpper(tool))
|
||||
}
|
||||
|
||||
func IsMusicAssetPath(rel string) bool {
|
||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||
return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/")
|
||||
|
||||
@@ -402,3 +402,16 @@ func TestResolveFFprobeEnv(t *testing.T) {
|
||||
t.Fatalf("expected '/custom/ffprobe', got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegMissingErrHasInstallHints(t *testing.T) {
|
||||
msg := ffmpegMissingErr("ffmpeg").Error()
|
||||
for _, want := range []string{"ffmpeg", "winget", "brew", "apt", "SOW_FFMPEG"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("ffmpeg error missing %q; got:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
probe := ffmpegMissingErr("ffprobe").Error()
|
||||
if !strings.Contains(probe, "SOW_FFPROBE") {
|
||||
t.Errorf("ffprobe error should mention SOW_FFPROBE; got:\n%s", probe)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user