Run wrapped tools directly as vintner <tool> ..., no PATH needed

`vintner cl ...`, `vintner msbuild ...`, etc. now work without adding
<dest>/bin/<arch> 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 <dest>/bin/<arch> directory directly, for a non-default
--dest or a specific architecture), else <defaultToolchainDir>/bin/
<hostArch> - 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 <TAB>`
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/<hostArch> resolution, no VINTNER_BIN override needed.
This commit is contained in:
Cheviiot
2026-07-25 16:23:46 +10:00
parent f6b9a0811a
commit 738234186d
10 changed files with 236 additions and 36 deletions
+22
View File
@@ -21,6 +21,7 @@ approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
- [Installation](#installation) - [Installation](#installation)
- [Quick start](#quick-start) - [Quick start](#quick-start)
- [Commands](#commands) - [Commands](#commands)
- [Invoking tools without PATH](#invoking-tools-without-path)
- [Building drivers (WDK)](#building-drivers-wdk) - [Building drivers (WDK)](#building-drivers-wdk)
- [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk) - [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk)
- [Automated/scripted builds](#automatedscripted-builds) - [Automated/scripted builds](#automatedscripted-builds)
@@ -102,6 +103,9 @@ export PATH=~/.vintner/bin/x64:$PATH
cl /nologo /EHsc hello.cpp 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 ## 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 dependency tree of whatever would actually be selected — honoring every
other flag — without downloading anything. other flag — without downloading anything.
## Invoking tools without PATH
`cl`, `link`, `msbuild`, and the rest also work as `vintner <tool>
[args...]`, with no need to add `<dest>/bin/<arch>` 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 `<dest>/bin/<arch>` 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/<host-arch>`, the layout a plain `vintner download &&
vintner install` with no `--dest` override produces.
## Building drivers (WDK) ## Building drivers (WDK)
`--with-wdk` also fetches the Windows Driver Kit: headers, import libs, `--with-wdk` also fetches the Windows Driver Kit: headers, import libs,
+34 -8
View File
@@ -1,6 +1,11 @@
package main package main
import "fmt" import (
"fmt"
"strings"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runCompletion prints a shell completion script for shell ("bash" or // runCompletion prints a shell completion script for shell ("bash" or
// "zsh") to stdout, meant to be sourced directly: // "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 // The flag lists below are hand-maintained alongside download.go/env.go's
// flag.FlagSet definitions rather than generated from them - there's no // flag.FlagSet definitions rather than generated from them - there's no
// reflection-friendly registry to walk, and the flag set rarely changes. // 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 { func runCompletion(args []string) int {
if len(args) != 1 { if len(args) != 1 {
fmt.Println("usage: vintner completion bash|zsh") fmt.Println("usage: vintner completion bash|zsh")
@@ -18,10 +28,10 @@ func runCompletion(args []string) int {
} }
switch args[0] { switch args[0] {
case "bash": case "bash":
fmt.Print(bashCompletionScript) fmt.Print(bashCompletionScript())
return 0 return 0
case "zsh": case "zsh":
fmt.Print(zshCompletionScript) fmt.Print(zshCompletionScript())
return 0 return 0
default: default:
fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0]) 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 " + "--msvc-version --sdk-version --host-arch --only-host --language " +
"--include-optional --skip-recommended --only-download --only-unpack " + "--include-optional --skip-recommended --only-download --only-unpack " +
"--keep-unpack --skip-patch --list-workloads --list-components " + "--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() { _vintner_complete() {
local cur cmd local cur cmd
COMPREPLY=() COMPREPLY=()
@@ -43,7 +60,7 @@ _vintner_complete() {
cmd="${COMP_WORDS[1]}" cmd="${COMP_WORDS[1]}"
if [ "$COMP_CWORD" -eq 1 ]; then 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 return 0
fi fi
@@ -65,8 +82,15 @@ _vintner_complete() {
} }
complete -F _vintner_complete vintner 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 zsh completion - source <(vintner completion zsh)
_vintner() { _vintner() {
@@ -83,7 +107,7 @@ _vintner() {
'help:print usage' 'help:print usage'
'h:alias for help' 'h:alias for help'
'completion:print a shell completion script' 'completion:print a shell completion script'
) ` + toolEntries.String() + ` )
if (( CURRENT == 2 )); then if (( CURRENT == 2 )); then
_describe 'command' subcommands _describe 'command' subcommands
@@ -115,6 +139,7 @@ _vintner() {
'--list-components[list available components and exit]' '--list-components[list available components and exit]'
'--print-deps-tree[print the dependency tree and exit]' '--print-deps-tree[print the dependency tree and exit]'
'--with-wdk[also fetch the Windows Driver Kit]' '--with-wdk[also fetch the Windows Driver Kit]'
'--with-dxsdk[also fetch the DirectX SDK]'
'--architecture[target architecture]:arch:(x86 x64 arm arm64 host)' '--architecture[target architecture]:arch:(x86 x64 arm arm64 host)'
'--ignore[package id to skip]:package id:' '--ignore[package id to skip]:package id:'
'-h[show help]' '-h[show help]'
@@ -138,3 +163,4 @@ _vintner() {
_vintner "$@" _vintner "$@"
` `
}
+22 -2
View File
@@ -4,6 +4,8 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"testing" "testing"
"github.com/Cheviiot/vintner/internal/wrapper"
) )
// TestCompletionScriptsAreSyntacticallyValid catches the easy way to break // TestCompletionScriptsAreSyntacticallyValid catches the easy way to break
@@ -15,8 +17,8 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
shell string shell string
script string script string
}{ }{
{"bash", bashCompletionScript}, {"bash", bashCompletionScript()},
{"zsh", zshCompletionScript}, {"zsh", zshCompletionScript()},
} { } {
t.Run(tc.shell, func(t *testing.T) { t.Run(tc.shell, func(t *testing.T) {
if _, err := exec.LookPath(tc.shell); err != nil { 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) { func TestRunCompletionUnknownShell(t *testing.T) {
if code := runCompletion([]string{"fish"}); code != 1 { if code := runCompletion([]string{"fish"}); code != 1 {
t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code) t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code)
+9 -8
View File
@@ -1,9 +1,9 @@
// Command vintner cross compiles with the real MSVC toolchain on Linux // 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`, // 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 // `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of
// those names (via symlinks set up by `vintner install`), and // those names (via symlinks set up by `vintner install`) or as
// otherwise exposes the `download`/`install`/`env`/`version` management // `vintner <tool> ...` directly (see runTool), and otherwise exposes the
// subcommands. // `download`/`install`/`env`/`version` management subcommands.
package main package main
import ( import (
@@ -24,11 +24,8 @@ func main() {
base := filepath.Base(os.Args[0]) base := filepath.Base(os.Args[0])
name := strings.TrimSuffix(strings.ToLower(base), ".exe") name := strings.TrimSuffix(strings.ToLower(base), ".exe")
if _, ok := wrapper.Tools[name]; ok { if wrapper.IsTool(name) {
os.Exit(wrapper.Run(name, os.Args[1:])) os.Exit(wrapper.Run(name, os.Args[1:], ""))
}
if name == "cmd" || name == "findstr" {
os.Exit(wrapper.Run(name, os.Args[1:]))
} }
os.Exit(runCLI(os.Args[1:])) os.Exit(runCLI(os.Args[1:]))
@@ -40,6 +37,10 @@ func runCLI(args []string) int {
return 1 return 1
} }
if wrapper.IsTool(args[0]) {
return runTool(args[0], args[1:])
}
switch args[0] { switch args[0] {
case "download", "dl": case "download", "dl":
return runDownload(args[1:]) return runDownload(args[1:])
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runTool dispatches `vintner <tool> [args...]` (cl, link, msbuild, ...)
// directly, without needing <dest>/bin/<arch> 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 <dest>/bin/<arch>
// directory - useful for a non-default --dest, or to pick a specific
// architecture when more than one is installed), else defaults to
// <defaultToolchainDir>/bin/<hostArch>, 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 <dest>/bin/<arch> directory.\n",
binDir)
return 1
}
return wrapper.Run(tool, args, binDir)
}
+24
View File
@@ -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)
}
}
+11 -4
View File
@@ -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. Language: set VINTNER_LANG=ru (or LANG=ru_RU...) for Russian output.
Completion: source <(vintner completion bash) # or zsh Completion: source <(vintner completion bash) # or zsh
Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly: Once installed, add <dir>/bin/<arch> 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 <dir>/bin/<arch> if it's
not the default ~/.vintner/bin/<host-arch>):
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`, `,
RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine
@@ -104,9 +107,13 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
--dest/[каталог] по умолчанию — ~/.vintner. --dest/[каталог] по умолчанию — ~/.vintner.
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском. Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
Можно не трогать PATH: "vintner cl ...", "vintner msbuild ..." и т.д. работают
напрямую (VINTNER_BIN — если каталог не стандартный ~/.vintner/bin/<hostarch>).
Автодополнение: source <(vintner completion bash) # или zsh Автодополнение: source <(vintner completion bash) # или zsh
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты напрямую: После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты
напрямую, либо не трогая PATH — «vintner cl ...», «vintner msbuild ...»
и т.д.:
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
`, `,
}, },
@@ -124,8 +131,8 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
RU: "Каталог не указан, используется значение по умолчанию: %s", RU: "Каталог не указан, используется значение по умолчанию: %s",
}, },
"install.done": { "install.done": {
EN: "Done. Add %s to PATH to use 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 и т.д.", RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д. напрямую, либо запускайте их как «vintner cl», «vintner link» и т.д., не трогая PATH.",
}, },
"env.usage": { "env.usage": {
+25 -13
View File
@@ -30,7 +30,15 @@ const toolRelayName = "toolrelay.exe"
// Run executes the named multi-call tool with args, exactly as the original // Run executes the named multi-call tool with args, exactly as the original
// bash wrappers would, and returns the process exit code. // bash wrappers would, and returns the process exit code.
func Run(tool string, args []string) int { //
// binDir is the <dest>/bin/<arch> 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 <tool>
// ...` direct dispatch (see cmd/vintner's runTool), which isn't running
// from inside any particular <dest>/bin/<arch> and so has nothing to
// resolve on its own.
func Run(tool string, args []string, binDir string) int {
if nativeTools[tool] { if nativeTools[tool] {
return runNative(tool, args) return runNative(tool, args)
} }
@@ -41,19 +49,23 @@ func Run(tool string, args []string) int {
return 127 return 127
} }
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves scriptDir := binDir
// symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved if scriptDir == "" {
// absolute path as argv[0] (some just pass the bare command name), which // os.Executable() (backed by /proc/self/exe on Linux) fully resolves
// would make an argv[0]-based lookup resolve against the caller's cwd // symlinks, unlike os.Args[0]: not every shell passes a
// instead of the actual install dir. `install` sets each arch dir up // PATH-resolved absolute path as argv[0] (some just pass the bare
// with its own local copy of the binary precisely so this resolves to // command name), which would make an argv[0]-based lookup resolve
// <dest>/bin/<arch>, not <dest>/bin. // against the caller's cwd instead of the actual install dir.
exePath, err := os.Executable() // `install` sets each arch dir up with its own local copy of the
if err != nil { // binary precisely so this resolves to <dest>/bin/<arch>, not
fmt.Fprintln(os.Stderr, "vintner:", err) // <dest>/bin.
return 1 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) cfg, err := wineenv.Load(scriptDir)
if err != nil { if err != nil {
+31 -1
View File
@@ -4,7 +4,11 @@
// filtering its output. // filtering its output.
package wrapper 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. // dirKind selects which install directory a tool's real .exe lives in.
type dirKind int type dirKind int
@@ -46,6 +50,32 @@ var Tools = map[string]spec{
// nativeTools are handled entirely without Wine. // nativeTools are handled entirely without Wine.
var nativeTools = map[string]bool{"cmd": true, "findstr": true} 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 <tool> ...` 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 { func (s spec) exeDir(p *wineenv.Paths) string {
switch s.dir { switch s.dir {
case dirSDK: case dirSDK:
+18
View File
@@ -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)
}
}
}