From 5cf52c9f9fcefa39949ef87a874d1722c595f6e0 Mon Sep 17 00:00:00 2001 From: Cheviiot <153805936+Cheviiot@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:14:47 +1000 Subject: [PATCH] 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 /bin/ toolchain's on-disk layout, printing a pass/fail checklist and exiting non-zero if anything's broken. --- README.md | 22 ++++- cmd/vintner/completion.go | 3 +- cmd/vintner/doctor.go | 176 +++++++++++++++++++++++++++++++++++++ cmd/vintner/doctor_test.go | 83 +++++++++++++++++ cmd/vintner/main.go | 2 + internal/i18n/i18n.go | 39 ++++++++ 6 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 cmd/vintner/doctor.go create mode 100644 cmd/vintner/doctor_test.go diff --git a/README.md b/README.md index c0532c4..9b80fb5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ approach: download the real MSVC/WinSDK, wrap the compiler under Wine. - [Quick start](#quick-start) - [Commands](#commands) - [Invoking tools without PATH](#invoking-tools-without-path) +- [Diagnosing problems (vintner doctor)](#diagnosing-problems-vintner-doctor) - [Building drivers (WDK)](#building-drivers-wdk) - [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk) - [Automated/scripted builds](#automatedscripted-builds) @@ -46,9 +47,9 @@ depending on the name it's invoked as. then runs the real `.exe` under `wine`/`wine64`, and rewrites `z:\...` paths back to Unix paths in the output, so your build system's error parsing keeps working. -- As `vintner`: it exposes the `download`, `install`, `env`, `version` - and `completion` subcommands below (short aliases: `dl`, `i`, `e`, `v`; - `help`/`h` prints usage). +- As `vintner`: it exposes the `download`, `install`, `env`, `version`, + `doctor` and `completion` subcommands below (short aliases: `dl`, `i`, + `e`, `v`; `help`/`h` prints usage). ## Installation @@ -113,6 +114,7 @@ vintner download (dl) --accept-license [--dest ] [options] fetch and unpa vintner install (i) [dir] wire up wrappers for a downloaded MSVC vintner env (e) --bin /bin/ print INCLUDE/LIB for native clang-cl/lld-link use vintner version (v) print the version +vintner doctor check wine/toolchain setup vintner help (h) print usage vintner completion bash|zsh print a shell completion script ``` @@ -154,6 +156,20 @@ than one is installed), otherwise defaults to `~/.vintner/bin/`, the layout a plain `vintner download && vintner install` with no `--dest` override produces. +## Diagnosing problems (vintner doctor) + +```bash +vintner doctor +``` + +Checks the things vintner actually needs at runtime — that `wine`/`wine64` +is on `PATH` and actually runs, that `msitools`/`cabextract` are present, +and that every installed `/bin/` toolchain (or the one +`VINTNER_BIN` points at) has its MSVC/SDK/MSBuild directories in place — +and prints a pass/fail checklist. Exits non-zero if anything failed. +Useful before filing a bug, or after a `download`/`install` that seemed to +finish but left tools failing in confusing ways. + ## Building drivers (WDK) `--with-wdk` also fetches the Windows Driver Kit: headers, import libs, diff --git a/cmd/vintner/completion.go b/cmd/vintner/completion.go index 7a5e4b1..231e4b7 100644 --- a/cmd/vintner/completion.go +++ b/cmd/vintner/completion.go @@ -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' diff --git a/cmd/vintner/doctor.go b/cmd/vintner/doctor.go new file mode 100644 index 0000000..f597642 --- /dev/null +++ b/cmd/vintner/doctor.go @@ -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 /bin/ 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 +} diff --git a/cmd/vintner/doctor_test.go b/cmd/vintner/doctor_test.go new file mode 100644 index 0000000..a23f3e8 --- /dev/null +++ b/cmd/vintner/doctor_test.go @@ -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) + } + } +} diff --git a/cmd/vintner/main.go b/cmd/vintner/main.go index 3587f70..2dee834 100644 --- a/cmd/vintner/main.go +++ b/cmd/vintner/main.go @@ -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 diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 0078299..f6fba76 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -73,6 +73,7 @@ Usage: vintner install (i) [dir] wire up wrappers for a downloaded MSVC vintner env (e) --bin print INCLUDE/LIB for native clang-cl/lld-link use vintner version (v) print the version + vintner doctor check wine/toolchain setup vintner help (h) show this message vintner completion bash|zsh print a shell completion script @@ -98,6 +99,7 @@ not the default ~/.vintner/bin/): vintner install (i) [каталог] настроить обёртки для скачанного MSVC vintner env (e) --bin вывести INCLUDE/LIB для clang-cl/lld-link напрямую vintner version (v) показать версию + vintner doctor проверить настройку wine/toolchain vintner help (h) показать эту справку vintner completion bash|zsh вывести скрипт автодополнения для оболочки @@ -188,4 +190,41 @@ not the default ~/.vintner/bin/): EN: "Do you accept the license? Answer \"yes\" or \"no\": ", RU: "Вы принимаете лицензию? Ответьте «yes» или «no»: ", }, + + "doctor.usage": { + EN: "usage: vintner doctor", + RU: "использование: vintner doctor", + }, + "doctor.section_wine": { + EN: "Wine:", + RU: "Wine:", + }, + "doctor.section_extract": { + EN: "Extraction tools:", + RU: "Инструменты распаковки:", + }, + "doctor.section_toolchain": { + EN: "Installed toolchain:", + RU: "Установленный набор инструментов:", + }, + "doctor.msitools_missing": { + EN: "msitools: not found (install the msitools package - needed by `vintner download`)", + RU: "msitools: не найден (установите пакет msitools — нужен для `vintner download`)", + }, + "doctor.cabextract_missing": { + EN: "cabextract: not found (install the cabextract package - only needed for --with-wdk/--with-dxsdk)", + RU: "cabextract: не найден (установите пакет cabextract — нужен только для --with-wdk/--with-dxsdk)", + }, + "doctor.no_toolchain": { + EN: "no installed toolchain found under %s (run `vintner download --accept-license && vintner install` first, or set VINTNER_BIN)", + RU: "установленный набор инструментов не найден в %s (сначала выполните `vintner download --accept-license && vintner install`, либо задайте VINTNER_BIN)", + }, + "doctor.summary_ok": { + EN: "\nAll checks passed.", + RU: "\nВсе проверки пройдены.", + }, + "doctor.summary_fail": { + EN: "\nSome checks failed - see [FAIL] lines above.", + RU: "\nНекоторые проверки не пройдены — см. строки [FAIL] выше.", + }, }