diff --git a/.gitea/workflows/build-binaries.yml b/.gitea/workflows/build-binaries.yml new file mode 100644 index 0000000..f4f5770 --- /dev/null +++ b/.gitea/workflows/build-binaries.yml @@ -0,0 +1,66 @@ +# Cross-platform Crucible binaries (D7 trigger standard): +# PR / push main -> cross-build ALL targets to prove they compile (no publish) +# tag v* -> build all targets + SHA256SUMS, upload to the Gitea release +# Crucible is pure Go (CGO_ENABLED=0), so cross-compiling is a fast loop. +name: build-binaries + +on: + pull_request: + push: + branches: [main] + tags: ['v*'] + +jobs: + build-binaries: + runs-on: nix-docker + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + + - name: Cross-build all targets + run: | + nix develop --command bash -c ' + set -euo pipefail + sha="$(git rev-parse --short=12 HEAD)" + ldflags="-s -w -X git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=${sha}" + rm -rf dist && mkdir -p dist + export CGO_ENABLED=0 + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + os="${target%/*}"; arch="${target#*/}" + ext=""; [ "$os" = windows ] && ext=".exe" + echo "building crucible-${os}-${arch}${ext}" + GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$ldflags" \ + -o "dist/crucible-${os}-${arch}${ext}" ./cmd/crucible + done + # Ship the canonical wrappers as release assets too: consumer + # drift-check fetches them from the latest release to compare. + cp wrappers/crucible.sh wrappers/crucible.ps1 dist/ + ( cd dist && sha256sum crucible-* > SHA256SUMS ) + cat dist/SHA256SUMS + ' + + - name: Upload to Gitea release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} + SERVER: ${{ github.server_url }} + run: | + nix develop --command bash -c ' + set -euo pipefail + api="${SERVER}/api/v1/repos/${REPO}" + auth="Authorization: token ${TOKEN}" + # Create the release (ignore "already exists"), then read its id. + curl -fsS -X POST -H "$auth" -H "Content-Type: application/json" \ + "${api}/releases" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" || true + id="$(curl -fsS -H "$auth" "${api}/releases/tags/${TAG}" \ + | grep -o "\"id\":[0-9]*" | head -1 | cut -d: -f2)" + for f in dist/*; do + echo "uploading $(basename "$f")" + curl -fsS -X POST -H "$auth" \ + -F "attachment=@${f}" \ + "${api}/releases/${id}/assets?name=$(basename "$f")" + done + ' diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index eeba638..6bfaeb7 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -9,7 +9,7 @@ name: build-image on: push: - tags: ["v*"] + tags: ['v*'] env: REGISTRY: registry.westgate.pw @@ -29,6 +29,7 @@ jobs: - name: Build OCI image (daemonless) run: nix build .#image + # This workflow only runs on v* tags, so every run is a release publish. - name: Publish image env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml deleted file mode 100644 index e7caf19..0000000 --- a/.gitea/workflows/release.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Tag a Crucible release: re-tag the already-built immutable image and attach -# per-platform binary bundles. Tag-gated; never PR-triggered (deploy/release is -# not a PR concern, D7). The : image from build-image is the source of truth. -name: release - -on: - push: - tags: - - "v*" - -env: - REGISTRY: registry.westgate.pw - IMAGE: deployment/crucible - -jobs: - release: - runs-on: nix-docker - steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 0 } - - - name: Build release binaries - run: | - nix develop --command bash -c ' - set -euo pipefail - GOOS=linux GOARCH=amd64 bash scripts/build-binaries.sh - mkdir -p dist - tar -C bin -czf "dist/crucible-linux-amd64-${GITHUB_REF_NAME}.tar.gz" . - ' - - - name: Upload release artifacts - uses: actions/upload-artifact@v4 - with: - name: crucible-${{ github.ref_name }} - path: dist/* - - # TODO(phase-6): re-tag registry.westgate.pw/deployment/crucible: as : - # and pin it from sow-platform releases/*.yml once the registry is live. 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") + } +} diff --git a/internal/menu/menu.go b/internal/menu/menu.go new file mode 100644 index 0000000..9503d18 --- /dev/null +++ b/internal/menu/menu.go @@ -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 + } +} diff --git a/internal/menu/menu_test.go b/internal/menu/menu_test.go new file mode 100644 index 0000000..3fe284d --- /dev/null +++ b/internal/menu/menu_test.go @@ -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") + } +} diff --git a/internal/music/music.go b/internal/music/music.go index 64584e5..d68ec3d 100644 --- a/internal/music/music.go +++ b/internal/music/music.go @@ -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/") diff --git a/internal/music/music_test.go b/internal/music/music_test.go index ba33de3..bb11d12 100644 --- a/internal/music/music_test.go +++ b/internal/music/music_test.go @@ -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) + } +} diff --git a/wrappers/crucible.ps1 b/wrappers/crucible.ps1 new file mode 100644 index 0000000..0762977 --- /dev/null +++ b/wrappers/crucible.ps1 @@ -0,0 +1,89 @@ +#!/usr/bin/env pwsh +# crucible - bootstrap wrapper (PowerShell). Prefers a crucible already on PATH; +# otherwise downloads the latest released binary from Gitea into a per-version +# cache and execs it. +# +# CANONICAL SOURCE: sow-tools/wrappers/crucible.ps1. Do not hand-edit copies in +# consumer repos - kept in sync by sow-tools CI (see drift-check). +# +# Env: CRUCIBLE_HOME, CRUCIBLE_TOKEN, CRUCIBLE_GITEA, CRUCIBLE_REPO. +$ErrorActionPreference = 'Stop' + +# 0. Prefer an existing crucible. +$existing = Get-Command crucible -ErrorAction SilentlyContinue +if ($existing) { & $existing.Source @args; exit $LASTEXITCODE } + +$gitea = if ($env:CRUCIBLE_GITEA) { $env:CRUCIBLE_GITEA } else { 'https://git.westgate.pw' } +$repo = if ($env:CRUCIBLE_REPO) { $env:CRUCIBLE_REPO } else { 'ShadowsOverWestgate/sow-tools' } + +# 1. OS/arch -> asset name (.exe on Windows). +$os = if ($IsWindows) { 'windows' } elseif ($IsMacOS) { 'darwin' } else { 'linux' } +$arch = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } 'ARM64' { 'arm64' } default { 'amd64' } +} +$ext = if ($os -eq 'windows') { '.exe' } else { '' } +$asset = "crucible-$os-$arch$ext" + +# 2. Auth header (anon first, token fallback). +$token = $env:CRUCIBLE_TOKEN +$tokenFile = Join-Path $HOME '.config/crucible/token' +if (-not $token -and (Test-Path $tokenFile)) { $token = (Get-Content $tokenFile -Raw).Trim() } +function Invoke-Fetch($url, $outFile) { + try { Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing; return $true } catch {} + if ($token) { + Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing -Headers @{ Authorization = "token $token" } + return $true + } + return $false +} + +# 3. Resolve latest tag. +$tag = $null +try { + $hdr = if ($token) { @{ Authorization = "token $token" } } else { @{} } + $rel = Invoke-RestMethod -Uri "$gitea/api/v1/repos/$repo/releases/latest" -Headers $hdr + $tag = $rel.tag_name +} catch {} + +# 4. Cache dir. +$cacheRoot = if ($env:CRUCIBLE_HOME) { $env:CRUCIBLE_HOME } + elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'crucible' } + else { Join-Path $HOME '.cache/crucible' } + +# 5. Offline fallback. +if (-not $tag) { + if (Test-Path $cacheRoot) { + $cached = Get-ChildItem $cacheRoot -Directory | Sort-Object Name | Select-Object -Last 1 + $cachedBin = if ($cached) { Join-Path $cached.FullName "crucible$ext" } else { $null } + if ($cachedBin -and (Test-Path $cachedBin)) { + Write-Error "crucible: offline - using cached $($cached.Name)" -ErrorAction Continue + & $cachedBin @args; exit $LASTEXITCODE + } + } + Write-Error "crucible: cannot reach $gitea and no cached binary found (set CRUCIBLE_TOKEN if private)." + exit 1 +} + +$destDir = Join-Path $cacheRoot $tag +$bin = Join-Path $destDir "crucible$ext" + +# 6. Download + verify on cache miss. +if (-not (Test-Path $bin)) { + New-Item -ItemType Directory -Force -Path $destDir | Out-Null + $base = "$gitea/$repo/releases/download/$tag" + $tmp = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid())) + $tmpBin = Join-Path $tmp "crucible$ext" + Write-Host "crucible: fetching $asset $tag..." + if (-not (Invoke-Fetch "$base/$asset" $tmpBin)) { Write-Error "crucible: download failed for $asset"; exit 1 } + $sums = Join-Path $tmp 'SHA256SUMS' + if (Invoke-Fetch "$base/SHA256SUMS" $sums) { + $want = (Select-String -Path $sums -Pattern ([regex]::Escape($asset) + '$') | Select-Object -First 1).Line -split '\s+' | Select-Object -First 1 + $got = (Get-FileHash $tmpBin -Algorithm SHA256).Hash.ToLower() + if ($want -and ($want.ToLower() -ne $got)) { Write-Error "crucible: checksum mismatch for $asset"; exit 1 } + } + Move-Item -Force $tmpBin $bin + Remove-Item -Recurse -Force $tmp +} + +& $bin @args +exit $LASTEXITCODE diff --git a/wrappers/crucible.sh b/wrappers/crucible.sh new file mode 100755 index 0000000..cae4637 --- /dev/null +++ b/wrappers/crucible.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# crucible — bootstrap wrapper (POSIX/bash). Prefers a crucible already on PATH +# (Nix devshell / installed); otherwise downloads the latest released binary +# from Gitea into a per-version cache and execs it. +# +# CANONICAL SOURCE: sow-tools/wrappers/crucible.sh. Do not hand-edit copies in +# consumer repos — they are kept in sync by sow-tools CI (see drift-check). +# +# Requires: bash, curl, and sha256sum (Linux) or shasum (macOS). +# Env: CRUCIBLE_HOME (cache dir), CRUCIBLE_TOKEN (private releases), +# CRUCIBLE_GITEA (default https://git.westgate.pw), +# CRUCIBLE_REPO (default ShadowsOverWestgate/sow-tools). +set -euo pipefail + +# 0. Prefer an existing crucible (Nix users, or already installed). +if command -v crucible >/dev/null 2>&1; then + exec crucible "$@" +fi + +gitea="${CRUCIBLE_GITEA:-https://git.westgate.pw}" +repo="${CRUCIBLE_REPO:-ShadowsOverWestgate/sow-tools}" + +# 1. --repo-local materializes into /.crucible instead of the user cache. +repo_local=0 +if [ "${1:-}" = "--repo-local" ]; then repo_local=1; shift; fi + +# 2. OS/arch -> asset name. +os="$(uname -s)"; arch="$(uname -m)" +case "$os" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) echo "crucible: unsupported OS '$os' — install ffmpeg-free crucible manually" >&2; exit 1 ;; +esac +case "$arch" in + x86_64|amd64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo "crucible: unsupported arch '$arch'" >&2; exit 1 ;; +esac +asset="crucible-${os}-${arch}" + +# 3. Auth: anonymous first, token fallback. +token="${CRUCIBLE_TOKEN:-}" +if [ -z "$token" ] && [ -f "$HOME/.config/crucible/token" ]; then + token="$(cat "$HOME/.config/crucible/token")" +fi +fetch() { # fetch URL DEST ; tries anon then token + if curl -fsSL "$1" -o "$2" 2>/dev/null; then return 0; fi + if [ -n "$token" ]; then curl -fsSL -H "Authorization: token $token" "$1" -o "$2"; return $?; fi + return 1 +} +fetch_stdout() { # prints body; anon then token + if curl -fsSL "$1" 2>/dev/null; then return 0; fi + if [ -n "$token" ]; then curl -fsSL -H "Authorization: token $token" "$1"; return $?; fi + return 1 +} + +# 4. Resolve the latest release tag (no jq dependency). +api="${gitea}/api/v1/repos/${repo}/releases/latest" +tag="$(fetch_stdout "$api" 2>/dev/null | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)" + +# 5. Cache dir. +if [ "$repo_local" = 1 ]; then + cache_root="$(cd "$(dirname "$0")" && pwd)/.crucible" +else + cache_root="${CRUCIBLE_HOME:-${XDG_CACHE_HOME:-$HOME/.cache}/crucible}" +fi + +# 6. Offline fallback: if tag lookup failed, use the newest cached version. +if [ -z "$tag" ]; then + if [ -d "$cache_root" ]; then + cached="$(ls -1 "$cache_root" 2>/dev/null | sort -V | tail -1 || true)" + if [ -n "$cached" ] && [ -x "$cache_root/$cached/crucible" ]; then + echo "crucible: offline — using cached $cached" >&2 + exec "$cache_root/$cached/crucible" "$@" + fi + fi + echo "crucible: cannot reach $gitea and no cached binary found." >&2 + echo " set CRUCIBLE_TOKEN if releases are private, or check the network." >&2 + exit 1 +fi + +dest_dir="$cache_root/$tag" +bin="$dest_dir/crucible" + +# 7. Download + verify on cache miss. +if [ ! -x "$bin" ]; then + mkdir -p "$dest_dir" + base="${gitea}/${repo}/releases/download/${tag}" + tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + echo "crucible: fetching ${asset} ${tag}..." >&2 + fetch "${base}/${asset}" "$tmp/crucible" || { echo "crucible: download failed for ${asset}" >&2; exit 1; } + if fetch "${base}/SHA256SUMS" "$tmp/SHA256SUMS"; then + want="$(grep " ${asset}\$" "$tmp/SHA256SUMS" | awk '{print $1}')" + if command -v sha256sum >/dev/null 2>&1; then + got="$(sha256sum "$tmp/crucible" | awk '{print $1}')" + else + got="$(shasum -a 256 "$tmp/crucible" | awk '{print $1}')" + fi + if [ -n "$want" ] && [ "$want" != "$got" ]; then + echo "crucible: checksum mismatch for ${asset} (want $want got $got)" >&2; exit 1 + fi + fi + chmod +x "$tmp/crucible" + mv "$tmp/crucible" "$bin" +fi + +exec "$bin" "$@"