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] выше.",
+ },
}