Add vintner doctor for diagnosing a broken wine/toolchain setup

A misconfigured environment (wine missing, msitools not installed, a
partially-built toolchain) otherwise only surfaces as a wine-specific
error buried deep inside a build. `vintner doctor` checks wine itself
(found and actually runs), the optional extraction tools download
needs, and every installed <dest>/bin/<arch> toolchain's on-disk
layout, printing a pass/fail checklist and exiting non-zero if
anything's broken.
This commit is contained in:
Cheviiot
2026-07-25 18:14:47 +10:00
parent b366dfa9ac
commit 5cf52c9f9f
6 changed files with 321 additions and 4 deletions
+2 -1
View File
@@ -49,7 +49,7 @@ const downloadFlags = "--dest --cache --major --preview --manifest --accept-lice
// plus every short alias) - unlike the wrapped-tool names, these really are
// fixed enough to hand-maintain: adding one is rare and always touches
// main.go's dispatch switch right next to this file anyway.
const subcommandNames = "download dl install i env e version v help h completion"
const subcommandNames = "download dl install i env e version v doctor help h completion"
func bashCompletionScript() string {
return `# vintner bash completion - eval "$(vintner completion bash)"
@@ -104,6 +104,7 @@ _vintner() {
'e:alias for env'
'version:print the version'
'v:alias for version'
'doctor:check wine/toolchain setup'
'help:print usage'
'h:alias for help'
'completion:print a shell completion script'
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/wineenv"
)
// runDoctor checks the pieces vintner actually needs at runtime - wine
// itself, the optional extraction tools download needs, and every
// installed <dest>/bin/<arch> toolchain's on-disk layout - and prints a
// pass/fail checklist. The point is surfacing a broken setup as a short,
// readable report instead of a wine-specific error buried deep inside a
// build (see internal/wineenv.FindWine's own error, which this reuses).
func runDoctor(args []string) int {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
fmt.Fprintln(os.Stderr, i18n.T("doctor.usage"))
return 1
}
d := &doctorReport{}
d.checkWine()
d.checkExtractionTools()
d.checkToolchains()
if d.failed {
fmt.Println(i18n.T("doctor.summary_fail"))
return 1
}
fmt.Println(i18n.T("doctor.summary_ok"))
return 0
}
type doctorReport struct {
failed bool
}
func (d *doctorReport) ok(format string, args ...any) {
fmt.Printf(" [ok] "+format+"\n", args...)
}
func (d *doctorReport) warn(format string, args ...any) {
fmt.Printf(" [warn] "+format+"\n", args...)
}
func (d *doctorReport) fail(format string, args ...any) {
fmt.Printf(" [FAIL] "+format+"\n", args...)
d.failed = true
}
func (d *doctorReport) checkWine() {
fmt.Println(i18n.T("doctor.section_wine"))
wineBin, err := wineenv.FindWine()
if err != nil {
d.fail("%s", err)
return
}
d.ok("found: %s", wineBin)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, wineBin, "--version").Output()
if err != nil {
d.fail("%s --version failed: %v", wineBin, err)
return
}
d.ok("runs: %s", trimNewline(string(out)))
}
func (d *doctorReport) checkExtractionTools() {
fmt.Println(i18n.T("doctor.section_extract"))
if _, err := exec.LookPath("msiextract"); err != nil {
d.warn(i18n.T("doctor.msitools_missing"))
} else {
d.ok("msitools: found (needed by `vintner download`)")
}
if _, err := exec.LookPath("cabextract"); err != nil {
d.warn(i18n.T("doctor.cabextract_missing"))
} else {
d.ok("cabextract: found (needed by --with-wdk/--with-dxsdk)")
}
}
func (d *doctorReport) checkToolchains() {
fmt.Println(i18n.T("doctor.section_toolchain"))
if binDir := os.Getenv("VINTNER_BIN"); binDir != "" {
d.checkToolchainAt(binDir, "VINTNER_BIN="+binDir)
return
}
def, err := defaultToolchainDir()
if err != nil {
d.fail("%s", err)
return
}
destBin := filepath.Join(def, "bin")
archDirs := installedArchDirs(destBin)
if len(archDirs) == 0 {
d.fail(i18n.T("doctor.no_toolchain", destBin))
return
}
for _, arch := range archDirs {
d.checkToolchainAt(filepath.Join(destBin, arch), arch)
}
}
// installedArchDirs returns the subdirectories of destBin that carry their
// own env.json, i.e. every architecture `vintner install` actually set up
// (there can be more than one - e.g. x86 and x64 side by side).
func installedArchDirs(destBin string) []string {
entries, err := os.ReadDir(destBin)
if err != nil {
return nil
}
var dirs []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(destBin, e.Name(), wineenv.ConfigFileName)); err == nil {
dirs = append(dirs, e.Name())
}
}
return dirs
}
func (d *doctorReport) checkToolchainAt(binDir, label string) {
cfg, err := wineenv.Load(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
baseUnix, err := wineenv.FindBaseUnix(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
d.ok("%s: MSVC %s, SDK %s, root %s", label, cfg.MSVCVer, cfg.SDKVer, baseUnix)
paths := wineenv.NewPaths(cfg, baseUnix)
for _, dir := range []struct{ name, path string }{
{"MSVC bin", paths.BinDir},
{"SDK bin", paths.SDKBinDir},
{"MSBuild bin", paths.MSBuildBinDir},
} {
if fi, err := os.Stat(dir.path); err != nil || !fi.IsDir() {
d.fail("%s: %s missing: %s", label, dir.name, dir.path)
} else {
d.ok("%s: %s: %s", label, dir.name, dir.path)
}
}
relay := filepath.Join(baseUnix, "bin", "toolrelay.exe")
if fi, err := os.Stat(relay); err != nil || fi.IsDir() {
d.warn("%s: toolrelay.exe not built (mt.exe's CMake exit-code translation won't apply; re-run `vintner install` to retry)", label)
} else {
d.ok("%s: toolrelay.exe: %s", label, relay)
}
}
func trimNewline(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestRunDoctorUsage(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
if code := runDoctor([]string{flag}); code != 1 {
t.Errorf("runDoctor([%q]) = %d, want 1", flag, code)
}
}
}
func TestDoctorReportOkWarnFail(t *testing.T) {
d := &doctorReport{}
d.ok("fine")
d.warn("meh")
if d.failed {
t.Fatal("ok/warn must not mark the report as failed")
}
d.fail("broken")
if !d.failed {
t.Fatal("fail must mark the report as failed")
}
}
func TestInstalledArchDirsFindsOnlyDirsWithEnvJSON(t *testing.T) {
destBin := t.TempDir()
for _, dir := range []string{"x64", "x86", "not-a-toolchain"} {
if err := os.MkdirAll(filepath.Join(destBin, dir), 0o755); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"x64", "x86"} {
if err := os.WriteFile(filepath.Join(destBin, dir, wineenv.ConfigFileName), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
}
got := installedArchDirs(destBin)
want := map[string]bool{"x64": true, "x86": true}
if len(got) != len(want) {
t.Fatalf("installedArchDirs = %v, want exactly %v", got, want)
}
for _, arch := range got {
if !want[arch] {
t.Errorf("installedArchDirs returned unexpected entry %q", arch)
}
}
}
func TestInstalledArchDirsMissingDir(t *testing.T) {
if got := installedArchDirs(filepath.Join(t.TempDir(), "does-not-exist")); got != nil {
t.Errorf("installedArchDirs on a missing dir = %v, want nil", got)
}
}
func TestCheckToolchainAtMissingEnvJSON(t *testing.T) {
d := &doctorReport{}
d.checkToolchainAt(t.TempDir(), "test")
if !d.failed {
t.Error("checkToolchainAt against a dir with no env.json should fail the report")
}
}
func TestTrimNewline(t *testing.T) {
cases := map[string]string{
"wine-9.0\n": "wine-9.0",
"wine-9.0\r\n": "wine-9.0",
"wine-9.0": "wine-9.0",
"": "",
}
for in, want := range cases {
if got := trimNewline(in); got != want {
t.Errorf("trimNewline(%q) = %q, want %q", in, got, want)
}
}
}
+2
View File
@@ -50,6 +50,8 @@ func runCLI(args []string) int {
return runEnv(args[1:])
case "completion":
return runCompletion(args[1:])
case "doctor":
return runDoctor(args[1:])
case "version", "v", "--version":
fmt.Println(versionString())
return 0