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.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+ require "vcdeps/version"
6
+
7
+ module Vcdeps
8
+ # Syncs an Installed's runtime DLLs, per-port copyright files, and a standalone
9
+ # Fiddle-preload shim into a vendor directory (§2.2/§2.7). This is a SYNC, not
10
+ # a copy: vendor! OWNS <into>\*.dll, <into>\licenses\* and <into>\preload.rb —
11
+ # it overwrites the computed write list and DELETES any owned file no longer on
12
+ # it (so a port removed from vcpkg.json leaves no stale DLL the shim would
13
+ # preload and the precompiled-gem glob would ship). Nothing else under `into`
14
+ # is touched, so authors may keep unrelated assets there.
15
+ module Vendor
16
+ module_function
17
+
18
+ SHIM_NAME = "preload.rb"
19
+ LICENSES_DIR = "licenses"
20
+
21
+ # Returns the list of files written (absolute, backslashed). See §2.2 for the
22
+ # four-case table; stale owned files are deleted whenever `into` exists.
23
+ def sync!(installed, into:, licenses: true, shim: true, out: $stderr)
24
+ dlls = installed.dlls
25
+ copyrights = licenses ? installed.copyright_files : {}
26
+
27
+ # The write plan: a DLL is vendored only when there is something to vendor;
28
+ # the shim is written only when DLLs exist; licenses ship whenever copyright
29
+ # files exist (including static-md, where there are no DLLs).
30
+ write_dlls = dlls
31
+ write_shim = shim && !dlls.empty?
32
+ write_copies = copyrights
33
+
34
+ # Nothing to write AND nothing to clean (into absent) -> [] without
35
+ # creating `into` (§2.2 last case).
36
+ if write_dlls.empty? && write_copies.empty? && !write_shim &&
37
+ !File.directory?(into)
38
+ return []
39
+ end
40
+
41
+ written = []
42
+ FileUtils.mkdir_p(into)
43
+
44
+ # Owned DLLs we intend to keep (base names), for the stale sweep.
45
+ kept_dll_names = []
46
+ write_dlls.each do |src|
47
+ base = File.basename(src)
48
+ dest = File.join(into, base)
49
+ FileUtils.cp(src, dest)
50
+ kept_dll_names << base.downcase
51
+ written << win(dest)
52
+ warn_on_ruby_bin_collision(base, out)
53
+ end
54
+
55
+ # Licenses.
56
+ kept_license_names = []
57
+ unless write_copies.empty?
58
+ ldir = File.join(into, LICENSES_DIR)
59
+ FileUtils.mkdir_p(ldir)
60
+ write_copies.each do |port, src|
61
+ dest = File.join(ldir, "#{port}-copyright.txt")
62
+ FileUtils.cp(src, dest)
63
+ kept_license_names << File.basename(dest).downcase
64
+ written << win(dest)
65
+ end
66
+ end
67
+
68
+ # Shim.
69
+ shim_path = File.join(into, SHIM_NAME)
70
+ if write_shim
71
+ File.write(shim_path, shim_source)
72
+ written << win(shim_path)
73
+ end
74
+
75
+ # Stale sweep of OWNED files only.
76
+ sweep_stale!(into, kept_dll_names, kept_license_names, write_shim)
77
+
78
+ written
79
+ end
80
+
81
+ # The generated preload shim (exact §2.7 template). Standalone: stdlib only,
82
+ # no vcdeps require, safe to require twice, safe with zero DLLs present.
83
+ def shim_source
84
+ <<~SHIM
85
+ # frozen_string_literal: true
86
+ #
87
+ # Generated by vcdeps #{Vcdeps::VERSION}. Do not edit; regenerate with `vcdeps vendor`.
88
+ #
89
+ # Preloads the vendored vcpkg runtime DLLs by ABSOLUTE path before the C
90
+ # extension loads. Windows resolves an extension's dependent DLLs via the
91
+ # standard search order, which checks the loaded-module list (step 4) long
92
+ # before PATH (step 12) and NEVER checks the .so's own directory — so loading
93
+ # each DLL here, by full path, is what makes `require "<gem>/<gem>"` work.
94
+ #
95
+ # The dlopen handles are intentionally leaked for process lifetime: the DLLs
96
+ # must stay loaded as long as the extension is loaded.
97
+ require "fiddle"
98
+
99
+ pending = Dir[File.join(__dir__, "*.dll")].sort
100
+ until pending.empty?
101
+ progressed = false
102
+ pending.delete_if do |dll|
103
+ Fiddle.dlopen(dll)
104
+ progressed = true
105
+ true
106
+ rescue Fiddle::DLError
107
+ false # depends on a sibling not yet loaded — retry next pass
108
+ end
109
+ break unless progressed # truly unloadable; let the require raise its own LoadError
110
+ end
111
+ SHIM
112
+ end
113
+
114
+ # --- internals -----------------------------------------------------------
115
+
116
+ # Delete any *.dll directly in `into` not on the kept list, any licenses\*
117
+ # file not kept, and a stale shim when no shim is being written.
118
+ def sweep_stale!(into, kept_dll_names, kept_license_names, write_shim)
119
+ Dir[File.join(into.tr("\\", "/"), "*.dll")].each do |dll|
120
+ File.delete(dll) unless kept_dll_names.include?(File.basename(dll).downcase)
121
+ end
122
+
123
+ ldir = File.join(into.tr("\\", "/"), LICENSES_DIR)
124
+ if File.directory?(ldir)
125
+ Dir[File.join(ldir, "*")].each do |f|
126
+ next unless File.file?(f)
127
+
128
+ File.delete(f) unless kept_license_names.include?(File.basename(f).downcase)
129
+ end
130
+ # Remove an empty licenses dir we no longer populate.
131
+ begin
132
+ Dir.rmdir(ldir) if Dir.empty?(ldir)
133
+ rescue SystemCallError
134
+ nil
135
+ end
136
+ end
137
+
138
+ shim = File.join(into.tr("\\", "/"), SHIM_NAME)
139
+ File.delete(shim) if !write_shim && File.exist?(shim)
140
+ end
141
+
142
+ # Warn when a vendored DLL base name also exists in Ruby's bindir
143
+ # (zlib1.dll/ffi-8.dll/yaml.dll shadowing, R§9.2) — first-loaded wins.
144
+ def warn_on_ruby_bin_collision(base, out)
145
+ bindir = RbConfig::CONFIG["bindir"].to_s
146
+ return if bindir.empty?
147
+
148
+ ruby_copy = File.join(bindir, base)
149
+ return unless File.exist?(ruby_copy)
150
+
151
+ out&.puts("[vcdeps] WARNING: #{base} also ships in #{bindir.tr('/', '\\')} " \
152
+ "— the copy loaded first wins process-wide; preload.rb runs " \
153
+ "first under normal require order.")
154
+ end
155
+
156
+ def win(path)
157
+ path.nil? ? path : path.tr("/", "\\")
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vcdeps
4
+ VERSION = "0.1.0"
5
+ end
data/lib/vcdeps.rb ADDED
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "vcdeps/version"
7
+
8
+ # vcdeps — vcpkg-powered native dependencies for Ruby C extensions on Windows
9
+ # (MSVC). Declare ports in a manifest, get correct mkmf flags and loadable
10
+ # runtime DLLs, no global state. Companion to vcvars (the toolchain) the way
11
+ # vcpkg is companion to MSVC.
12
+ #
13
+ # # in ext/<gem>/extconf.rb, after require "mkmf":
14
+ # require "vcdeps/mkmf"
15
+ # Vcdeps.mkmf!(vendor: File.expand_path("../../lib/foo/vendor", __dir__))
16
+ #
17
+ # Library use:
18
+ #
19
+ # require "vcdeps"
20
+ # inst = Vcdeps.install!(manifest: "ext/foo")
21
+ # Vcdeps.vendor!(inst, into: "lib/foo/vendor")
22
+ #
23
+ # Windows MSVC (mswin) Ruby only. Pure Ruby — no compiler required to install.
24
+ module Vcdeps
25
+ class Error < StandardError; end # root; everything vcdeps raises
26
+ class ToolNotFound < Error; end # no usable vcpkg anywhere
27
+ class BootstrapError < Error; end # private bootstrap failed
28
+ class ManifestError < Error; end # vcpkg.json missing/unparsable/no baseline
29
+ class TripletError < Error; end # unknown arch, static-CRT triplet, arch mismatch
30
+
31
+ # `vcpkg install` exited nonzero. Carries the exact failing argv, the child
32
+ # exit status, and the last <= 4000 chars of combined output (UTF-8 scrubbed).
33
+ class InstallError < Error
34
+ attr_reader :command, :status, :log_tail
35
+
36
+ def initialize(message, command:, status:, log_tail:)
37
+ super(message)
38
+ @command = command
39
+ @status = status
40
+ @log_tail = log_tail
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ # The vcdeps state root: ENV["VCDEPS_HOME"] or %LOCALAPPDATA%\vcdeps.
47
+ # Deliberately a SHORT path (vcpkg port builds break in deep trees, R§7).
48
+ def home
49
+ base = ENV["VCDEPS_HOME"]
50
+ base = File.join(ENV["LOCALAPPDATA"] || Dir.tmpdir, "vcdeps") if base.nil? || base.empty?
51
+ base.tr("/", "\\")
52
+ end
53
+
54
+ # Triplet for this Ruby (x64 -> "x64-windows", arm64 -> "arm64-windows", ...).
55
+ # Raises Vcdeps::TripletError for an unrecognized arch.
56
+ def default_triplet(arch = RbConfig::CONFIG["arch"])
57
+ Triplet.default(arch)
58
+ end
59
+
60
+ # Locate a usable vcpkg (§2.3 resolution order). Returns a Tool or nil; with
61
+ # bootstrap: true a miss bootstraps a private instance instead of nil.
62
+ def tool(bootstrap: false, out: $stderr)
63
+ ToolFinder.find(bootstrap: bootstrap, out: out)
64
+ end
65
+
66
+ # tool(), but raises Vcdeps::ToolNotFound (with the three remedies) on a miss.
67
+ # Default consent for bootstrap comes from ENV so `gem install` can be
68
+ # unblocked without editing anything.
69
+ def tool!(bootstrap: ENV["VCDEPS_BOOTSTRAP"] == "1", out: $stderr)
70
+ found = tool(bootstrap: bootstrap, out: out)
71
+ return found if found
72
+
73
+ raise ToolNotFound, <<~MSG.strip
74
+ vcdeps: no usable vcpkg found. Pick one of:
75
+ (a) Install the VS "vcpkg package manager" component
76
+ (Microsoft.VisualStudio.Component.Vcpkg) — it is Recommended (not
77
+ Required) in the "Desktop development with C++" workload, so a
78
+ cl-capable box can still lack it.
79
+ (b) Set VCPKG_ROOT to an existing vcpkg instance.
80
+ (c) Run `vcdeps bootstrap` (or set VCDEPS_BOOTSTRAP=1 for non-interactive
81
+ installs).
82
+ See `vcdeps doctor` for details.
83
+ MSG
84
+ end
85
+
86
+ # Create the private, registration-free vcpkg instance under <home>\vcpkg
87
+ # (the vcpkg-init mechanism, R§2.4). Idempotent. Raises BootstrapError.
88
+ def bootstrap!(out: $stderr)
89
+ require "vcdeps/bootstrap"
90
+ Bootstrap.run!(out: out)
91
+ end
92
+
93
+ # Run `vcpkg install` for <manifest>\vcpkg.json into <home>\installed\<key>.
94
+ # Streams combined output to `out` (nil to silence). Fast path: a present
95
+ # .vcdeps-complete marker means the install is provably current and vcpkg is
96
+ # not invoked (force: true bypasses it). Triplet precedence: kwarg >
97
+ # ENV["VCDEPS_TRIPLET"] > default_triplet.
98
+ #
99
+ # Raises ManifestError / TripletError / ToolNotFound / InstallError.
100
+ def install!(manifest: Dir.pwd, triplet: nil, tool: nil, force: false, out: $stderr)
101
+ manifest_dir = File.expand_path(manifest)
102
+
103
+ # Pre-flight the manifest BEFORE locating the tool (a missing vcpkg.json or
104
+ # missing baseline must never invoke vcpkg, §5.1/§5.2).
105
+ Manifest.load!(manifest_dir)
106
+
107
+ resolved_triplet = resolve_triplet(triplet)
108
+ Triplet.validate!(resolved_triplet)
109
+
110
+ resolved_tool = tool || tool!(out: out)
111
+
112
+ key = Manifest.key(manifest_dir, resolved_triplet, resolved_tool.version)
113
+ install_root = File.join(home, "installed", key).tr("/", "\\")
114
+
115
+ installed = Installed.new(root: install_root, triplet: resolved_triplet,
116
+ manifest_dir: manifest_dir, key: key, tool: resolved_tool)
117
+
118
+ # Fast path: marker present and not forced.
119
+ return installed if !force && Runner.marker_present?(install_root)
120
+
121
+ FileUtils.mkdir_p(install_root)
122
+ command = Runner.install_args(resolved_tool.exe, resolved_triplet,
123
+ manifest_dir, install_root, home)
124
+ Runner.run_install(command, env: Runner.child_env(resolved_tool.root),
125
+ chdir: manifest_dir, out: out)
126
+ Runner.write_marker!(install_root, key, resolved_tool.version)
127
+
128
+ installed
129
+ end
130
+
131
+ # Sync an Installed's runtime DLLs (+ copyright files, + preload shim) into
132
+ # `into` (§2.2). SYNC, not copy: owns <into>\*.dll, <into>\licenses\* and
133
+ # <into>\preload.rb. Returns the list of files written.
134
+ def vendor!(installed, into:, licenses: true, shim: true, out: $stderr)
135
+ require "vcdeps/vendor"
136
+ Vendor.sync!(installed, into: File.expand_path(into),
137
+ licenses: licenses, shim: shim, out: out)
138
+ end
139
+
140
+ # Write/update "builtin-baseline" in <manifest>\vcpkg.json by running
141
+ # `vcpkg x-update-baseline --add-initial-baseline` with chdir: manifest.
142
+ # Returns the resulting baseline SHA (re-read from the manifest).
143
+ # Raises ManifestError / ToolNotFound / InstallError.
144
+ def baseline!(manifest: Dir.pwd, tool: nil, out: $stderr)
145
+ manifest_dir = File.expand_path(manifest)
146
+ path = File.join(manifest_dir, Manifest::MANIFEST_NAME)
147
+ unless File.exist?(path)
148
+ raise ManifestError, "vcdeps: no manifest at #{path.tr('/', '\\')} to add a " \
149
+ "baseline to."
150
+ end
151
+
152
+ resolved_tool = tool || tool!(out: out)
153
+ command = [resolved_tool.exe, "x-update-baseline", "--add-initial-baseline"]
154
+ Runner.run(command, env: Runner.child_env(resolved_tool.root),
155
+ chdir: manifest_dir, out: out)
156
+
157
+ manifest = Manifest.read_json(path)
158
+ manifest["builtin-baseline"].to_s
159
+ end
160
+
161
+ # Triplet precedence for install! (kwarg > VCDEPS_TRIPLET > default).
162
+ def resolve_triplet(kwarg)
163
+ return kwarg if kwarg && !kwarg.to_s.empty?
164
+
165
+ env = ENV["VCDEPS_TRIPLET"]
166
+ return env if env && !env.empty?
167
+
168
+ default_triplet
169
+ end
170
+ private_class_method :resolve_triplet
171
+ end
172
+
173
+ require "vcdeps/triplet"
174
+ require "vcdeps/tool"
175
+ require "vcdeps/manifest"
176
+ require "vcdeps/runner"
177
+ require "vcdeps/installed"
178
+ # NOTE: vcdeps/mkmf, vcdeps/vendor, vcdeps/bootstrap, vcdeps/doctor and
179
+ # vcdeps/cli are required lazily (mkmf must never be loaded into a normal
180
+ # process; the others are CLI-only or pulled in on first use).
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vcdeps
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ned
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: vcvars
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 0.1.1
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '0.1'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.1.1
32
+ - !ruby/object:Gem::Dependency
33
+ name: rake
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '13.0'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '13.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: minitest
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '5.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '5.0'
60
+ description: |
61
+ vcdeps wires Microsoft's vcpkg package manager into mkmf: declare native
62
+ dependencies in ext/<gem>/vcpkg.json, call Vcdeps.mkmf! from extconf.rb, and
63
+ vcdeps locates (or bootstraps) vcpkg, installs the ports out of tree with the
64
+ correct dynamic-CRT triplet, prepends the include/lib paths so they win over
65
+ Ruby's own opt-dir, and vendors the runtime DLLs with a generated
66
+ Fiddle-preload shim so the built extension actually loads.
67
+
68
+ It provides a library API (install!/vendor!/baseline!/tool!), the extconf
69
+ entry point Vcdeps.mkmf!, and a CLI (vcdeps doctor/where/install/vendor/
70
+ baseline/bootstrap). Manifest mode only; builds and caches live under
71
+ %LOCALAPPDATA%\vcdeps, never inside the gem. Windows MSVC (mswin) Ruby
72
+ only. Pure Ruby — no compiler required to install vcdeps itself.
73
+ executables:
74
+ - vcdeps
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE.txt
80
+ - README.md
81
+ - exe/vcdeps
82
+ - lib/vcdeps.rb
83
+ - lib/vcdeps/bootstrap.rb
84
+ - lib/vcdeps/cli.rb
85
+ - lib/vcdeps/doctor.rb
86
+ - lib/vcdeps/installed.rb
87
+ - lib/vcdeps/manifest.rb
88
+ - lib/vcdeps/mkmf.rb
89
+ - lib/vcdeps/runner.rb
90
+ - lib/vcdeps/tool.rb
91
+ - lib/vcdeps/triplet.rb
92
+ - lib/vcdeps/vendor.rb
93
+ - lib/vcdeps/version.rb
94
+ homepage: https://github.com/main-path/vcdeps
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/main-path/vcdeps
99
+ source_code_uri: https://github.com/main-path/vcdeps
100
+ changelog_uri: https://github.com/main-path/vcdeps/blob/main/CHANGELOG.md
101
+ bug_tracker_uri: https://github.com/main-path/vcdeps/issues
102
+ rubygems_mfa_required: 'true'
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 3.6.9
118
+ specification_version: 4
119
+ summary: vcpkg-powered native dependencies for Ruby C extensions on Windows (MSVC),
120
+ companion to vcvars.
121
+ test_files: []