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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +1020 -0
  5. data/exe/proscenium +19 -0
  6. data/exe/psm +5 -0
  7. data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
  8. data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
  9. data/lib/proscenium/app_package.rb +90 -0
  10. data/lib/proscenium/builder.rb +403 -0
  11. data/lib/proscenium/bundled_gems.rb +140 -0
  12. data/lib/proscenium/cli/brackets.rb +66 -0
  13. data/lib/proscenium/cli/bun_linker.rb +79 -0
  14. data/lib/proscenium/cli/capabilities.json +29 -0
  15. data/lib/proscenium/cli/collisions.rb +94 -0
  16. data/lib/proscenium/cli/contexts.rb +133 -0
  17. data/lib/proscenium/cli/diff.rb +80 -0
  18. data/lib/proscenium/cli/error.rb +291 -0
  19. data/lib/proscenium/cli/gem_archive.rb +44 -0
  20. data/lib/proscenium/cli/gem_check.rb +150 -0
  21. data/lib/proscenium/cli/gitignore.rb +63 -0
  22. data/lib/proscenium/cli/inspect.rb +257 -0
  23. data/lib/proscenium/cli/install.rb +378 -0
  24. data/lib/proscenium/cli/manager.rb +323 -0
  25. data/lib/proscenium/cli/platform_variants.rb +82 -0
  26. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  27. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  28. data/lib/proscenium/cli/project_lock.rb +106 -0
  29. data/lib/proscenium/cli/registration.rb +140 -0
  30. data/lib/proscenium/cli/reporter.rb +103 -0
  31. data/lib/proscenium/cli/rules.rb +169 -0
  32. data/lib/proscenium/cli/runner.rb +142 -0
  33. data/lib/proscenium/cli/verify.rb +107 -0
  34. data/lib/proscenium/cli.rb +122 -0
  35. data/lib/proscenium/context_map.rb +181 -0
  36. data/lib/proscenium/css_module/path.rb +32 -0
  37. data/lib/proscenium/css_module/transformer.rb +118 -0
  38. data/lib/proscenium/css_module.rb +63 -0
  39. data/lib/proscenium/dependency_context.rb +74 -0
  40. data/lib/proscenium/ensure_loaded.rb +31 -0
  41. data/lib/proscenium/error.rb +10 -0
  42. data/lib/proscenium/ext/proscenium +0 -0
  43. data/lib/proscenium/ext/proscenium.h +113 -0
  44. data/lib/proscenium/helper.rb +82 -0
  45. data/lib/proscenium/importer.rb +189 -0
  46. data/lib/proscenium/log_subscriber.rb +38 -0
  47. data/lib/proscenium/manifest.rb +48 -0
  48. data/lib/proscenium/mapping_generation.rb +64 -0
  49. data/lib/proscenium/middleware/base.rb +102 -0
  50. data/lib/proscenium/middleware/chunks.rb +46 -0
  51. data/lib/proscenium/middleware/esbuild.rb +11 -0
  52. data/lib/proscenium/middleware/ruby_gems.rb +37 -0
  53. data/lib/proscenium/middleware/silence_request.rb +31 -0
  54. data/lib/proscenium/middleware/vendor.rb +31 -0
  55. data/lib/proscenium/middleware.rb +67 -0
  56. data/lib/proscenium/monkey.rb +77 -0
  57. data/lib/proscenium/railtie.rb +106 -0
  58. data/lib/proscenium/railties/assets.rake +19 -0
  59. data/lib/proscenium/resolver.rb +67 -0
  60. data/lib/proscenium/runtime/bootstrap.js +266 -0
  61. data/lib/proscenium/runtime/bun.js +143 -0
  62. data/lib/proscenium/runtime/server.rb +702 -0
  63. data/lib/proscenium/side_load.rb +210 -0
  64. data/lib/proscenium/source_path.rb +15 -0
  65. data/lib/proscenium/stale_contexts.rb +118 -0
  66. data/lib/proscenium/templates/rescues/build_error.html.erb +32 -0
  67. data/lib/proscenium/utils.rb +104 -0
  68. data/lib/proscenium/version.rb +5 -0
  69. data/lib/proscenium/yaml_tree.rb +48 -0
  70. data/lib/proscenium.rb +87 -0
  71. 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