From 738234186dc2015e9c88620f14420753ae072543 Mon Sep 17 00:00:00 2001 From: Cheviiot <153805936+Cheviiot@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:23:46 +1000 Subject: [PATCH] Run wrapped tools directly as `vintner ...`, no PATH needed `vintner cl ...`, `vintner msbuild ...`, etc. now work without adding /bin/ to PATH or relying on the same-directory symlinks `install` sets up there. wrapper.Run gained an explicit binDir parameter (empty string preserves the existing os.Executable()-based self-location for the ordinary multi-call/symlink case) so cmd/vintner's new runTool can point it at a resolved toolchain directory instead. Resolution order: VINTNER_BIN if set (same meaning as `env --bin` - point it at a /bin/ directory directly, for a non-default --dest or a specific architecture), else /bin/ - the layout a plain `vintner download && vintner install` with no --dest override produces. A missing toolchain gets a clear error pointing at both fixes, rather than bubbling up whatever wineenv.Load's env.json error looks like. Also fixed shell completion falling out of sync with the actual tool list: the bash/zsh scripts previously hand-copied tool/flag names (and had already gone stale once - --with-dxsdk was missing from the download flag completions since it was added). The tool name list is now generated from wrapper.ToolNames() instead of hand-maintained, and both scripts now complete tool names too, so `vintner ` suggests `cl`, `link`, `msbuild`, etc. alongside the management subcommands. Verified end-to-end with a minimal PATH (/usr/bin:/bin only, no toolchain dir on it at all): `vintner cl /nologo hello.c` compiled successfully, and `vintner msbuild -t:Rebuild ...` rebuilt the same real KMDF driver verified earlier this session - both via the default ~/.vintner/bin/ resolution, no VINTNER_BIN override needed. --- README.md | 22 ++++++++++++++++++ cmd/vintner/completion.go | 42 +++++++++++++++++++++++++++------- cmd/vintner/completion_test.go | 24 +++++++++++++++++-- cmd/vintner/main.go | 17 +++++++------- cmd/vintner/tool.go | 40 ++++++++++++++++++++++++++++++++ cmd/vintner/tool_test.go | 24 +++++++++++++++++++ internal/i18n/i18n.go | 15 ++++++++---- internal/wrapper/run.go | 38 +++++++++++++++++++----------- internal/wrapper/tools.go | 32 +++++++++++++++++++++++++- internal/wrapper/tools_test.go | 18 +++++++++++++++ 10 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 cmd/vintner/tool.go create mode 100644 cmd/vintner/tool_test.go diff --git a/README.md b/README.md index 35f83d9..c0532c4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ approach: download the real MSVC/WinSDK, wrap the compiler under Wine. - [Installation](#installation) - [Quick start](#quick-start) - [Commands](#commands) +- [Invoking tools without PATH](#invoking-tools-without-path) - [Building drivers (WDK)](#building-drivers-wdk) - [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk) - [Automated/scripted builds](#automatedscripted-builds) @@ -102,6 +103,9 @@ export PATH=~/.vintner/bin/x64:$PATH cl /nologo /EHsc hello.cpp ``` +Don't want to touch PATH? Skip step 3 and run tools through vintner +directly instead - see [Invoking tools without PATH](#invoking-tools-without-path). + ## Commands ``` @@ -132,6 +136,24 @@ package id or through `--with-*`. `--print-deps-tree` prints the dependency tree of whatever would actually be selected — honoring every other flag — without downloading anything. +## Invoking tools without PATH + +`cl`, `link`, `msbuild`, and the rest also work as `vintner +[args...]`, with no need to add `/bin/` to `PATH` or rely on +the symlinks `install` sets up there: + +```bash +vintner cl /nologo /EHsc hello.cpp +vintner msbuild MyProject.sln +``` + +Resolves the toolchain to use from `VINTNER_BIN` if set (same meaning as +`env --bin`: point it at a `/bin/` directory directly — useful +for a non-default `--dest`, or to pick a specific architecture when more +than one is installed), otherwise defaults to +`~/.vintner/bin/`, the layout a plain `vintner download && +vintner install` with no `--dest` override produces. + ## 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 13a38b8..7a5e4b1 100644 --- a/cmd/vintner/completion.go +++ b/cmd/vintner/completion.go @@ -1,6 +1,11 @@ package main -import "fmt" +import ( + "fmt" + "strings" + + "github.com/Cheviiot/vintner/internal/wrapper" +) // runCompletion prints a shell completion script for shell ("bash" or // "zsh") to stdout, meant to be sourced directly: @@ -11,6 +16,11 @@ import "fmt" // The flag lists below are hand-maintained alongside download.go/env.go's // flag.FlagSet definitions rather than generated from them - there's no // reflection-friendly registry to walk, and the flag set rarely changes. +// The tool name list isn't hand-maintained, though (see wrapper.ToolNames): +// a hand-copied one already went stale once already for a flag +// (--with-dxsdk missing from here after being added to download.go), and a +// list of every wrapped tool has more entries and changes for the same +// reasons the flag lists do, so it's worth generating for real. func runCompletion(args []string) int { if len(args) != 1 { fmt.Println("usage: vintner completion bash|zsh") @@ -18,10 +28,10 @@ func runCompletion(args []string) int { } switch args[0] { case "bash": - fmt.Print(bashCompletionScript) + fmt.Print(bashCompletionScript()) return 0 case "zsh": - fmt.Print(zshCompletionScript) + fmt.Print(zshCompletionScript()) return 0 default: fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0]) @@ -33,9 +43,16 @@ const downloadFlags = "--dest --cache --major --preview --manifest --accept-lice "--msvc-version --sdk-version --host-arch --only-host --language " + "--include-optional --skip-recommended --only-download --only-unpack " + "--keep-unpack --skip-patch --list-workloads --list-components " + - "--print-deps-tree --with-wdk --architecture --ignore -h --help" + "--print-deps-tree --with-wdk --with-dxsdk --architecture --ignore -h --help" -var bashCompletionScript = `# vintner bash completion - eval "$(vintner completion bash)" +// subcommandNames lists vintner's own management subcommands (long form +// 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" + +func bashCompletionScript() string { + return `# vintner bash completion - eval "$(vintner completion bash)" _vintner_complete() { local cur cmd COMPREPLY=() @@ -43,7 +60,7 @@ _vintner_complete() { cmd="${COMP_WORDS[1]}" if [ "$COMP_CWORD" -eq 1 ]; then - COMPREPLY=($(compgen -W "download dl install i env e version v help h completion" -- "$cur")) + COMPREPLY=($(compgen -W "` + subcommandNames + ` ` + strings.Join(wrapper.ToolNames(), " ") + `" -- "$cur")) return 0 fi @@ -65,8 +82,15 @@ _vintner_complete() { } complete -F _vintner_complete vintner ` +} -var zshCompletionScript = `#compdef vintner +func zshCompletionScript() string { + var toolEntries strings.Builder + for _, name := range wrapper.ToolNames() { + fmt.Fprintf(&toolEntries, " %q\n", name+":run this tool directly, e.g. \"vintner "+name+" ...\"") + } + + return `#compdef vintner # vintner zsh completion - source <(vintner completion zsh) _vintner() { @@ -83,7 +107,7 @@ _vintner() { 'help:print usage' 'h:alias for help' 'completion:print a shell completion script' - ) +` + toolEntries.String() + ` ) if (( CURRENT == 2 )); then _describe 'command' subcommands @@ -115,6 +139,7 @@ _vintner() { '--list-components[list available components and exit]' '--print-deps-tree[print the dependency tree and exit]' '--with-wdk[also fetch the Windows Driver Kit]' + '--with-dxsdk[also fetch the DirectX SDK]' '--architecture[target architecture]:arch:(x86 x64 arm arm64 host)' '--ignore[package id to skip]:package id:' '-h[show help]' @@ -138,3 +163,4 @@ _vintner() { _vintner "$@" ` +} diff --git a/cmd/vintner/completion_test.go b/cmd/vintner/completion_test.go index 5bf17fa..2b9d7b5 100644 --- a/cmd/vintner/completion_test.go +++ b/cmd/vintner/completion_test.go @@ -4,6 +4,8 @@ import ( "os/exec" "strings" "testing" + + "github.com/Cheviiot/vintner/internal/wrapper" ) // TestCompletionScriptsAreSyntacticallyValid catches the easy way to break @@ -15,8 +17,8 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) { shell string script string }{ - {"bash", bashCompletionScript}, - {"zsh", zshCompletionScript}, + {"bash", bashCompletionScript()}, + {"zsh", zshCompletionScript()}, } { t.Run(tc.shell, func(t *testing.T) { if _, err := exec.LookPath(tc.shell); err != nil { @@ -31,6 +33,24 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) { } } +// TestCompletionScriptsListEveryTool guards against the exact staleness bug +// found and fixed alongside this test: a hand-copied tool/flag list here +// drifting from the real set in internal/wrapper (or download.go's flags) +// as tools/flags get added. Every current tool name must appear in both +// generated scripts. +func TestCompletionScriptsListEveryTool(t *testing.T) { + bash := bashCompletionScript() + zsh := zshCompletionScript() + for _, name := range wrapper.ToolNames() { + if !strings.Contains(bash, name) { + t.Errorf("bash completion script doesn't mention tool %q", name) + } + if !strings.Contains(zsh, name+":") { + t.Errorf("zsh completion script doesn't mention tool %q", name) + } + } +} + func TestRunCompletionUnknownShell(t *testing.T) { if code := runCompletion([]string{"fish"}); code != 1 { t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code) diff --git a/cmd/vintner/main.go b/cmd/vintner/main.go index 4c264b8..3587f70 100644 --- a/cmd/vintner/main.go +++ b/cmd/vintner/main.go @@ -1,9 +1,9 @@ // Command vintner cross compiles with the real MSVC toolchain on Linux // via Wine. It's a multi-call binary that behaves as `cl`, `link`, `lib`, // `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of -// those names (via symlinks set up by `vintner install`), and -// otherwise exposes the `download`/`install`/`env`/`version` management -// subcommands. +// those names (via symlinks set up by `vintner install`) or as +// `vintner ...` directly (see runTool), and otherwise exposes the +// `download`/`install`/`env`/`version` management subcommands. package main import ( @@ -24,11 +24,8 @@ func main() { base := filepath.Base(os.Args[0]) name := strings.TrimSuffix(strings.ToLower(base), ".exe") - if _, ok := wrapper.Tools[name]; ok { - os.Exit(wrapper.Run(name, os.Args[1:])) - } - if name == "cmd" || name == "findstr" { - os.Exit(wrapper.Run(name, os.Args[1:])) + if wrapper.IsTool(name) { + os.Exit(wrapper.Run(name, os.Args[1:], "")) } os.Exit(runCLI(os.Args[1:])) @@ -40,6 +37,10 @@ func runCLI(args []string) int { return 1 } + if wrapper.IsTool(args[0]) { + return runTool(args[0], args[1:]) + } + switch args[0] { case "download", "dl": return runDownload(args[1:]) diff --git a/cmd/vintner/tool.go b/cmd/vintner/tool.go new file mode 100644 index 0000000..d439859 --- /dev/null +++ b/cmd/vintner/tool.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Cheviiot/vintner/internal/wrapper" +) + +// runTool dispatches `vintner [args...]` (cl, link, msbuild, ...) +// directly, without needing /bin/ on PATH or a same-directory +// symlink pointing back at this binary. +// +// Resolves which toolchain bin dir to use from VINTNER_BIN if set (same +// meaning as `env --bin`: point it directly at a /bin/ +// directory - useful for a non-default --dest, or to pick a specific +// architecture when more than one is installed), else defaults to +// /bin/, the layout a plain `vintner +// download && vintner install` with no --dest override produces. +func runTool(tool string, args []string) int { + binDir := os.Getenv("VINTNER_BIN") + if binDir == "" { + def, err := defaultToolchainDir() + if err != nil { + fmt.Fprintln(os.Stderr, "vintner:", err) + return 1 + } + binDir = filepath.Join(def, "bin", detectHostArch()) + } + if fi, err := os.Stat(binDir); err != nil || !fi.IsDir() { + fmt.Fprintf(os.Stderr, + "vintner: no installed toolchain found at %s\n"+ + "Run `vintner download --accept-license && vintner install` first, "+ + "or set VINTNER_BIN to an existing /bin/ directory.\n", + binDir) + return 1 + } + return wrapper.Run(tool, args, binDir) +} diff --git a/cmd/vintner/tool_test.go b/cmd/vintner/tool_test.go new file mode 100644 index 0000000..54cda2f --- /dev/null +++ b/cmd/vintner/tool_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestRunToolReportsMissingToolchain(t *testing.T) { + t.Setenv("VINTNER_BIN", filepath.Join(t.TempDir(), "does-not-exist")) + if code := runTool("cl", nil); code != 1 { + t.Errorf("runTool with a nonexistent VINTNER_BIN = %d, want 1", code) + } +} + +func TestRunToolUsesVINTNERBinOverDefault(t *testing.T) { + // A directory that exists but has no env.json - past the "toolchain + // found at all" check, into wrapper.Run's own (already-tested) + // env.json-loading error path. Confirms VINTNER_BIN is actually being + // read and passed through, without needing a full fake toolchain. + t.Setenv("VINTNER_BIN", t.TempDir()) + if code := runTool("cl", nil); code != 1 { + t.Errorf("runTool with an empty VINTNER_BIN dir = %d, want 1 (from the missing env.json)", code) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 37544d2..0078299 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -84,7 +84,10 @@ has many, including --with-wdk, --with-dxsdk, --list-workloads, Language: set VINTNER_LANG=ru (or LANG=ru_RU...) for Russian output. Completion: source <(vintner completion bash) # or zsh -Once installed, add /bin/ to PATH and invoke the tools directly: +Once installed, add /bin/ to PATH and invoke the tools directly, +or skip PATH and run them through vintner itself - "vintner cl ...", +"vintner msbuild ...", etc. (set VINTNER_BIN to a /bin/ if it's +not the default ~/.vintner/bin/): cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr `, RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine @@ -104,9 +107,13 @@ Once installed, add /bin/ to PATH and invoke the tools directly: --dest/[каталог] по умолчанию — ~/.vintner. Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском. +Можно не трогать PATH: "vintner cl ...", "vintner msbuild ..." и т.д. работают +напрямую (VINTNER_BIN — если каталог не стандартный ~/.vintner/bin/). Автодополнение: source <(vintner completion bash) # или zsh -После установки добавьте /bin/ в PATH и вызывайте инструменты напрямую: +После установки добавьте /bin/ в PATH и вызывайте инструменты +напрямую, либо не трогая PATH — «vintner cl ...», «vintner msbuild ...» +и т.д.: cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr `, }, @@ -124,8 +131,8 @@ Once installed, add /bin/ to PATH and invoke the tools directly: RU: "Каталог не указан, используется значение по умолчанию: %s", }, "install.done": { - EN: "Done. Add %s to PATH to use cl, link, lib, ...", - RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д.", + EN: "Done. Add %s to PATH to use cl, link, lib, ... directly, or run them as \"vintner cl\", \"vintner link\", etc. without touching PATH.", + RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д. напрямую, либо запускайте их как «vintner cl», «vintner link» и т.д., не трогая PATH.", }, "env.usage": { diff --git a/internal/wrapper/run.go b/internal/wrapper/run.go index e639cca..90c95ed 100644 --- a/internal/wrapper/run.go +++ b/internal/wrapper/run.go @@ -30,7 +30,15 @@ const toolRelayName = "toolrelay.exe" // Run executes the named multi-call tool with args, exactly as the original // bash wrappers would, and returns the process exit code. -func Run(tool string, args []string) int { +// +// binDir is the /bin/ directory holding env.json for this +// invocation. Pass "" for the ordinary multi-call case (invoked as `cl`, +// `link`, etc. via a same-directory symlink) to have it resolved from the +// running binary's own location; a non-empty value is for `vintner +// ...` direct dispatch (see cmd/vintner's runTool), which isn't running +// from inside any particular /bin/ and so has nothing to +// resolve on its own. +func Run(tool string, args []string, binDir string) int { if nativeTools[tool] { return runNative(tool, args) } @@ -41,19 +49,23 @@ func Run(tool string, args []string) int { return 127 } - // os.Executable() (backed by /proc/self/exe on Linux) fully resolves - // symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved - // absolute path as argv[0] (some just pass the bare command name), which - // would make an argv[0]-based lookup resolve against the caller's cwd - // instead of the actual install dir. `install` sets each arch dir up - // with its own local copy of the binary precisely so this resolves to - // /bin/, not /bin. - exePath, err := os.Executable() - if err != nil { - fmt.Fprintln(os.Stderr, "vintner:", err) - return 1 + scriptDir := binDir + if scriptDir == "" { + // os.Executable() (backed by /proc/self/exe on Linux) fully resolves + // symlinks, unlike os.Args[0]: not every shell passes a + // PATH-resolved absolute path as argv[0] (some just pass the bare + // command name), which would make an argv[0]-based lookup resolve + // against the caller's cwd instead of the actual install dir. + // `install` sets each arch dir up with its own local copy of the + // binary precisely so this resolves to /bin/, not + // /bin. + exePath, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, "vintner:", err) + return 1 + } + scriptDir = filepath.Dir(exePath) } - scriptDir := filepath.Dir(exePath) cfg, err := wineenv.Load(scriptDir) if err != nil { diff --git a/internal/wrapper/tools.go b/internal/wrapper/tools.go index a93b4a3..1f5efa1 100644 --- a/internal/wrapper/tools.go +++ b/internal/wrapper/tools.go @@ -4,7 +4,11 @@ // filtering its output. package wrapper -import "github.com/Cheviiot/vintner/internal/wineenv" +import ( + "sort" + + "github.com/Cheviiot/vintner/internal/wineenv" +) // dirKind selects which install directory a tool's real .exe lives in. type dirKind int @@ -46,6 +50,32 @@ var Tools = map[string]spec{ // nativeTools are handled entirely without Wine. var nativeTools = map[string]bool{"cmd": true, "findstr": true} +// IsTool reports whether name is a recognized wrapped tool - either a +// Wine-hosted one in Tools or a native shim in nativeTools. Shared between +// the ordinary multi-call dispatch (invoked *as* one of these names via a +// same-directory symlink) and `vintner ...` direct dispatch, so both +// recognize exactly the same set of names. +func IsTool(name string) bool { + _, ok := Tools[name] + return ok || nativeTools[name] +} + +// ToolNames returns every recognized tool name, sorted - Tools and +// nativeTools combined. Used to generate shell completion without a +// separate hand-maintained list that could drift from the real set (as +// happened once already with a stale --with-dxsdk completion entry). +func ToolNames() []string { + names := make([]string, 0, len(Tools)+len(nativeTools)) + for name := range Tools { + names = append(names, name) + } + for name := range nativeTools { + names = append(names, name) + } + sort.Strings(names) + return names +} + func (s spec) exeDir(p *wineenv.Paths) string { switch s.dir { case dirSDK: diff --git a/internal/wrapper/tools_test.go b/internal/wrapper/tools_test.go index 1b5dedd..ec04768 100644 --- a/internal/wrapper/tools_test.go +++ b/internal/wrapper/tools_test.go @@ -43,3 +43,21 @@ func TestEveryToolHasAnExeName(t *testing.T) { } } } + +func TestIsTool(t *testing.T) { + for name := range Tools { + if !IsTool(name) { + t.Errorf("IsTool(%q) = false, want true (it's in Tools)", name) + } + } + for name := range nativeTools { + if !IsTool(name) { + t.Errorf("IsTool(%q) = false, want true (it's in nativeTools)", name) + } + } + for _, name := range []string{"download", "install", "env", "version", "help", "completion", "frobnicate", ""} { + if IsTool(name) { + t.Errorf("IsTool(%q) = true, want false", name) + } + } +}