proscenium 0.25.3 → 0.26.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +170 -23
  3. data/exe/proscenium +19 -0
  4. data/exe/psm +5 -0
  5. data/lib/proscenium/app_package.rb +90 -0
  6. data/lib/proscenium/builder.rb +221 -20
  7. data/lib/proscenium/bundled_gems.rb +103 -0
  8. data/lib/proscenium/cli/brackets.rb +66 -0
  9. data/lib/proscenium/cli/bun_linker.rb +79 -0
  10. data/lib/proscenium/cli/capabilities.json +29 -0
  11. data/lib/proscenium/cli/collisions.rb +94 -0
  12. data/lib/proscenium/cli/contexts.rb +133 -0
  13. data/lib/proscenium/cli/diff.rb +80 -0
  14. data/lib/proscenium/cli/error.rb +291 -0
  15. data/lib/proscenium/cli/gem_archive.rb +44 -0
  16. data/lib/proscenium/cli/gem_check.rb +150 -0
  17. data/lib/proscenium/cli/gitignore.rb +63 -0
  18. data/lib/proscenium/cli/inspect.rb +257 -0
  19. data/lib/proscenium/cli/install.rb +378 -0
  20. data/lib/proscenium/cli/manager.rb +323 -0
  21. data/lib/proscenium/cli/platform_variants.rb +82 -0
  22. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  23. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  24. data/lib/proscenium/cli/project_lock.rb +106 -0
  25. data/lib/proscenium/cli/registration.rb +140 -0
  26. data/lib/proscenium/cli/reporter.rb +103 -0
  27. data/lib/proscenium/cli/rules.rb +169 -0
  28. data/lib/proscenium/cli/runner.rb +142 -0
  29. data/lib/proscenium/cli/verify.rb +107 -0
  30. data/lib/proscenium/cli.rb +122 -0
  31. data/lib/proscenium/context_map.rb +181 -0
  32. data/lib/proscenium/css_module/transformer.rb +18 -3
  33. data/lib/proscenium/css_module.rb +5 -6
  34. data/lib/proscenium/dependency_context.rb +74 -0
  35. data/lib/proscenium/error.rb +10 -0
  36. data/lib/proscenium/helper.rb +38 -4
  37. data/lib/proscenium/importer.rb +53 -42
  38. data/lib/proscenium/manifest.rb +18 -23
  39. data/lib/proscenium/mapping_generation.rb +64 -0
  40. data/lib/proscenium/middleware/base.rb +5 -10
  41. data/lib/proscenium/middleware/chunks.rb +5 -0
  42. data/lib/proscenium/monkey.rb +51 -69
  43. data/lib/proscenium/railtie.rb +16 -0
  44. data/lib/proscenium/railties/assets.rake +2 -1
  45. data/lib/proscenium/resolver.rb +43 -11
  46. data/lib/proscenium/runtime/bootstrap.js +26 -6
  47. data/lib/proscenium/runtime/server.rb +57 -36
  48. data/lib/proscenium/side_load.rb +75 -18
  49. data/lib/proscenium/stale_contexts.rb +118 -0
  50. data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
  51. data/lib/proscenium/utils.rb +55 -0
  52. data/lib/proscenium/version.rb +1 -1
  53. data/lib/proscenium/yaml_tree.rb +48 -0
  54. data/lib/proscenium.rb +7 -4
  55. metadata +42 -13
  56. data/lib/proscenium/ext/proscenium +0 -0
  57. data/lib/proscenium/ext/proscenium.h +0 -113
  58. data/lib/proscenium/react-manager/index.jsx +0 -102
  59. data/lib/proscenium/react-manager/react.js +0 -2
  60. data/lib/proscenium/react_componentable.rb +0 -94
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'bundler'
5
+ require_relative '../bundled_gems'
6
+ require_relative '../dependency_context'
7
+ require_relative '../stale_contexts'
8
+ require_relative 'collisions'
9
+ require_relative 'contexts'
10
+ require_relative 'diff'
11
+ require_relative 'manager'
12
+ require_relative 'platform_variants'
13
+ require_relative 'project_lock'
14
+ require_relative 'registration'
15
+ require_relative 'runner'
16
+ require_relative 'verify'
17
+
18
+ module Proscenium
19
+ module CLI
20
+ # `proscenium install` and `install --frozen` (#154): the plan's installation algorithm.
21
+ class Install
22
+ # What a context directory without its package.json may hold and still be install's to remove.
23
+ OURS = %w[node_modules package.json.tmp .DS_Store].freeze
24
+
25
+ # Options: frozen, production, offline, manager (requested), experimental, js_args.
26
+ def initialize(root, reporter, **options)
27
+ @root = root
28
+ @reporter = reporter
29
+ @options = options
30
+ end
31
+
32
+ def call
33
+ @timings = {}
34
+ if defined?(PROSCENIUM_STARTED)
35
+ @timings[:load] = ms(Process.clock_gettime(Process::CLOCK_MONOTONIC) - PROSCENIUM_STARTED)
36
+ end
37
+ timed(:manager_check) do
38
+ @manager = Manager.select(@root, requested: @options[:manager])
39
+ @manager.check_version!(experimental: @options[:experimental], frozen: frozen?)
40
+ @manager.check_project!(frozen: frozen?)
41
+ end
42
+ if (warning = @manager.end_of_life_warning)
43
+ @reporter.warning(warning, phase: 'manager')
44
+ end
45
+ read_bundle
46
+ frozen? ? check_frozen : install
47
+ 0
48
+ end
49
+
50
+ # The gems whose contexts declare a package the manager's failure output names, as
51
+ # [gem, package] pairs: an age-gate refusal or a failed fetch names only the package.
52
+ def self.blame(contexts, output)
53
+ return [] unless output.match?(/ERR_|error:/i)
54
+
55
+ contexts.flat_map do |gem, json|
56
+ context = JSON.parse(json)
57
+ Rules::DEPENDENCY_FIELDS.flat_map { (context[it] || {}).keys }
58
+ .reject { it.start_with?('@rubygems/') }
59
+ .select { output.match?(named(it)) }
60
+ .map { [gem, it] }
61
+ end
62
+ end
63
+
64
+ # `name` as a whole package name, not a part of another.
65
+ def self.named(name) = %r{(?<![\w@/.-])#{Regexp.escape(name)}(?![\w/-])}
66
+
67
+ private
68
+
69
+ def frozen? = @options[:frozen]
70
+
71
+ # Milliseconds each phase took, reported with the result so an install's cost can be split
72
+ # between Proscenium and the manager (C40).
73
+ def timed(phase)
74
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
+ yield
76
+ ensure
77
+ @timings[phase] = ms(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
78
+ end
79
+
80
+ def ms(seconds) = (seconds * 1000).round
81
+
82
+ def count(...) = Reporter.count(...)
83
+
84
+ def read_bundle
85
+ overrides = BundledGems.overrides(@root)
86
+ specs = timed(:bundle) { BundledGems.installed_specs }
87
+ @excluded = BundledGems.excluded_names(specs)
88
+ @contexts = timed(:projection) do
89
+ Contexts.new(@root, BundledGems.participating(specs, overrides:))
90
+ end
91
+ @committed = Contexts.committed(@root)
92
+ check_owned_directory
93
+ report_problems
94
+ check_platform_variants(specs, overrides)
95
+ check_trusted_sources
96
+ check_collisions
97
+ rescue BundledGems::ConfigError => e
98
+ raise Error.new('PSM-E-CONFIG', detail: e.message)
99
+ end
100
+
101
+ def report_problems
102
+ @contexts.warnings.each do |code, args|
103
+ @reporter.warning(Error.new(code, **args), phase: 'validate')
104
+ end
105
+ return if @contexts.ok?
106
+
107
+ @contexts.problems.each do |code, args|
108
+ @reporter.error(Error.new(code, **args), phase: 'validate', manager: @manager.name)
109
+ end
110
+ raise Error.new('PSM-E-PROBLEMS', count: @contexts.problems.size)
111
+ end
112
+
113
+ # `.proscenium/packages/` is Proscenium's: every entry is a generated context. A package.json
114
+ # there without the generated `proscenium` block, or a folder with no package.json that holds
115
+ # more than a node_modules, is someone else's, and install refuses rather than delete it as an
116
+ # orphan.
117
+ def check_owned_directory
118
+ check_owned_links
119
+ foreign = @committed.select do |gem, path|
120
+ # Without a context, only an old context's node_modules, a write that never finished, or
121
+ # macOS's .DS_Store is ours to remove; anything else, a .git included, is the user's.
122
+ next (Dir.children(File.dirname(path)) - OURS).any? unless File.exist?(path)
123
+
124
+ !generated?(gem, ContextMap.read_json(path))
125
+ rescue JSON::ParserError
126
+ true
127
+ end
128
+ entries = foreign.keys + strays
129
+ return if entries.empty?
130
+
131
+ raise Error.new('PSM-E-OWNED-DIR', entries: entries.join(', '))
132
+ end
133
+
134
+ # Whether `package` is a context install wrote for `gem`: its generated name, and a
135
+ # `proscenium` block naming a projection. A `proscenium` key alone is not enough, or install
136
+ # would remove someone else's package as an orphan.
137
+ def generated?(gem, package)
138
+ package.is_a?(Hash) && package['name'] == DependencyContext.name_for(gem) &&
139
+ package.dig('proscenium', 'projection').is_a?(String)
140
+ rescue TypeError
141
+ false
142
+ end
143
+
144
+ # A file, or a link leading nowhere, where only context directories belong.
145
+ def strays
146
+ dir = File.join(@root, Contexts::DIR)
147
+ return [] unless Dir.exist?(dir)
148
+
149
+ Dir.children(dir).reject { File.directory?(File.join(dir, it)) } - OURS
150
+ end
151
+
152
+ # Everything install writes is under .proscenium/ (C29). A link anywhere on the way, or in
153
+ # place of a context, would send those writes outside the app.
154
+ def check_owned_links
155
+ owned = ['.proscenium', Contexts::DIR] + @committed.flat_map do |gem, path|
156
+ [File.join(Contexts::DIR, gem), relative(path)]
157
+ end
158
+ links = owned.select { File.symlink?(File.join(@root, it)) }
159
+ raise Error.new('PSM-E-OWNED-LINK', paths: links.join(', ')) if links.any?
160
+ end
161
+
162
+ def check_platform_variants(specs, overrides)
163
+ found = PlatformVariants.differences(Bundler.app_cache.to_s, specs, overrides:)
164
+ return if found.empty?
165
+
166
+ raise Error.new('PSM-E-PLATFORM-VARIANT',
167
+ variants: found.map { |gem, file| "#{gem} (#{file})" }.join(', '))
168
+ end
169
+
170
+ def check_trusted_sources
171
+ manifests = @contexts.contexts.transform_values { JSON.parse(it.json) }
172
+ # Bun's trust is read only when a gem reaches some package another way.
173
+ return if Rules.substitutions(manifests).empty?
174
+
175
+ found = Rules.trusted_substitutions(@manager.trusted_names, manifests)
176
+ return if found.empty?
177
+
178
+ raise Error.new('PSM-E-TRUSTED-SOURCE', list: found.map { " - #{it}" }.join("\n"))
179
+ end
180
+
181
+ def check_collisions
182
+ found = Collisions.find(@root, @manager.name, @contexts.contexts.keys)
183
+ return if found.empty?
184
+
185
+ raise Error.new('PSM-E-COLLISION', count: found.size,
186
+ list: found.map { " - #{it}" }.join("\n"))
187
+ end
188
+
189
+ # Committed contexts no participating gem owns. A locked gem that is not installed (another
190
+ # group or platform) keeps its context: the production rule trusts it.
191
+ def orphans = @committed.keys - @contexts.contexts.keys - @excluded
192
+
193
+ def changed
194
+ @contexts.contexts.values.reject do |context|
195
+ path = @committed[context.gem]
196
+ path && File.exist?(path) && File.read(path) == context.json
197
+ end
198
+ end
199
+
200
+ def check_frozen
201
+ # The lock, not the marker: a frozen install writes nothing of Proscenium's, but its manager
202
+ # writes node_modules, so it must not run alongside another install. Drift is read and the
203
+ # tree verified under it too, so another install cannot change what is being compared.
204
+ ProjectLock.new(@root).synchronize('proscenium install --frozen') do |lock|
205
+ check_drift
206
+ timed(:manager) { run_manager(lock) }
207
+ timed(:verify) { Verify.new(@root, @manager, @contexts.contexts.keys).call }
208
+ end
209
+ headline = "Everything is up to date for #{count(@contexts.contexts.size, 'gem')}."
210
+ lines = [@reporter.paint(headline, :bold, :green), *kept]
211
+ lines.unshift('') unless @reporter.json?
212
+ @reporter.info(lines.join("\n"), event: 'frozen', timings: @timings)
213
+ end
214
+
215
+ def check_drift
216
+ @committed = Contexts.committed(@root)
217
+ drift = []
218
+ registered = Registration.registered?(@root, @manager.name)
219
+ drift << 'the gem contexts are not registered' unless registered
220
+ drift << "#{lockfile} is missing" unless File.exist?(File.join(@root, lockfile))
221
+ changed.each { drift << describe_change(it) }
222
+ orphans.each { drift << format(StaleContexts::ORPHAN, gem: it) }
223
+ return if drift.empty?
224
+
225
+ list = drift.map { " - #{it}" }.join("\n")
226
+ raise Error.new('PSM-E-DRIFT', count: drift.size, list:)
227
+ end
228
+
229
+ # A line for each committed context kept for a gem that is not installed.
230
+ def kept
231
+ (@excluded & @committed.keys).map do |gem|
232
+ " #{gem} is not installed here, so its committed dependency context was kept as it is."
233
+ end
234
+ end
235
+
236
+ def describe_change(context)
237
+ path = @committed[context.gem]
238
+ unless path && File.exist?(path)
239
+ return "#{context.gem}: its dependency context hasn't been written yet"
240
+ end
241
+
242
+ before = File.read(path)
243
+ diff = Diff.unified(relative(path), before, context.json)
244
+ committed = JSON.parse(before)['proscenium'] || {}
245
+ current = JSON.parse(context.json)['proscenium']
246
+ cause = if committed['projection'] != current['projection']
247
+ 'its dependency context was written by a different version of Proscenium'
248
+ elsif committed['projectionSha256'] == current['projectionSha256']
249
+ 'its dependency context was edited by hand'
250
+ else
251
+ 'its NPM dependencies changed since its dependency context was written'
252
+ end
253
+ "#{context.gem}: #{cause}\n#{diff}"
254
+ rescue JSON::ParserError
255
+ "#{context.gem}: its dependency context is not valid JSON"
256
+ end
257
+
258
+ def install
259
+ lock = ProjectLock.new(@root)
260
+ lock.synchronize('proscenium install') do
261
+ if lock.interrupted?
262
+ @reporter.info('Finishing an install that was stopped before it finished.',
263
+ event: 'recover')
264
+ end
265
+ # Computed before the marker: a registration it refuses has changed nothing, and must not
266
+ # leave the engine refusing every build.
267
+ edits = Registration.edits(@root, @manager.name)
268
+ lock.mark!
269
+ @written = register(edits) + write_contexts + remove_orphans
270
+ timed(:manager) { run_manager(lock) }
271
+ timed(:verify) { Verify.new(@root, @manager, @contexts.contexts.keys).call }
272
+ lock.unmark!
273
+ end
274
+ summarize
275
+ end
276
+
277
+ # The one-time registration, printed as a diff before it is written.
278
+ def register(edits)
279
+ edits.map do |path, before, after|
280
+ diff = Diff.unified(relative(path), before, after)
281
+ message = if @reporter.json? then diff
282
+ else "Updating #{relative(path)}:\n#{@reporter.paint_diff(diff)}"
283
+ end
284
+ @reporter.info(message, event: 'registration', file: relative(path))
285
+ File.write(path, after)
286
+ relative(path)
287
+ end
288
+ end
289
+
290
+ # Each changed context, written atomically; an unchanged one is left alone, so its native
291
+ # links survive.
292
+ def write_contexts
293
+ changed.map do |context|
294
+ dir = File.join(@root, Contexts::DIR, context.gem)
295
+ FileUtils.mkdir_p(dir)
296
+ path = File.join(dir, 'package.json')
297
+ ProjectLock.write("#{path}.tmp", context.json)
298
+ File.rename("#{path}.tmp", path)
299
+ relative(path)
300
+ end
301
+ end
302
+
303
+ def remove_orphans
304
+ orphans.map do |gem|
305
+ FileUtils.rm_rf(File.join(@root, Contexts::DIR, gem))
306
+ "#{Contexts::DIR}/#{gem} (removed)"
307
+ end
308
+ end
309
+
310
+ def run_manager(lock = nil)
311
+ unless @reporter.json?
312
+ @reporter.info(@reporter.paint("Running #{@manager.name} #{manager_args.join(' ')}...",
313
+ :dim))
314
+ end
315
+ Runner.run(@manager.executable, manager_args, root: @root,
316
+ on_spawn: lock&.method(:manager_started),
317
+ manager: @manager.name)
318
+ rescue Error => e
319
+ raise unless e.code == 'PSM-E-NATIVE'
320
+
321
+ blamed = self.class.blame(@contexts.contexts.transform_values(&:json), e.output.to_s)
322
+ raise if blamed.empty?
323
+
324
+ involved = blamed.map { |gem, package| "#{package}, which #{gem} brings in" }.join('; ')
325
+ error = Error.new('PSM-E-NATIVE', command: "#{@manager.name} #{manager_args.join(' ')}",
326
+ status: e.details[:exitStatus], details: e.details,
327
+ escape: "It involves #{involved}.")
328
+ raise error
329
+ ensure
330
+ lock&.manager_finished
331
+ end
332
+
333
+ def manager_args
334
+ args = ['install']
335
+ args << '--frozen-lockfile' if frozen?
336
+ args << (@manager.name == 'bun' ? '--production' : '--prod') if @options[:production]
337
+ args << '--offline' if @options[:offline]
338
+ # A gem only in an excluded group has no source to read; its context is trusted and its
339
+ # dependencies left out of a production install.
340
+ if @options[:production]
341
+ (@excluded & @committed.keys).each do |gem|
342
+ args << "--filter=!#{DependencyContext.name_for(gem)}"
343
+ end
344
+ end
345
+ args + Array(@options[:js_args])
346
+ end
347
+
348
+ def lockfile = Manager::LOCKFILES.key(@manager.name)
349
+
350
+ def relative(path) = path.delete_prefix("#{@root}/")
351
+
352
+ def summarize
353
+ contexts = @contexts.contexts.values
354
+ headline = "Installed NPM dependencies for #{count(contexts.size, 'gem')} with " \
355
+ "#{@manager.name} #{@manager.version}."
356
+ width = contexts.map { it.gem.size }.max.to_i
357
+ lines = [@reporter.paint(headline, :bold, :green)]
358
+ contexts.each do |context|
359
+ total = JSON.parse(context.json).values_at('dependencies', 'peerDependencies',
360
+ 'optionalDependencies').compact.sum(&:size)
361
+ lines << " #{context.gem.ljust(width)} #{count(total, 'dependency', 'dependencies')}"
362
+ end
363
+ introduced = contexts.flat_map(&:git_and_url)
364
+ if introduced.any?
365
+ lines << '' << 'Gems bring in these Git and URL dependencies:'
366
+ lines.concat(introduced.map { " #{it}" })
367
+ end
368
+ lines.concat(kept)
369
+ commit = (@written + [lockfile]).uniq
370
+ lines << '' << @reporter.paint('Commit these files:', :bold)
371
+ lines.concat(commit.map { " #{it}" })
372
+ lines.unshift('') unless @reporter.json?
373
+ @reporter.info(lines.join("\n"), event: 'installed', manager: @manager.name,
374
+ gems: contexts.map(&:gem), commit:, timings: @timings)
375
+ end
376
+ end
377
+ end
378
+ end