proscenium 0.26.0-x86_64-linux-gnu
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/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +1020 -0
- data/exe/proscenium +19 -0
- data/exe/psm +5 -0
- data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
- data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
- data/lib/proscenium/app_package.rb +90 -0
- data/lib/proscenium/builder.rb +403 -0
- data/lib/proscenium/bundled_gems.rb +140 -0
- data/lib/proscenium/cli/brackets.rb +66 -0
- data/lib/proscenium/cli/bun_linker.rb +79 -0
- data/lib/proscenium/cli/capabilities.json +29 -0
- data/lib/proscenium/cli/collisions.rb +94 -0
- data/lib/proscenium/cli/contexts.rb +133 -0
- data/lib/proscenium/cli/diff.rb +80 -0
- data/lib/proscenium/cli/error.rb +291 -0
- data/lib/proscenium/cli/gem_archive.rb +44 -0
- data/lib/proscenium/cli/gem_check.rb +150 -0
- data/lib/proscenium/cli/gitignore.rb +63 -0
- data/lib/proscenium/cli/inspect.rb +257 -0
- data/lib/proscenium/cli/install.rb +378 -0
- data/lib/proscenium/cli/manager.rb +323 -0
- data/lib/proscenium/cli/platform_variants.rb +82 -0
- data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
- data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
- data/lib/proscenium/cli/project_lock.rb +106 -0
- data/lib/proscenium/cli/registration.rb +140 -0
- data/lib/proscenium/cli/reporter.rb +103 -0
- data/lib/proscenium/cli/rules.rb +169 -0
- data/lib/proscenium/cli/runner.rb +142 -0
- data/lib/proscenium/cli/verify.rb +107 -0
- data/lib/proscenium/cli.rb +122 -0
- data/lib/proscenium/context_map.rb +181 -0
- data/lib/proscenium/css_module/path.rb +32 -0
- data/lib/proscenium/css_module/transformer.rb +118 -0
- data/lib/proscenium/css_module.rb +63 -0
- data/lib/proscenium/dependency_context.rb +74 -0
- data/lib/proscenium/ensure_loaded.rb +31 -0
- data/lib/proscenium/error.rb +10 -0
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +113 -0
- data/lib/proscenium/helper.rb +82 -0
- data/lib/proscenium/importer.rb +189 -0
- data/lib/proscenium/log_subscriber.rb +38 -0
- data/lib/proscenium/manifest.rb +48 -0
- data/lib/proscenium/mapping_generation.rb +64 -0
- data/lib/proscenium/middleware/base.rb +102 -0
- data/lib/proscenium/middleware/chunks.rb +46 -0
- data/lib/proscenium/middleware/esbuild.rb +11 -0
- data/lib/proscenium/middleware/ruby_gems.rb +37 -0
- data/lib/proscenium/middleware/silence_request.rb +31 -0
- data/lib/proscenium/middleware/vendor.rb +31 -0
- data/lib/proscenium/middleware.rb +67 -0
- data/lib/proscenium/monkey.rb +77 -0
- data/lib/proscenium/railtie.rb +106 -0
- data/lib/proscenium/railties/assets.rake +19 -0
- data/lib/proscenium/resolver.rb +67 -0
- data/lib/proscenium/runtime/bootstrap.js +266 -0
- data/lib/proscenium/runtime/bun.js +143 -0
- data/lib/proscenium/runtime/server.rb +702 -0
- data/lib/proscenium/side_load.rb +210 -0
- data/lib/proscenium/source_path.rb +15 -0
- data/lib/proscenium/stale_contexts.rb +118 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +32 -0
- data/lib/proscenium/utils.rb +104 -0
- data/lib/proscenium/version.rb +5 -0
- data/lib/proscenium/yaml_tree.rb +48 -0
- data/lib/proscenium.rb +87 -0
- metadata +148 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../context_map'
|
|
5
|
+
require_relative '../yaml_tree'
|
|
6
|
+
require_relative 'pnpm_approvals'
|
|
7
|
+
require 'open3'
|
|
8
|
+
require 'bundler'
|
|
9
|
+
require 'date'
|
|
10
|
+
|
|
11
|
+
module Proscenium
|
|
12
|
+
module CLI
|
|
13
|
+
# Which package manager a project uses, whether Proscenium supports it, and whether the project
|
|
14
|
+
# is set up to register contexts with it (#154). Nothing here writes: every refusal happens
|
|
15
|
+
# before `install` changes a file.
|
|
16
|
+
class Manager
|
|
17
|
+
CAPABILITIES = JSON.parse(File.read(File.expand_path('capabilities.json', __dir__)))
|
|
18
|
+
SUPPORTED = %w[pnpm bun].freeze
|
|
19
|
+
# Lockfile => the manager that writes it. LOCKFILES.key(name) is a manager's own lockfile.
|
|
20
|
+
LOCKFILES = { 'pnpm-lock.yaml' => 'pnpm', 'bun.lock' => 'bun', 'yarn.lock' => 'yarn',
|
|
21
|
+
'package-lock.json' => 'npm', 'npm-shrinkwrap.json' => 'npm' }.freeze
|
|
22
|
+
LINKERS = %w[hoisted isolated].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :name, :version, :executable, :root
|
|
25
|
+
|
|
26
|
+
# Selects the manager for the project at `root`: `requested` (from --manager), else the root
|
|
27
|
+
# package.json's `packageManager`, else exactly one recognized lockfile.
|
|
28
|
+
def self.select(root, requested: nil)
|
|
29
|
+
requested ||= from_package_manager(root)
|
|
30
|
+
lockfiles = LOCKFILES.select { |file, _| File.exist?(File.join(root, file)) }.values.uniq
|
|
31
|
+
lockfiles << 'yarn' if File.exist?(File.join(root, '.yarnrc.yml'))
|
|
32
|
+
lockfiles.uniq!
|
|
33
|
+
|
|
34
|
+
name = requested || lockfiles.first
|
|
35
|
+
# Every lockfile is its manager's, whichever manager is named; bun.lockb is Bun's too.
|
|
36
|
+
lockb = File.exist?(File.join(root, 'bun.lockb'))
|
|
37
|
+
signals = [requested, *lockfiles, ('bun' if lockb)].compact.uniq
|
|
38
|
+
if signals.size > 1
|
|
39
|
+
raise Error.new('PSM-E-MANAGER-CONFLICT', signals: signals.join(' and '))
|
|
40
|
+
end
|
|
41
|
+
# However Bun is chosen, a binary bun.lockb is its only lock until bun.lock is written.
|
|
42
|
+
if lockb && !lockfiles.include?('bun') && [nil, 'bun'].include?(name)
|
|
43
|
+
raise Error, 'PSM-E-BUN-LOCKB'
|
|
44
|
+
end
|
|
45
|
+
raise Error, 'PSM-E-NO-MANAGER' unless name
|
|
46
|
+
raise Error.new('PSM-E-UNSUPPORTED-MANAGER', manager: name) unless SUPPORTED.include?(name)
|
|
47
|
+
|
|
48
|
+
new(name, root)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The manager named by package.json's `packageManager` (`pnpm@10.33.1+sha512...`), or nil.
|
|
52
|
+
def self.from_package_manager(root)
|
|
53
|
+
path = File.join(root, 'package.json')
|
|
54
|
+
return nil unless File.exist?(path)
|
|
55
|
+
|
|
56
|
+
field = ContextMap.read_json(path)['packageManager']
|
|
57
|
+
field.is_a?(String) ? field.split('@').first : nil
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(name, root)
|
|
63
|
+
@name = name
|
|
64
|
+
@root = root
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Checks the installed manager's version against the capability table. An unqualified
|
|
68
|
+
# version is refused unless `experimental`, which `frozen` never allows.
|
|
69
|
+
def check_version!(experimental: false, frozen: false)
|
|
70
|
+
@executable = self.class.which(@name) or raise Error.new('PSM-E-MANAGER-MISSING',
|
|
71
|
+
manager: @name)
|
|
72
|
+
check_launchable!
|
|
73
|
+
# pnpm runs the version packageManager pins or refuses to run; Bun ignores the field, so
|
|
74
|
+
# Bun's own answer is the only one that counts.
|
|
75
|
+
@version = (@name == 'pnpm' && runs_pin? && pinned_version) || probed_version
|
|
76
|
+
return self if line
|
|
77
|
+
|
|
78
|
+
raise Error, 'PSM-E-EXPERIMENTAL-FROZEN' if experimental && frozen
|
|
79
|
+
unless experimental
|
|
80
|
+
raise Error.new('PSM-E-MANAGER-VERSION', manager: @name, version: @version,
|
|
81
|
+
supported: supported_ranges)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The capability table's line for the installed version, or nil. A prerelease, such as
|
|
88
|
+
# `12.0.0-beta.1`, belongs to no line.
|
|
89
|
+
def line
|
|
90
|
+
return if @version.to_s.match?(/\A\d+(?:\.\d+)*-/)
|
|
91
|
+
|
|
92
|
+
version = Gem::Version.new(@version.to_s[/\A\d+(?:\.\d+)*/] || '0')
|
|
93
|
+
CAPABILITIES.dig('managers', @name, 'lines').find do |line|
|
|
94
|
+
Gem::Requirement.new(*line['range'].split(',').map(&:strip)).satisfied_by?(version)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A warning when the installed line's owner stops maintaining it within six months: the
|
|
99
|
+
# next Proscenium release may no longer support it.
|
|
100
|
+
def end_of_life_warning(today: Date.today)
|
|
101
|
+
eol = line && line['eol']
|
|
102
|
+
return nil unless eol && Date.parse(eol) - today <= 183
|
|
103
|
+
|
|
104
|
+
Error.new('PSM-W-END-OF-LIFE', manager: @name, version: @version, eol:)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def supported_ranges
|
|
108
|
+
CAPABILITIES.dig('managers', @name, 'lines').map { it['range'] }.join('; ')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A `.cmd` or `.bat` shim, as npm installs pnpm, runs under cmd.exe, which cannot use a UNC
|
|
112
|
+
# path as its working directory: it falls back to C:\Windows and runs the manager there
|
|
113
|
+
# (C27). So a project reached through a UNC path needs a native executable or a drive.
|
|
114
|
+
def check_launchable!
|
|
115
|
+
return unless @executable.match?(/\.(?:cmd|bat)\z/i) && self.class.unc?(@root)
|
|
116
|
+
|
|
117
|
+
raise Error.new('PSM-E-UNC-SHIM', manager: @name, root: @root)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The version a `--version` run printed. Only stdout, and its last line that is one: the first
|
|
121
|
+
# run of a pinned manager also prints that it is downloading it.
|
|
122
|
+
def self.version_in(output)
|
|
123
|
+
output.lines.map(&:strip).grep(/\A\d+\.\d+\.\d+\S*\z/).last || output.strip
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The version package.json's packageManager pins for this manager, or nil. pnpm runs exactly
|
|
127
|
+
# that version or refuses to run, so it is the one install gets, and reading it runs nothing:
|
|
128
|
+
# pnpm 12 writes pnpm-lock.yaml on any command in a pinned project, `--version` included, so
|
|
129
|
+
# probing would change the project before install could refuse anything.
|
|
130
|
+
def pinned_version
|
|
131
|
+
path = File.join(@root, 'package.json')
|
|
132
|
+
field = File.exist?(path) && ContextMap.read_json(path)['packageManager']
|
|
133
|
+
return unless field.is_a?(String)
|
|
134
|
+
|
|
135
|
+
name, version = field.split('+').first.split('@', 2)
|
|
136
|
+
version if name == @name && version
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Whether pnpm runs only the version packageManager pins: it downloads it (pmOnFail
|
|
142
|
+
# `download`, the default) or refuses to run (`error`). With `warn` or `ignore`, from the
|
|
143
|
+
# environment or pnpm-workspace.yaml, it runs whatever is installed, and probing it writes
|
|
144
|
+
# nothing.
|
|
145
|
+
def runs_pin?
|
|
146
|
+
setting = PnpmApprovals.env('pm_on_fail') || workspace_setting('pmOnFail')
|
|
147
|
+
!%w[warn ignore].include?(setting)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def workspace_setting(key)
|
|
151
|
+
path = File.join(@root, 'pnpm-workspace.yaml')
|
|
152
|
+
return unless File.exist?(path)
|
|
153
|
+
|
|
154
|
+
text = ContextMap.utf8(File.read(path)).delete_prefix(ContextMap::BOM)
|
|
155
|
+
value, = YamlTree.value(text, key)
|
|
156
|
+
value.value if value.is_a?(Psych::Nodes::Scalar)
|
|
157
|
+
rescue Psych::SyntaxError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def probed_version
|
|
162
|
+
out, status = Bundler.with_unbundled_env do
|
|
163
|
+
Open3.capture2(@executable, '--version', chdir: @root)
|
|
164
|
+
end
|
|
165
|
+
raise Error.new('PSM-E-MANAGER-MISSING', manager: @name) unless status.success?
|
|
166
|
+
|
|
167
|
+
self.class.version_in(out)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.unc?(path) = path.start_with?('//', '\\\\')
|
|
171
|
+
|
|
172
|
+
# Bun registers contexts only in a project that has chosen its linker explicitly: without
|
|
173
|
+
# one, registering a workspace can switch the app to another linker. A missing linker is
|
|
174
|
+
# written by registration (BunLinker), so only `frozen`, which writes nothing, refuses it;
|
|
175
|
+
# an unknown one is always refused.
|
|
176
|
+
def check_project!(frozen: false)
|
|
177
|
+
check_not_nested!
|
|
178
|
+
return self unless @name == 'bun'
|
|
179
|
+
|
|
180
|
+
# Registration adds an [install] table, which Bun refuses beside one already set inline.
|
|
181
|
+
linker = bun_linker
|
|
182
|
+
refused = linker ? !LINKERS.include?(linker) : frozen || inline_install?
|
|
183
|
+
raise Error, 'PSM-E-BUN-LINKER' if refused
|
|
184
|
+
|
|
185
|
+
package_json
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The app's package.json, or nil. Bun reads a trailing comma that Ruby refuses, and so would
|
|
190
|
+
# registration, so a file that is not strict JSON is refused here, before any write.
|
|
191
|
+
def package_json
|
|
192
|
+
path = File.join(@root, 'package.json')
|
|
193
|
+
File.exist?(path) ? ContextMap.read_json(path) : nil
|
|
194
|
+
rescue JSON::ParserError
|
|
195
|
+
raise Error.new('PSM-E-REGISTRATION', file: 'package.json')
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The names whose install scripts the manager runs. pnpm: those pnpm-workspace.yaml approves
|
|
199
|
+
# builds for, or :all when it allows every build. Bun: package.json's trustedDependencies, or
|
|
200
|
+
# without one, Bun's own default list, as the installed Bun prints it.
|
|
201
|
+
def trusted_names
|
|
202
|
+
if @name == 'pnpm'
|
|
203
|
+
return PnpmApprovals.allow_all?(@root) ? :all : PnpmApprovals.names(@root)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
package = package_json
|
|
207
|
+
listed = package['trustedDependencies'] if package.is_a?(Hash)
|
|
208
|
+
return listed.grep(String) if listed.is_a?(Array)
|
|
209
|
+
|
|
210
|
+
out, status = Bundler.with_unbundled_env do
|
|
211
|
+
Open3.capture2(@executable, 'pm', 'default-trusted', chdir: @root)
|
|
212
|
+
end
|
|
213
|
+
names = status.success? ? out.lines.filter_map { it[/\A - (\S+)\s*\z/, 1] } : []
|
|
214
|
+
# An empty list would let a gem reach any default-trusted name, so it is refused.
|
|
215
|
+
raise Error, 'PSM-E-BUN-DEFAULT-TRUSTED' if names.empty?
|
|
216
|
+
|
|
217
|
+
names
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# bunfig.toml's `install` table name, bare or quoted.
|
|
221
|
+
INSTALL = /(?:install|"install"|'install')/
|
|
222
|
+
LINKER = /(?:linker|"linker"|'linker')\s*=\s*["']([^"']*)["']/
|
|
223
|
+
TABLE_LINKER = /\A#{LINKER}\z/
|
|
224
|
+
# Before any table: `install.linker = "…"`, or `install = { …, linker = "…" }`.
|
|
225
|
+
TOP_LEVEL_LINKER = /\A#{INSTALL}\s*(?:\.\s*#{LINKER}\z|=\s*\{(?:.*,)?\s*#{LINKER}\s*[,}])/
|
|
226
|
+
INLINE_INSTALL = /\A#{INSTALL}\s*[.=]/
|
|
227
|
+
|
|
228
|
+
# The `linker` bunfig.toml sets for Bun's `install` table, or nil.
|
|
229
|
+
def bun_linker
|
|
230
|
+
table = nil
|
|
231
|
+
bunfig_lines.each do |line|
|
|
232
|
+
if (header = line[/\A\[\s*([^\]]+?)\s*\]\z/, 1])
|
|
233
|
+
table = header.delete(%q('"))
|
|
234
|
+
elsif (value = linker_on(line, table))
|
|
235
|
+
return value
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def linker_on(line, table)
|
|
242
|
+
return line[TABLE_LINKER, 1] if table == 'install'
|
|
243
|
+
|
|
244
|
+
match = TOP_LEVEL_LINKER.match(line) unless table
|
|
245
|
+
match && (match[1] || match[2])
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Whether bunfig.toml sets the install table with a dotted key or inline, before any table.
|
|
249
|
+
def inline_install?
|
|
250
|
+
bunfig_lines.take_while { !it.start_with?('[') }.any? { it.match?(INLINE_INSTALL) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# A TOML comment: a # outside a basic ("…", with escapes) or literal ('…') string.
|
|
254
|
+
COMMENT = /\A((?:[^#"']|"(?:[^"\\]|\\.)*"|'[^']*')*)#.*/
|
|
255
|
+
|
|
256
|
+
# bunfig.toml's lines without comments or blank lines; none when there is no bunfig.toml.
|
|
257
|
+
def bunfig_lines
|
|
258
|
+
path = File.join(@root, 'bunfig.toml')
|
|
259
|
+
return [] unless File.exist?(path)
|
|
260
|
+
|
|
261
|
+
File.readlines(path).map { it.sub(COMMENT, '\\1').strip }.reject(&:empty?)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A Rails app inside an enclosing JS workspace is out of v1: its contexts would belong to
|
|
265
|
+
# the enclosing workspace's install, not the app's.
|
|
266
|
+
def check_not_nested!
|
|
267
|
+
root = File.expand_path(@root)
|
|
268
|
+
dir = File.dirname(root)
|
|
269
|
+
until dir == File.dirname(dir)
|
|
270
|
+
if File.exist?(File.join(dir, 'pnpm-workspace.yaml')) || member?(dir, root)
|
|
271
|
+
raise Error.new('PSM-E-NESTED-WORKSPACE', enclosing: dir)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
dir = File.dirname(dir)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Whether the package.json in `dir` lists `root` among its workspaces. Bun installs an app
|
|
279
|
+
# its patterns do not match as a project of its own (probed on Bun 1.4.2). `*` crosses `/`
|
|
280
|
+
# here, so at worst an app that is not a member is refused.
|
|
281
|
+
def member?(dir, root)
|
|
282
|
+
path = File.join(dir, 'package.json')
|
|
283
|
+
package = File.exist?(path) && ContextMap.read_json(path)
|
|
284
|
+
return false unless package.is_a?(Hash)
|
|
285
|
+
|
|
286
|
+
workspaces = package['workspaces']
|
|
287
|
+
patterns = Array(workspaces.is_a?(Hash) ? workspaces['packages'] : workspaces).grep(String)
|
|
288
|
+
excluded, included = patterns.map { it.delete_prefix('./').chomp('/') }
|
|
289
|
+
.partition { it.start_with?('!') }
|
|
290
|
+
relative = root.delete_prefix("#{dir}/")
|
|
291
|
+
included.any? { glob?(it, relative) } && excluded.none? { glob?(it[1..], relative) }
|
|
292
|
+
rescue JSON::ParserError
|
|
293
|
+
# Bun reads a trailing comma Ruby refuses: one that may declare workspaces may hold the app.
|
|
294
|
+
AppPackage.unescape(File.read(path)).include?('"workspaces"')
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Whether Bun's glob `pattern` matches `path`. FNM_EXTGLOB for braces, which Bun reads; and
|
|
298
|
+
# each `**/` either way, as Bun's also matches no directory, which Ruby's does not.
|
|
299
|
+
def glob?(pattern, path) = globs(pattern).any? { File.fnmatch?(it, path, File::FNM_EXTGLOB) }
|
|
300
|
+
|
|
301
|
+
# `pattern` with each of its `**/` kept or dropped.
|
|
302
|
+
def globs(pattern)
|
|
303
|
+
head, star, tail = pattern.partition('**/')
|
|
304
|
+
return [pattern] if star.empty?
|
|
305
|
+
|
|
306
|
+
globs(tail).flat_map { ["#{head}#{star}#{it}", "#{head}#{it}"] }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# An executable on PATH, honoring PATHEXT on Windows (so `pnpm.cmd` is found), or nil.
|
|
310
|
+
def self.which(command)
|
|
311
|
+
exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';').map(&:downcase) : ['']
|
|
312
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
|
|
313
|
+
exts.each do |ext|
|
|
314
|
+
# Absolute: the manager is spawned with the project as its working directory.
|
|
315
|
+
path = File.expand_path(File.join(dir, "#{command}#{ext}"))
|
|
316
|
+
return path if File.file?(path) && File.executable?(path)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
nil
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../bundled_gems'
|
|
5
|
+
require_relative '../context_map'
|
|
6
|
+
require_relative '../dependency_context'
|
|
7
|
+
require_relative 'gem_archive'
|
|
8
|
+
require_relative 'rules'
|
|
9
|
+
|
|
10
|
+
module Proscenium
|
|
11
|
+
module CLI
|
|
12
|
+
# A gem's platform variants must agree on JavaScript dependencies (#154, C33). The lock records
|
|
13
|
+
# one set of dependencies per gem, whichever variant a host installs, so variants that
|
|
14
|
+
# participate differently, or project different dependencies, are unsupported until a
|
|
15
|
+
# profile-aware lock design exists. The other variants are read from the Bundler cache when
|
|
16
|
+
# they are there; their frontend files may differ freely.
|
|
17
|
+
module PlatformVariants
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Each [gem, variant archive name] in `cache` that differs from the installed variant in
|
|
21
|
+
# `specs`. `overrides`, from the app's package.json `proscenium` key, decide participation
|
|
22
|
+
# over each gemspec.
|
|
23
|
+
def differences(cache, specs, overrides: {})
|
|
24
|
+
return [] unless Dir.exist?(cache)
|
|
25
|
+
|
|
26
|
+
specs.flat_map do |spec|
|
|
27
|
+
variants(cache, spec).filter_map do |file|
|
|
28
|
+
variant, manifest, = GemArchive.read(file)
|
|
29
|
+
next unless variant.name == spec.name && variant.full_name != spec.full_name
|
|
30
|
+
|
|
31
|
+
[spec.name, File.basename(file)] if differs?(spec, variant, manifest, overrides)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def variants(cache, spec)
|
|
37
|
+
Dir.glob(["#{spec.name}-#{spec.version}.gem", "#{spec.name}-#{spec.version}-*.gem"],
|
|
38
|
+
base: cache).map { File.join(cache, it) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def differs?(spec, variant, manifest, overrides)
|
|
42
|
+
participates = overrides.fetch(spec.name) { BundledGems.opted_in?(spec) }
|
|
43
|
+
return true if participates != overrides.fetch(spec.name) { BundledGems.opted_in?(variant) }
|
|
44
|
+
return false unless participates
|
|
45
|
+
|
|
46
|
+
projection(spec.name, installed_manifest(spec)) != projection(spec.name, manifest) ||
|
|
47
|
+
refused?(spec.name, variant, manifest)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether the author contract refuses the variant, as it would on the host that installs it:
|
|
51
|
+
# the projection drops scripts and workspaces, so equal projections do not show it.
|
|
52
|
+
def refused?(gem, variant, text)
|
|
53
|
+
manifest = ContextMap.parse_json(text)
|
|
54
|
+
return true unless manifest.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
# A host that installs it refuses a frontend root outside the gem.
|
|
57
|
+
root = BundledGems.frontend_root(variant) or return true
|
|
58
|
+
gyp = root.empty? ? 'binding.gyp' : File.join(root, 'binding.gyp')
|
|
59
|
+
Rules.check(gem, manifest, binding_gyp: variant.files.include?(gyp))
|
|
60
|
+
.any? { !Rules::WARNINGS.include?(it.first) }
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def installed_manifest(spec)
|
|
66
|
+
root = BundledGems.manifest_root(spec)
|
|
67
|
+
path = root && File.join(root, 'package.json')
|
|
68
|
+
path && File.file?(path) ? File.read(path) : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The projection hash of a manifest's text, or nil for none that can be read.
|
|
72
|
+
def projection(gem, text)
|
|
73
|
+
manifest = text && ContextMap.parse_json(text)
|
|
74
|
+
return nil unless manifest.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
DependencyContext.project(gem, manifest).dig('proscenium', 'projectionSha256')
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../context_map'
|
|
4
|
+
require_relative '../yaml_tree'
|
|
5
|
+
|
|
6
|
+
module Proscenium
|
|
7
|
+
module CLI
|
|
8
|
+
# The package names pnpm-workspace.yaml approves builds for (#154): allowBuilds' keys set to
|
|
9
|
+
# true, a version dropped (`name@1.0.0`), and the older onlyBuiltDependencies. Read as pnpm
|
|
10
|
+
# reads it, aliases and merge keys included, from the parse tree.
|
|
11
|
+
module PnpmApprovals
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# A pnpm setting from the environment: `PNPM_CONFIG_<KEY>`, which pnpm 11 and 12 read ahead
|
|
15
|
+
# of `pnpm_config_<key>`, or nil.
|
|
16
|
+
def env(key)
|
|
17
|
+
ENV.fetch("PNPM_CONFIG_#{key.upcase}") { ENV.fetch("pnpm_config_#{key}", nil) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Whether pnpm runs every package's build: dangerouslyAllowAllBuilds, which pnpm 11 and 12
|
|
21
|
+
# read from the environment, pnpm-workspace.yaml and pnpm's global config.yaml. Any of them
|
|
22
|
+
# saying true is enough.
|
|
23
|
+
def allow_all?(root)
|
|
24
|
+
env = %w[PNPM_CONFIG_DANGEROUSLY_ALLOW_ALL_BUILDS pnpm_config_dangerously_allow_all_builds]
|
|
25
|
+
return true if env.any? { ENV.fetch(it, nil) == 'true' }
|
|
26
|
+
|
|
27
|
+
[File.join(root, 'pnpm-workspace.yaml'), global_config].any? do |path|
|
|
28
|
+
setting?(path, 'dangerouslyAllowAllBuilds')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# pnpm's global config.yaml: under $XDG_CONFIG_HOME/pnpm, else ~/Library/Preferences/pnpm on
|
|
33
|
+
# macOS (both probed), ~/.config/pnpm on Linux and %LOCALAPPDATA%\pnpm\config on Windows
|
|
34
|
+
# (as pnpm documents them).
|
|
35
|
+
def global_config
|
|
36
|
+
xdg = ENV.fetch('XDG_CONFIG_HOME', nil)
|
|
37
|
+
return File.join(xdg, 'pnpm', 'config.yaml') if xdg
|
|
38
|
+
if Gem.win_platform?
|
|
39
|
+
return File.join(ENV.fetch('LOCALAPPDATA', Dir.home), 'pnpm', 'config', 'config.yaml')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
dir = RUBY_PLATFORM.include?('darwin') ? 'Library/Preferences/pnpm' : '.config/pnpm'
|
|
43
|
+
File.join(Dir.home, dir, 'config.yaml')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whether the YAML file at `path` sets `key` to true.
|
|
47
|
+
def setting?(path, key)
|
|
48
|
+
return false unless File.exist?(path)
|
|
49
|
+
|
|
50
|
+
text = ContextMap.utf8(File.read(path)).delete_prefix(ContextMap::BOM)
|
|
51
|
+
value, anchors = YamlTree.value(text, key)
|
|
52
|
+
true?(YamlTree.node(value, anchors))
|
|
53
|
+
rescue Psych::SyntaxError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def names(root)
|
|
58
|
+
path = File.join(root, 'pnpm-workspace.yaml')
|
|
59
|
+
return [] unless File.exist?(path)
|
|
60
|
+
|
|
61
|
+
text = ContextMap.utf8(File.read(path)).delete_prefix(ContextMap::BOM)
|
|
62
|
+
allowed, anchors = YamlTree.value(text, 'allowBuilds')
|
|
63
|
+
listed, = YamlTree.value(text, 'onlyBuiltDependencies')
|
|
64
|
+
node = ->(given) { YamlTree.node(given, anchors) }
|
|
65
|
+
names = approvals(allowed, node).select { |_, approved| approved }.keys
|
|
66
|
+
listed = listed.is_a?(Psych::Nodes::Sequence) ? listed.children.map(&node) : []
|
|
67
|
+
names += listed.grep(Psych::Nodes::Scalar).map(&:value)
|
|
68
|
+
# The package name alone: an opaque locator (`foo@https://host/pkg@1.0.0`) approves `foo`.
|
|
69
|
+
names.map { it.sub(/(?<=.)@.*\z/m, '') }.uniq
|
|
70
|
+
rescue Psych::SyntaxError
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Name => whether approved, for a mapping. A merge key (`<<`) brings in its mapping's, or
|
|
75
|
+
# mappings', entries; the mapping's own keys override them, and an earlier merged mapping
|
|
76
|
+
# overrides a later one, as YAML merges. A mapping already being merged is a cycle, which
|
|
77
|
+
# ends there.
|
|
78
|
+
def approvals(mapping, node, merging = [])
|
|
79
|
+
return {} unless mapping.is_a?(Psych::Nodes::Mapping)
|
|
80
|
+
return {} if merging.any? { it.equal?(mapping) }
|
|
81
|
+
|
|
82
|
+
own = {}
|
|
83
|
+
merged = {}
|
|
84
|
+
mapping.children.each_slice(2) do |key, value|
|
|
85
|
+
key = node.call(key)
|
|
86
|
+
value = node.call(value)
|
|
87
|
+
if scalar?(key, '<<')
|
|
88
|
+
sources = value.is_a?(Psych::Nodes::Sequence) ? value.children.map(&node) : [value]
|
|
89
|
+
sources.each { merged = approvals(it, node, [*merging, mapping]).merge(merged) }
|
|
90
|
+
elsif scalar?(key)
|
|
91
|
+
own[key.value] = true?(value)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
merged.merge(own)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# YAML's true in the core schema pnpm reads: `true`, `True` or `TRUE`, unquoted.
|
|
98
|
+
def true?(node)
|
|
99
|
+
node.is_a?(Psych::Nodes::Scalar) && !node.quoted && TRUE_VALUES.include?(node.value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
TRUE_VALUES = %w[true True TRUE].freeze
|
|
103
|
+
|
|
104
|
+
def scalar?(node, value = nil)
|
|
105
|
+
node.is_a?(Psych::Nodes::Scalar) && (value.nil? || node.value == value)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative '../context_map'
|
|
5
|
+
require_relative 'brackets'
|
|
6
|
+
|
|
7
|
+
module Proscenium
|
|
8
|
+
module CLI
|
|
9
|
+
# Registers the contexts in pnpm-workspace.yaml (#154), as a textual splice so the file's other
|
|
10
|
+
# settings, order and comments survive.
|
|
11
|
+
module PnpmWorkspace
|
|
12
|
+
# The `packages` key, plain or quoted, opening a flow list or a block, where root_pair finds
|
|
13
|
+
# it, with the list's anchor if it has one.
|
|
14
|
+
KEY = /(?:packages|'packages'|"packages")/
|
|
15
|
+
ANCHOR = /(?:&[^\s\[\]{},]+[ \t]*)?/
|
|
16
|
+
FLOW = /\G#{KEY}[ \t]*:[ \t]*#{ANCHOR}\[/
|
|
17
|
+
BLOCK = /\G#{KEY}[ \t]*:[ \t]*#{ANCHOR}(#.*?)?\r?\n/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Adds the pattern to `packages`, in the list's own style: a flow list, or a block list at
|
|
22
|
+
# whatever indentation its items use. The result is parsed back; anything but the old list
|
|
23
|
+
# plus the pattern, with every other key unchanged, is refused rather than written.
|
|
24
|
+
def splice(text)
|
|
25
|
+
return text if ContextMap.registered_in?('pnpm-workspace.yaml', text, strict: true)
|
|
26
|
+
|
|
27
|
+
text = ContextMap.utf8(text)
|
|
28
|
+
bom = text.start_with?(ContextMap::BOM) ? ContextMap::BOM : ''
|
|
29
|
+
body = text.delete_prefix(bom)
|
|
30
|
+
spliced = splice_body(body)
|
|
31
|
+
verify(body, spliced)
|
|
32
|
+
if ContextMap.excludes?(ContextMap.yaml_packages(spliced))
|
|
33
|
+
raise Error.new('PSM-E-REGISTRATION-EXCLUDED', file: 'pnpm-workspace.yaml')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
"#{bom}#{spliced}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def splice_body(text)
|
|
40
|
+
eol = text.include?("\r\n") ? "\r\n" : "\n"
|
|
41
|
+
key, value = root_pair(text)
|
|
42
|
+
at = key ? offset(text, key) : text.length # past the end, neither matches
|
|
43
|
+
# Adding to an anchored list adds to every alias of it too.
|
|
44
|
+
anchor = value.anchor if value.is_a?(Psych::Nodes::Sequence)
|
|
45
|
+
raise refused if anchor && aliased?(text, anchor)
|
|
46
|
+
|
|
47
|
+
if value.is_a?(Psych::Nodes::Alias)
|
|
48
|
+
spell_out(text, value)
|
|
49
|
+
elsif (flow = text.match(FLOW, at))
|
|
50
|
+
close = Brackets.array_end(text, flow.end(0)) or raise refused
|
|
51
|
+
insert_into_flow(text, flow.end(0), text[flow.end(0)...close], eol)
|
|
52
|
+
elsif (key = text.match(BLOCK, at))
|
|
53
|
+
indent = text[key.end(0)..][/\A(?:[ \t]*(?:#.*)?\r?\n)*([ \t]*)- /, 1] || ' '
|
|
54
|
+
text.dup.insert(key.end(0), "#{indent}- #{ContextMap::REGISTRATION}#{eol}")
|
|
55
|
+
else
|
|
56
|
+
# A new key would override a list inherited through a merge key (`<<: *defaults`).
|
|
57
|
+
raise refused if merges?(text)
|
|
58
|
+
|
|
59
|
+
"packages:#{eol} - #{ContextMap::REGISTRATION}#{eol}#{eol unless text.empty?}#{text}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def merges?(text)
|
|
64
|
+
root = Psych.parse_stream(text).children.first&.root
|
|
65
|
+
root.is_a?(Psych::Nodes::Mapping) &&
|
|
66
|
+
root.children.each_slice(2).any? { |key, _| key.is_a?(Psych::Nodes::Scalar) && key.value == '<<' }
|
|
67
|
+
rescue Psych::SyntaxError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The root mapping's `packages` key and its value, at any indentation, or nil. The last pair,
|
|
72
|
+
# as pnpm and ContextMap read it; another mapping's `packages` key is not it.
|
|
73
|
+
def root_pair(text)
|
|
74
|
+
root = Psych.parse_stream(text).children.first&.root
|
|
75
|
+
return unless root.is_a?(Psych::Nodes::Mapping)
|
|
76
|
+
|
|
77
|
+
root.children.each_slice(2).reverse_each
|
|
78
|
+
.find { |key, _| key.is_a?(Psych::Nodes::Scalar) && key.value == 'packages' }
|
|
79
|
+
rescue Psych::SyntaxError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def aliased?(text, anchor)
|
|
84
|
+
Psych.parse_stream(text).each.any? { it.is_a?(Psych::Nodes::Alias) && it.anchor == anchor }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def offset(text, node) = text.lines.first(node.start_line).sum(&:length) + node.start_column
|
|
88
|
+
|
|
89
|
+
# `packages: *list` as a flow list of the entries it names plus the pattern, so the anchored
|
|
90
|
+
# list stays as it is for anything else that uses it.
|
|
91
|
+
def spell_out(text, node)
|
|
92
|
+
entries = ContextMap.yaml_packages(text)
|
|
93
|
+
raise refused unless entries&.all?(String)
|
|
94
|
+
|
|
95
|
+
list = (entries + [ContextMap::REGISTRATION]).map { "'#{it.gsub("'", "''")}'" }.join(', ')
|
|
96
|
+
start = offset(text, node)
|
|
97
|
+
"#{text[0...start]}[#{list}]#{text[(start + node.end_column - node.start_column)..]}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Appends the pattern as the flow list's last item, on its own line when the items are, and
|
|
101
|
+
# keeps a trailing comma.
|
|
102
|
+
def insert_into_flow(text, start, body, eol)
|
|
103
|
+
list = body.rstrip
|
|
104
|
+
comma = list.end_with?(',')
|
|
105
|
+
indent = body[/\n([ \t]*)\S/, 1]
|
|
106
|
+
separator = if list.empty? then ''
|
|
107
|
+
elsif indent then "#{',' unless comma}#{eol}#{indent}"
|
|
108
|
+
else comma ? ' ' : ', '
|
|
109
|
+
end
|
|
110
|
+
item = "'#{ContextMap::REGISTRATION}'#{',' if comma}"
|
|
111
|
+
text.dup.insert(start + list.length, "#{separator}#{item}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The `packages` list of a document that is empty or a mapping, as ContextMap reads it.
|
|
115
|
+
def packages(text)
|
|
116
|
+
root = Psych.parse_stream(text).children.first&.root
|
|
117
|
+
raise refused unless root.nil? || root.is_a?(Psych::Nodes::Mapping)
|
|
118
|
+
|
|
119
|
+
ContextMap.yaml_packages(text)
|
|
120
|
+
rescue Psych::SyntaxError
|
|
121
|
+
raise refused
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The splice must add the pattern to `packages` and change nothing else.
|
|
125
|
+
def verify(text, spliced)
|
|
126
|
+
before = packages(text)
|
|
127
|
+
after = packages(spliced)
|
|
128
|
+
expected = Array(before) + [ContextMap::REGISTRATION]
|
|
129
|
+
same = after.is_a?(Array) && after.tally == expected.tally
|
|
130
|
+
raise refused unless same && others(spliced) == others(text)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The document without its `packages` key, as YAML with aliases left unexpanded: comparing
|
|
134
|
+
# expanded values could take forever on aliases that nest.
|
|
135
|
+
def others(text)
|
|
136
|
+
stream = Psych.parse_stream(text)
|
|
137
|
+
mapping = stream.children.first&.root
|
|
138
|
+
return '' unless mapping.is_a?(Psych::Nodes::Mapping)
|
|
139
|
+
|
|
140
|
+
mapping.children.replace(mapping.children.each_slice(2).reject do |key, _|
|
|
141
|
+
key.is_a?(Psych::Nodes::Scalar) && key.value == 'packages'
|
|
142
|
+
end.flatten)
|
|
143
|
+
mapping.children.empty? ? '' : stream.to_yaml
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def refused = Error.new('PSM-E-REGISTRATION', file: 'pnpm-workspace.yaml')
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|