vcdeps 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +258 -0
- data/exe/vcdeps +6 -0
- data/lib/vcdeps/bootstrap.rb +163 -0
- data/lib/vcdeps/cli.rb +206 -0
- data/lib/vcdeps/doctor.rb +263 -0
- data/lib/vcdeps/installed.rb +139 -0
- data/lib/vcdeps/manifest.rb +100 -0
- data/lib/vcdeps/mkmf.rb +125 -0
- data/lib/vcdeps/runner.rb +145 -0
- data/lib/vcdeps/tool.rb +146 -0
- data/lib/vcdeps/triplet.rb +77 -0
- data/lib/vcdeps/vendor.rb +160 -0
- data/lib/vcdeps/version.rb +5 -0
- data/lib/vcdeps.rb +180 -0
- metadata +121 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Vcdeps
|
|
7
|
+
# Reads, validates, and canonically hashes a vcpkg manifest directory. The
|
|
8
|
+
# install key (§2.5) is SHA256 over the canonicalized vcpkg.json +
|
|
9
|
+
# vcpkg-configuration.json + triplet + tool version, so the same inputs always
|
|
10
|
+
# map to the same out-of-tree install root and any change forces a fresh one.
|
|
11
|
+
module Manifest
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
MANIFEST_NAME = "vcpkg.json"
|
|
15
|
+
CONFIGURATION_NAME = "vcpkg-configuration.json"
|
|
16
|
+
|
|
17
|
+
# Absolute path to <dir>\vcpkg.json (display-backslashed).
|
|
18
|
+
def manifest_path(dir)
|
|
19
|
+
File.join(dir, MANIFEST_NAME).tr("/", "\\")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configuration_path(dir)
|
|
23
|
+
File.join(dir, CONFIGURATION_NAME)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Read and JSON-parse a file with BOM tolerance (editors love BOMs).
|
|
27
|
+
# Returns the parsed Hash. Raises Vcdeps::ManifestError on a missing file or
|
|
28
|
+
# a JSON syntax error (carrying the parser's position message).
|
|
29
|
+
def read_json(path)
|
|
30
|
+
raw = File.read(path, encoding: "BOM|UTF-8")
|
|
31
|
+
JSON.parse(raw)
|
|
32
|
+
rescue Errno::ENOENT
|
|
33
|
+
raise ManifestError, "vcdeps: no manifest at #{path.tr('/', '\\')}. " \
|
|
34
|
+
"Create a vcpkg.json there (see `vcdeps doctor`)."
|
|
35
|
+
rescue JSON::ParserError => e
|
|
36
|
+
raise ManifestError, "vcdeps: #{File.basename(path)} is not valid JSON: " \
|
|
37
|
+
"#{e.message}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Pre-flight check (§5.1/§5.2): the manifest exists, parses, and either pins
|
|
41
|
+
# "builtin-baseline" OR has a vcpkg-configuration.json beside it (git-registry
|
|
42
|
+
# instances hard-fail otherwise). Returns the parsed manifest Hash.
|
|
43
|
+
def load!(dir)
|
|
44
|
+
path = File.join(dir, MANIFEST_NAME)
|
|
45
|
+
manifest = read_json(path)
|
|
46
|
+
|
|
47
|
+
unless manifest.is_a?(Hash)
|
|
48
|
+
raise ManifestError, "vcdeps: #{MANIFEST_NAME} must be a JSON object."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
has_baseline = manifest.key?("builtin-baseline") &&
|
|
52
|
+
!manifest["builtin-baseline"].to_s.empty?
|
|
53
|
+
has_config = File.exist?(configuration_path(dir))
|
|
54
|
+
|
|
55
|
+
unless has_baseline || has_config
|
|
56
|
+
raise ManifestError, "vcdeps: #{manifest_path(dir)} has no " \
|
|
57
|
+
"\"builtin-baseline\" (required by git-registry vcpkg instances). " \
|
|
58
|
+
"Add one with: vcdeps baseline --manifest #{dir.tr('/', '\\')}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
manifest
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The 16-hex install key for (manifest dir, triplet, tool version) (§2.5).
|
|
65
|
+
def key(dir, triplet, tool_version)
|
|
66
|
+
manifest = read_json(File.join(dir, MANIFEST_NAME))
|
|
67
|
+
config_path = configuration_path(dir)
|
|
68
|
+
config = File.exist?(config_path) ? read_json(config_path) : nil
|
|
69
|
+
|
|
70
|
+
material = [
|
|
71
|
+
canonical(manifest),
|
|
72
|
+
config ? canonical(config) : "",
|
|
73
|
+
triplet.to_s,
|
|
74
|
+
tool_version.to_s
|
|
75
|
+
].join("\0")
|
|
76
|
+
|
|
77
|
+
Digest::SHA256.hexdigest(material)[0, 16]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Canonical JSON: recursively sort object keys, preserve array order,
|
|
81
|
+
# compact-generate. Two textually different but semantically identical
|
|
82
|
+
# manifests canonicalize identically.
|
|
83
|
+
def canonical(value)
|
|
84
|
+
JSON.generate(sort_value(value))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sort_value(value)
|
|
88
|
+
case value
|
|
89
|
+
when Hash
|
|
90
|
+
value.keys.sort.each_with_object({}) do |k, h|
|
|
91
|
+
h[k] = sort_value(value[k])
|
|
92
|
+
end
|
|
93
|
+
when Array
|
|
94
|
+
value.map { |v| sort_value(v) }
|
|
95
|
+
else
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/vcdeps/mkmf.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# vcdeps/mkmf — the extconf.rb entry point. NEVER require this into a normal
|
|
4
|
+
# process: it requires "mkmf", which mutates global build state. Load it only
|
|
5
|
+
# from inside an extconf.rb, after `require "mkmf"` and the suite's mswin guard.
|
|
6
|
+
#
|
|
7
|
+
# require "vcdeps/mkmf"
|
|
8
|
+
# Vcdeps.mkmf!(vendor: File.expand_path("../../lib/foo/vendor", __dir__))
|
|
9
|
+
|
|
10
|
+
require "mkmf"
|
|
11
|
+
require "vcvars"
|
|
12
|
+
require "vcdeps"
|
|
13
|
+
|
|
14
|
+
module Vcdeps
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Install the manifest's ports and wire mkmf so the compile/link see the vcpkg
|
|
18
|
+
# tree FIRST (defeating opt-dir shadowing, R§8.4). Behavior in order (§2.4):
|
|
19
|
+
# 1. Vcvars.activate! (idempotent) so mkmf's own try_compile probes work.
|
|
20
|
+
# 2. --with-vcdeps-dir bypass: skip vcpkg, wire a prebuilt <prefix>.
|
|
21
|
+
# 3. install! (manifest defaults to the extension SOURCE dir — $srcdir).
|
|
22
|
+
# 4. PREPEND -I to $INCFLAGS and unshift lib_dir onto $LIBPATH.
|
|
23
|
+
# 5. vendor! when vendor: is given; else warn loudly if DLLs went unvendored.
|
|
24
|
+
#
|
|
25
|
+
# triplet precedence: kwarg > --with-vcdeps-triplet= > VCDEPS_TRIPLET > default.
|
|
26
|
+
# Returns the Installed. Raises everything install!/vendor! raise.
|
|
27
|
+
def mkmf!(manifest: nil, triplet: nil, vendor: nil, force: false)
|
|
28
|
+
# 1. Toolchain: a Vcvars::Error propagates (no cl == doomed; message names
|
|
29
|
+
# `vcvars doctor`). No-op when a dev env is already active (CI).
|
|
30
|
+
Vcvars.activate!
|
|
31
|
+
|
|
32
|
+
# triplet from --with-vcdeps-triplet (with_config) sits between kwarg and ENV.
|
|
33
|
+
cfg_triplet = mkmf_with_config("vcdeps-triplet")
|
|
34
|
+
resolved_triplet = triplet || (cfg_triplet unless cfg_triplet == true) || nil
|
|
35
|
+
|
|
36
|
+
# 2. Bypass: --with-vcdeps-dir=<prefix> wires a prebuilt tree, no vcpkg.
|
|
37
|
+
bypass = mkmf_with_config("vcdeps-dir")
|
|
38
|
+
installed =
|
|
39
|
+
if bypass && bypass != true
|
|
40
|
+
bypass_installed(File.expand_path(bypass), resolved_triplet)
|
|
41
|
+
else
|
|
42
|
+
manifest_dir = manifest || default_manifest_dir
|
|
43
|
+
install!(manifest: manifest_dir, triplet: resolved_triplet, force: force)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
wire_mkmf!(installed)
|
|
47
|
+
handle_vendor(installed, vendor)
|
|
48
|
+
|
|
49
|
+
installed
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Wire mkmf: PREPEND the include dir to $INCFLAGS (it precedes $CPPFLAGS on the
|
|
53
|
+
# compile line) and unshift the lib dir onto $LIBPATH (renders before the
|
|
54
|
+
# opt-dir -libpath:). q() double-quotes a spaced path; $LIBPATH quoting is
|
|
55
|
+
# delegated to mkmf's libpathflag (verified to quote spaced paths).
|
|
56
|
+
def wire_mkmf!(installed)
|
|
57
|
+
inc = installed.include_dir
|
|
58
|
+
$INCFLAGS = "-I#{mkmf_quote(inc)} #{$INCFLAGS}"
|
|
59
|
+
$LIBPATH.unshift(installed.lib_dir)
|
|
60
|
+
installed
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Build an Installed for a --with-vcdeps-dir bypass (tool: nil; prefix taken
|
|
64
|
+
# as-is so include/lib/bin resolve under <prefix>; dlls from a bin\*.dll glob;
|
|
65
|
+
# ports: []).
|
|
66
|
+
def bypass_installed(prefix, triplet)
|
|
67
|
+
triplet ||= resolve_triplet_for_bypass
|
|
68
|
+
Installed.new(root: prefix, triplet: triplet, manifest_dir: prefix,
|
|
69
|
+
key: "bypass", tool: nil, prefix: prefix)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The extension SOURCE dir: File.expand_path($srcdir) when mkmf defined it
|
|
73
|
+
# (under rake-compiler cwd is tmp/<arch>/... while vcpkg.json sits beside
|
|
74
|
+
# extconf.rb — $srcdir is the correct anchor), else Dir.pwd.
|
|
75
|
+
def default_manifest_dir
|
|
76
|
+
srcdir = (defined?($srcdir) && $srcdir) ? $srcdir : nil
|
|
77
|
+
srcdir ? File.expand_path(srcdir) : Dir.pwd
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# vendor! when a vendor dir is given; else loudly warn that the built .so will
|
|
81
|
+
# need these DLLs at runtime and the .so's own dir is not searched (R§9.1).
|
|
82
|
+
def handle_vendor(installed, vendor)
|
|
83
|
+
if vendor
|
|
84
|
+
vendor!(installed, into: vendor)
|
|
85
|
+
elsif !installed.dlls.empty?
|
|
86
|
+
warn <<~MSG
|
|
87
|
+
[vcdeps] WARNING: this build links #{installed.dlls.size} runtime DLL(s)
|
|
88
|
+
that are NOT vendored. The built extension will fail to load at runtime
|
|
89
|
+
("LoadError: 126: The specified module could not be found") because
|
|
90
|
+
Windows does not search the .so's own directory for dependent DLLs.
|
|
91
|
+
Pass `vendor:` to Vcdeps.mkmf! (so vcdeps copies the DLLs + a preload
|
|
92
|
+
shim into your gem), or build with --with-vcdeps-triplet=x64-windows-static-md
|
|
93
|
+
to link the libraries statically and ship no DLLs.
|
|
94
|
+
MSG
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# --- mkmf seams (kept tiny so tests can reason about them) ----------------
|
|
99
|
+
|
|
100
|
+
# Read a --with-<name>[=value] flag via mkmf's with_config. Returns the value,
|
|
101
|
+
# true (bare flag), or nil/false (absent). Wrapped so a non-mkmf context is
|
|
102
|
+
# tolerated in unit tests.
|
|
103
|
+
def mkmf_with_config(name)
|
|
104
|
+
return nil unless respond_to?(:with_config, true) || defined?(with_config)
|
|
105
|
+
|
|
106
|
+
with_config(name)
|
|
107
|
+
rescue StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Double-quote a path containing spaces for a naked -I flag (§5.20). Already
|
|
112
|
+
# backslashed by Installed.
|
|
113
|
+
def mkmf_quote(path)
|
|
114
|
+
path.to_s.include?(" ") ? %("#{path}") : path.to_s
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def resolve_triplet_for_bypass
|
|
118
|
+
env = ENV["VCDEPS_TRIPLET"]
|
|
119
|
+
return env if env && !env.empty?
|
|
120
|
+
|
|
121
|
+
default_triplet
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private_class_method :resolve_triplet_for_bypass
|
|
125
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Vcdeps
|
|
8
|
+
# The ONLY place a vcpkg argv is constructed and spawned. Every `--x-*` flag
|
|
9
|
+
# lives in `args` so an upstream rename is a one-line fix (R§3/§5.13), and the
|
|
10
|
+
# runner test pins the exact argv. Combined child output is streamed to `out`
|
|
11
|
+
# line by line (never silent during a multi-minute port build); on any abnormal
|
|
12
|
+
# unwind the child is TerminateProcess'd and reaped so vcpkg.exe never outlives
|
|
13
|
+
# the call (§3.2).
|
|
14
|
+
module Runner
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
MARKER_NAME = ".vcdeps-complete"
|
|
18
|
+
LOG_TAIL_MAX = 4000
|
|
19
|
+
|
|
20
|
+
# Build the `vcpkg install` argv (§5.13 pin). All `--x-*` roots redirect out
|
|
21
|
+
# of the gem tree into <home> (R§7 path-length trap).
|
|
22
|
+
def install_args(exe, triplet, manifest_dir, install_root, home)
|
|
23
|
+
[
|
|
24
|
+
exe,
|
|
25
|
+
"install",
|
|
26
|
+
"--triplet", triplet,
|
|
27
|
+
"--x-manifest-root=#{manifest_dir}",
|
|
28
|
+
"--x-install-root=#{install_root}",
|
|
29
|
+
"--x-buildtrees-root=#{File.join(home, 'blds')}",
|
|
30
|
+
"--x-packages-root=#{File.join(home, 'pkgs')}"
|
|
31
|
+
]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The child environment: telemetry OFF by default (a gem install must not
|
|
35
|
+
# phone home, §5.15), VCPKG_ROOT pinned to the tool's own root. All other
|
|
36
|
+
# vcpkg env (binary cache, downloads) passes through untouched (§5.18).
|
|
37
|
+
def child_env(tool_root)
|
|
38
|
+
env = {}
|
|
39
|
+
env["VCPKG_DISABLE_METRICS"] = "1" unless ENV["VCDEPS_METRICS"] == "1"
|
|
40
|
+
env["VCPKG_ROOT"] = tool_root if tool_root
|
|
41
|
+
env
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Run `vcpkg install`. Streams combined output to `out` (nil to silence),
|
|
45
|
+
# returns the captured combined output on success, raises InstallError on a
|
|
46
|
+
# nonzero exit. `command` is the full argv (already built by install_args).
|
|
47
|
+
def run_install(command, env:, chdir:, out:)
|
|
48
|
+
run(command, env: env, chdir: chdir, out: out)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Generic streamed runner shared by install! and baseline!. Raises
|
|
52
|
+
# InstallError on nonzero exit; the network-failure hint (§5.17) is appended
|
|
53
|
+
# by the caller via install_error so this stays generic.
|
|
54
|
+
def run(command, env:, chdir:, out:)
|
|
55
|
+
combined = +""
|
|
56
|
+
|
|
57
|
+
Open3.popen2e(env, *command, chdir: chdir) do |_stdin, oe, wt|
|
|
58
|
+
pid = wt.pid
|
|
59
|
+
oe.binmode
|
|
60
|
+
begin
|
|
61
|
+
# Buffered each_line dispatches to a scheduler's io_read hook on mswin
|
|
62
|
+
# (§6); reads block in VM I/O with the GVL released. Scrub to UTF-8.
|
|
63
|
+
oe.each_line do |raw|
|
|
64
|
+
line = raw.dup.force_encoding("UTF-8").scrub("�")
|
|
65
|
+
combined << line
|
|
66
|
+
out&.print(line)
|
|
67
|
+
out&.flush if out.respond_to?(:flush)
|
|
68
|
+
end
|
|
69
|
+
status = wt.value
|
|
70
|
+
unless status.success?
|
|
71
|
+
raise InstallError.new(
|
|
72
|
+
install_error_message(command, status.exitstatus, combined),
|
|
73
|
+
command: command, status: status.exitstatus,
|
|
74
|
+
log_tail: log_tail(combined)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
rescue InstallError
|
|
78
|
+
raise
|
|
79
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
80
|
+
# ANY abnormal unwind (Interrupt, Timeout, a raise from the out block):
|
|
81
|
+
# kill THIS vcpkg.exe so its install-root lock handle is released. We
|
|
82
|
+
# do it HERE, before Open3's block teardown tries to drain the pipe
|
|
83
|
+
# (which would otherwise block on a still-running child). TerminateProcess
|
|
84
|
+
# is not recursive — helper processes vcpkg spawned may briefly survive
|
|
85
|
+
# (§3.2/§5.16). The pipe is then closed so the drain returns at once.
|
|
86
|
+
begin
|
|
87
|
+
Process.kill(:KILL, pid)
|
|
88
|
+
rescue Errno::ESRCH, RangeError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
begin
|
|
92
|
+
oe.close
|
|
93
|
+
rescue IOError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
raise
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
combined
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Last <= LOG_TAIL_MAX chars, UTF-8 scrubbed.
|
|
104
|
+
def log_tail(combined)
|
|
105
|
+
tail = combined.length > LOG_TAIL_MAX ? combined[-LOG_TAIL_MAX..] : combined
|
|
106
|
+
tail.to_s.dup.force_encoding("UTF-8").scrub("�")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Compose the InstallError message, appending the offline hint when the
|
|
110
|
+
# output looks network-shaped (§5.17).
|
|
111
|
+
def install_error_message(command, status, combined)
|
|
112
|
+
msg = "vcdeps: `#{File.basename(command.first)} #{command[1]}` exited #{status}."
|
|
113
|
+
if combined =~ /(failed to (fetch|download)|could not resolve|network)/i
|
|
114
|
+
msg += " First install of this baseline requires network access; " \
|
|
115
|
+
"see `vcdeps doctor` checks 9-10."
|
|
116
|
+
end
|
|
117
|
+
msg
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ---- completion marker (§2.5) -------------------------------------------
|
|
121
|
+
|
|
122
|
+
def marker_path(install_root)
|
|
123
|
+
File.join(install_root, MARKER_NAME)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def marker_present?(install_root)
|
|
127
|
+
File.exist?(marker_path(install_root))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Write the marker atomically: temp file then File.rename (atomic on NTFS).
|
|
131
|
+
def write_marker!(install_root, key, tool_version)
|
|
132
|
+
payload = JSON.generate(
|
|
133
|
+
"key" => key,
|
|
134
|
+
"tool_version" => tool_version.to_s,
|
|
135
|
+
"finished_at" => Time.now.utc.iso8601
|
|
136
|
+
)
|
|
137
|
+
final = marker_path(install_root)
|
|
138
|
+
tmp = "#{final}.tmp.#{Process.pid}"
|
|
139
|
+
File.write(tmp, payload)
|
|
140
|
+
File.rename(tmp, final)
|
|
141
|
+
ensure
|
|
142
|
+
File.unlink(tmp) if tmp && File.exist?(tmp) && !File.exist?(final)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/vcdeps/tool.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "vcvars"
|
|
5
|
+
|
|
6
|
+
module Vcdeps
|
|
7
|
+
# Where a usable vcpkg lives. `source` is one of :env, :devenv, :vs, :actions,
|
|
8
|
+
# :private (see §2.2). `version` is the date stamp parsed from `vcpkg version`.
|
|
9
|
+
Tool = Struct.new(:exe, :root, :version, :source, keyword_init: true) do
|
|
10
|
+
# => "vcpkg 2026-02-21 (vs) at C:\...\VC\vcpkg"
|
|
11
|
+
def to_s
|
|
12
|
+
"vcpkg #{version || '?'} (#{source}) at #{root}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Locates a usable vcpkg instance (or bootstraps a private one). The
|
|
17
|
+
# resolution order and validation are the load-bearing integration logic
|
|
18
|
+
# (R§2.5): every candidate must have a real vcpkg.exe AND a ".vcpkg-root"
|
|
19
|
+
# marker before it is trusted, and a user-set VCPKG_INSTALLATION_ROOT that
|
|
20
|
+
# lies (runner-images #9269) is skipped rather than believed.
|
|
21
|
+
module ToolFinder
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# The upstream standalone vcpkg.exe (the same artifact vcpkg-init downloads).
|
|
25
|
+
BOOTSTRAP_URL =
|
|
26
|
+
"https://github.com/microsoft/vcpkg-tool/releases/latest/download/vcpkg.exe"
|
|
27
|
+
|
|
28
|
+
# Memoized version per resolved exe path: avoids re-spawning `vcpkg version`.
|
|
29
|
+
def version_cache
|
|
30
|
+
@version_cache ||= {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Display-normalize to backslashes, like Vcvars::Locator.win.
|
|
34
|
+
def win(path)
|
|
35
|
+
path.nil? ? path : path.tr("/", "\\")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A directory is a valid vcpkg root iff it holds vcpkg.exe AND a
|
|
39
|
+
# ".vcpkg-root" marker (R§2.2/R§2.3). Returns the absolute exe path or nil.
|
|
40
|
+
def valid_root(root)
|
|
41
|
+
return nil if root.nil? || root.to_s.empty?
|
|
42
|
+
|
|
43
|
+
exe = File.join(root, "vcpkg.exe")
|
|
44
|
+
marker = File.join(root, ".vcpkg-root")
|
|
45
|
+
return nil unless File.file?(exe) && File.exist?(marker)
|
|
46
|
+
|
|
47
|
+
File.expand_path(exe)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve a Tool, or nil. With bootstrap: true a miss bootstraps a private
|
|
51
|
+
# instance instead of returning nil. Resolution order is §2.3 steps 1-5.
|
|
52
|
+
def find(bootstrap: false, out: $stderr)
|
|
53
|
+
tool = resolve
|
|
54
|
+
return tool if tool
|
|
55
|
+
return nil unless bootstrap
|
|
56
|
+
|
|
57
|
+
Vcdeps.bootstrap!(out: out)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The five-step resolution (no bootstrap). Returns a Tool or nil.
|
|
61
|
+
def resolve
|
|
62
|
+
# 1. Explicit VCPKG_ROOT — explicit beats implicit.
|
|
63
|
+
if (exe = valid_root(ENV["VCPKG_ROOT"]))
|
|
64
|
+
return build_tool(exe, :env)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 2. Activate vcvars (idempotent, mswin only, errors swallowed) and read
|
|
68
|
+
# the VCPKG_ROOT the VS developer env sets. After activation the VS
|
|
69
|
+
# bundle's VCPKG_ROOT is usually already present (R§2.1).
|
|
70
|
+
activate_vcvars_quietly
|
|
71
|
+
if (exe = valid_root(ENV["VCPKG_ROOT"]))
|
|
72
|
+
return build_tool(exe, :devenv)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 3. Vcvars.locate.vs_path + "\VC\vcpkg" probed directly (covers a located
|
|
76
|
+
# VS even if the activation env did not export the vcpkg extension).
|
|
77
|
+
if (vs = locate_vs_path) && (exe = valid_root(File.join(vs, "VC", "vcpkg")))
|
|
78
|
+
return build_tool(exe, :vs)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 4. VCPKG_INSTALLATION_ROOT (GitHub Actions) — VALIDATED; a stale/wrong
|
|
82
|
+
# value (issue #9269) is skipped, not trusted.
|
|
83
|
+
if (exe = valid_root(ENV["VCPKG_INSTALLATION_ROOT"]))
|
|
84
|
+
return build_tool(exe, :actions)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# 5. A previously bootstrapped private instance under <home>\vcpkg.
|
|
88
|
+
if (exe = valid_root(File.join(Vcdeps.home, "vcpkg")))
|
|
89
|
+
return build_tool(exe, :private)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build a Tool for a resolved exe, reading (and memoizing) its version.
|
|
96
|
+
def build_tool(exe, source)
|
|
97
|
+
Tool.new(exe: win(exe), root: win(File.dirname(exe)),
|
|
98
|
+
version: read_version(exe), source: source)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Parse the date stamp from `vcpkg version`'s first line:
|
|
102
|
+
# "vcpkg package management program version 2026-02-21-<sha>..."
|
|
103
|
+
# -> "2026-02-21". Falls back to the whole token if the shape differs.
|
|
104
|
+
# Memoized per exe path.
|
|
105
|
+
def read_version(exe)
|
|
106
|
+
key = File.expand_path(exe)
|
|
107
|
+
return version_cache[key] if version_cache.key?(key)
|
|
108
|
+
|
|
109
|
+
version_cache[key] = capture_version(exe)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def capture_version(exe)
|
|
113
|
+
out, status = Open3.capture2(exe, "version")
|
|
114
|
+
return nil unless status&.success?
|
|
115
|
+
|
|
116
|
+
line = out.to_s.lines.first.to_s
|
|
117
|
+
# Grab a leading date "YYYY-MM-DD" from the version token if present.
|
|
118
|
+
if (m = line.match(/version\s+(\d{4}-\d{2}-\d{2})/))
|
|
119
|
+
m[1]
|
|
120
|
+
elsif (m = line.match(/(\d{4}-\d{2}-\d{2})/))
|
|
121
|
+
m[1]
|
|
122
|
+
else
|
|
123
|
+
line.strip.empty? ? nil : line.strip
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Vcvars.activate! is idempotent and mswin-only; any failure here is not
|
|
130
|
+
# fatal to resolution (steps 3-5 may still find a tool), so swallow it.
|
|
131
|
+
def activate_vcvars_quietly
|
|
132
|
+
return unless Vcvars.mswin?
|
|
133
|
+
|
|
134
|
+
Vcvars.activate!
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def locate_vs_path
|
|
140
|
+
inst = Vcvars.locate
|
|
141
|
+
inst&.vs_path
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Vcdeps
|
|
6
|
+
# Derives the vcpkg triplet for the running Ruby and validates user-supplied
|
|
7
|
+
# triplets. The default is the dynamic-CRT, dynamic-library triplet that
|
|
8
|
+
# matches an `-MD` mswin Ruby (x64 -> "x64-windows"); static-CRT triplets are
|
|
9
|
+
# rejected (the LNK2005/dual-heap hazard, R§4).
|
|
10
|
+
#
|
|
11
|
+
# No "x64" literal lives in code: the architecture token is derived from
|
|
12
|
+
# RbConfig::CONFIG["arch"], so arm64-mswin derives "arm64-windows" for free.
|
|
13
|
+
module Triplet
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Maps a Ruby arch string to its vcpkg architecture token, or nil if
|
|
17
|
+
# unrecognized. Mirrors Vcvars::Doctor.arch's regexes.
|
|
18
|
+
# "x64-mswin64_140" -> "x64"
|
|
19
|
+
# "arm64-mswin64_140" -> "arm64"
|
|
20
|
+
# "i386-mswin32" -> "x86"
|
|
21
|
+
def arch_token(arch)
|
|
22
|
+
a = arch.to_s
|
|
23
|
+
return "x64" if a =~ /\A(?:x64|x86_64|amd64)/i
|
|
24
|
+
return "arm64" if a =~ /arm64|aarch64/i
|
|
25
|
+
return "x86" if a =~ /\A(?:i[3-6]86|x86)/i
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Known architecture prefixes a triplet may start with — used to detect an
|
|
30
|
+
# arch mismatch against the running Ruby.
|
|
31
|
+
ARCH_TOKENS = %w[x64 x86 arm64 arm].freeze
|
|
32
|
+
|
|
33
|
+
# The default triplet for the given Ruby arch (dynamic CRT, dynamic libs).
|
|
34
|
+
# Raises Vcdeps::TripletError for an unrecognized arch.
|
|
35
|
+
def default(arch = RbConfig::CONFIG["arch"])
|
|
36
|
+
token = arch_token(arch)
|
|
37
|
+
raise TripletError, "vcdeps: cannot derive a vcpkg triplet for arch " \
|
|
38
|
+
"#{arch.inspect} (recognized: x64, x86, arm64). Set VCDEPS_TRIPLET or " \
|
|
39
|
+
"pass --with-vcdeps-triplet=<triplet> explicitly." if token.nil?
|
|
40
|
+
|
|
41
|
+
"#{token}-windows"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate a triplet against §2.6 and return it unchanged, or raise
|
|
45
|
+
# Vcdeps::TripletError. `arch` is the running Ruby's arch (for the mismatch
|
|
46
|
+
# check); pass it explicitly in tests to exercise the forward-compat path.
|
|
47
|
+
def validate!(triplet, arch: RbConfig::CONFIG["arch"])
|
|
48
|
+
t = triplet.to_s
|
|
49
|
+
|
|
50
|
+
unless t =~ /\A[a-z0-9][a-z0-9-]*\z/
|
|
51
|
+
raise TripletError, "vcdeps: invalid triplet #{triplet.inspect} " \
|
|
52
|
+
"(must match /\\A[a-z0-9][a-z0-9-]*\\z/). Run `vcdeps doctor`."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reject a static-CRT triplet (-static suffix). static-md (static libs +
|
|
56
|
+
# dynamic CRT) is fine; plain -static sets VCPKG_CRT_LINKAGE static.
|
|
57
|
+
if t =~ /-static\z/
|
|
58
|
+
raise TripletError, "vcdeps: triplet #{triplet.inspect} uses a STATIC " \
|
|
59
|
+
"CRT, which is ABI-incompatible with this -MD Ruby (LNK2005 / dual " \
|
|
60
|
+
"heap). Use #{t.sub(/-static\z/, '-static-md')} (static libs, dynamic " \
|
|
61
|
+
"CRT — no DLLs to vendor) or the default #{default(arch)}."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# If the triplet leads with a known arch token that differs from this
|
|
65
|
+
# Ruby's arch, it would LNK1112 at link time.
|
|
66
|
+
lead = t.split("-", 2).first
|
|
67
|
+
ruby_token = arch_token(arch)
|
|
68
|
+
if ARCH_TOKENS.include?(lead) && ruby_token && lead != ruby_token
|
|
69
|
+
raise TripletError, "vcdeps: triplet #{triplet.inspect} targets #{lead} " \
|
|
70
|
+
"but this Ruby is #{ruby_token} (#{arch}); linking would fail with " \
|
|
71
|
+
"LNK1112. Use a #{ruby_token}-* triplet (default #{default(arch)})."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
t
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|