proscenium 0.25.3 → 0.26.1
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 +4 -4
- data/README.md +170 -23
- data/exe/proscenium +19 -0
- data/exe/psm +5 -0
- data/lib/proscenium/app_package.rb +90 -0
- data/lib/proscenium/builder.rb +221 -20
- data/lib/proscenium/bundled_gems.rb +103 -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/transformer.rb +18 -3
- data/lib/proscenium/css_module.rb +5 -6
- data/lib/proscenium/dependency_context.rb +74 -0
- data/lib/proscenium/error.rb +10 -0
- data/lib/proscenium/helper.rb +38 -4
- data/lib/proscenium/importer.rb +53 -42
- data/lib/proscenium/manifest.rb +18 -23
- data/lib/proscenium/mapping_generation.rb +64 -0
- data/lib/proscenium/middleware/base.rb +5 -10
- data/lib/proscenium/middleware/chunks.rb +5 -0
- data/lib/proscenium/monkey.rb +51 -69
- data/lib/proscenium/railtie.rb +16 -0
- data/lib/proscenium/railties/assets.rake +2 -1
- data/lib/proscenium/resolver.rb +43 -11
- data/lib/proscenium/runtime/bootstrap.js +26 -6
- data/lib/proscenium/runtime/server.rb +57 -36
- data/lib/proscenium/side_load.rb +75 -18
- data/lib/proscenium/stale_contexts.rb +118 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
- data/lib/proscenium/utils.rb +55 -0
- data/lib/proscenium/version.rb +1 -1
- data/lib/proscenium/yaml_tree.rb +48 -0
- data/lib/proscenium.rb +7 -4
- metadata +42 -13
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +0 -113
- data/lib/proscenium/react-manager/index.jsx +0 -102
- data/lib/proscenium/react-manager/react.js +0 -2
- data/lib/proscenium/react_componentable.rb +0 -94
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rubygems/package'
|
|
5
|
+
require 'zlib'
|
|
6
|
+
require_relative 'gem_archive'
|
|
7
|
+
require_relative 'rules'
|
|
8
|
+
require_relative '../bundled_gems'
|
|
9
|
+
require_relative '../context_map'
|
|
10
|
+
|
|
11
|
+
module Proscenium
|
|
12
|
+
module CLI
|
|
13
|
+
# `proscenium gem check [path]`: the gem author's check, which needs no bundle. Checks a gem
|
|
14
|
+
# source directory, or a built `.gem`, against the author contract: opted in, the manifest and
|
|
15
|
+
# frontend files in `spec.files`, and the manifest rules. A built `.gem` is only read; a source
|
|
16
|
+
# directory's gemspec is Ruby, which loading it runs, so check an untrusted gem as a `.gem`.
|
|
17
|
+
class GemCheck
|
|
18
|
+
FRONTEND = /\.(?:js|jsx|mjs|ts|tsx|css)\z/
|
|
19
|
+
# Not shipped on purpose: tests, dependencies, and tool configuration at the root, such as
|
|
20
|
+
# eslint.config.js.
|
|
21
|
+
UNSHIPPED = Regexp.union(%r{(?:\A|/)(?:node_modules|test|spec)/}, /\.(?:test|spec)\.[a-z]+\z/,
|
|
22
|
+
%r{\A[^/]+\.config\.[a-z]+\z})
|
|
23
|
+
|
|
24
|
+
def initialize(path, reporter)
|
|
25
|
+
@path = File.expand_path(path || Dir.pwd)
|
|
26
|
+
@reporter = reporter
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
spec, files, manifest_text, cause = @path.end_with?('.gem') ? read_archive : read_source
|
|
31
|
+
problems = check(spec, files, manifest_text, cause)
|
|
32
|
+
problems.each { |code, args| @reporter.error(Error.new(code, **args), phase: 'gem-check') }
|
|
33
|
+
if problems.empty?
|
|
34
|
+
message = "#{spec.name} #{spec.version} passes every check: Proscenium can install " \
|
|
35
|
+
'its NPM dependencies.'
|
|
36
|
+
@reporter.info(@reporter.paint(message, :bold, :green),
|
|
37
|
+
event: 'gem-check', gem: spec.name)
|
|
38
|
+
return 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Error::EXIT.fetch(:input)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# `cause` is why the manifest could not be read safely, if it could not (C29).
|
|
47
|
+
def check(spec, files, manifest_text, cause = nil)
|
|
48
|
+
gem = spec.name
|
|
49
|
+
unless spec.metadata['proscenium.dependencies'] == 'true'
|
|
50
|
+
return [['PSM-E-NOT-OPTED-IN', { gem: }]]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
root = frontend_root(spec)
|
|
54
|
+
return [['PSM-E-FRONTEND-ROOT', { gem:, root: }]] unless root
|
|
55
|
+
|
|
56
|
+
manifest_path = root.empty? ? 'package.json' : File.join(root, 'package.json')
|
|
57
|
+
return [['PSM-E-MANIFEST', { gem:, path: manifest_path, cause: }]] if cause
|
|
58
|
+
unless manifest_text
|
|
59
|
+
return [['PSM-E-MANIFEST',
|
|
60
|
+
{ gem:, path: manifest_path, cause: 'it is missing' }]]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
manifest = parse(manifest_text)
|
|
64
|
+
if manifest.is_a?(String)
|
|
65
|
+
return [['PSM-E-MANIFEST',
|
|
66
|
+
{ gem:, path: manifest_path, cause: manifest }]]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
gyp = root.empty? ? 'binding.gyp' : File.join(root, 'binding.gyp')
|
|
70
|
+
problems = Rules.check(gem, manifest, root: source_root(root), path: manifest_path,
|
|
71
|
+
binding_gyp: files.include?(gyp))
|
|
72
|
+
# A field of the wrong type stops here: the checks below read it as an object.
|
|
73
|
+
return problems if problems.any? { it.first == 'PSM-E-MANIFEST' }
|
|
74
|
+
|
|
75
|
+
unless files.include?(manifest_path)
|
|
76
|
+
problems << ['PSM-E-GEM-FILES',
|
|
77
|
+
{ gem:, files: manifest_path }]
|
|
78
|
+
end
|
|
79
|
+
missing = unlisted_frontend(files, root)
|
|
80
|
+
problems << ['PSM-E-GEM-FILES', { gem:, files: summarize(missing) }] if missing.any?
|
|
81
|
+
problems + cross_gem(spec, manifest)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def frontend_root(spec) = BundledGems.frontend_root(spec)
|
|
85
|
+
|
|
86
|
+
# A reference to another gem's context needs that gem as a runtime dependency.
|
|
87
|
+
def cross_gem(spec, manifest)
|
|
88
|
+
runtime = spec.runtime_dependencies.map(&:name)
|
|
89
|
+
Rules::DEPENDENCY_FIELDS.flat_map { (manifest[it] || {}).keys }
|
|
90
|
+
.grep(%r{\A@rubygems/})
|
|
91
|
+
.map { it.delete_prefix('@rubygems/') }
|
|
92
|
+
.reject { runtime.include?(it) }
|
|
93
|
+
.map { ['PSM-E-CROSS-GEM', { gem: spec.name, other: it }] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse(text)
|
|
97
|
+
manifest = ContextMap.parse_json(text)
|
|
98
|
+
manifest.is_a?(Hash) ? manifest : 'it is not a JSON object'
|
|
99
|
+
rescue JSON::ParserError => e
|
|
100
|
+
"it is not valid JSON (#{e.message.lines.first.strip})"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def read_source
|
|
104
|
+
gemspecs = Dir.glob('*.gemspec', base: @path)
|
|
105
|
+
raise Error.new('PSM-E-GEMSPEC', path: @path, found: gemspecs.size) unless gemspecs.one?
|
|
106
|
+
|
|
107
|
+
# Loaded by absolute path: Specification.load caches by the path it is given, so a relative
|
|
108
|
+
# one would return another directory's gem. Inside the directory, because gemspecs often
|
|
109
|
+
# list their files relative to it.
|
|
110
|
+
spec = Dir.chdir(@path) { Gem::Specification.load(File.join(@path, gemspecs.first)) }
|
|
111
|
+
raise Error.new('PSM-E-GEMSPEC', path: @path, found: 0) unless spec
|
|
112
|
+
|
|
113
|
+
root = frontend_root(spec) || ''
|
|
114
|
+
manifest = File.join(@path, root, 'package.json')
|
|
115
|
+
@source = @path
|
|
116
|
+
return [spec, spec.files, nil, nil] unless File.exist?(manifest)
|
|
117
|
+
|
|
118
|
+
cause = Rules.manifest_file_problem(manifest, @path)
|
|
119
|
+
[spec, spec.files, cause ? nil : File.read(manifest), cause]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A built gem, read from its archive (GemArchive).
|
|
123
|
+
def read_archive
|
|
124
|
+
spec, manifest, cause = GemArchive.read(@path)
|
|
125
|
+
[spec, spec.files, manifest, cause]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def source_root(root) = @source && File.join(@source, root)
|
|
129
|
+
|
|
130
|
+
# Frontend files on disk under the frontend root but missing from `spec.files`, in the gem's
|
|
131
|
+
# root or a top-level directory it ships something from: a directory it ships nothing from,
|
|
132
|
+
# such as a demo app beside the gem's code, is left out on purpose. A built gem has nothing on
|
|
133
|
+
# disk to compare.
|
|
134
|
+
def unlisted_frontend(files, root)
|
|
135
|
+
return [] unless @source
|
|
136
|
+
|
|
137
|
+
shipped = files.filter_map { it.split('/', 2).first if it.include?('/') }
|
|
138
|
+
Dir.glob('**/*', base: File.join(@source, root)).grep(FRONTEND)
|
|
139
|
+
.map { root.empty? ? it : File.join(root, it) }
|
|
140
|
+
.reject { files.include?(it) || UNSHIPPED.match?(it) }
|
|
141
|
+
.select { !it.include?('/') || shipped.include?(it.split('/', 2).first) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def summarize(paths)
|
|
145
|
+
shown = paths.first(3).join(', ')
|
|
146
|
+
paths.size > 3 ? "#{shown} and #{paths.size - 3} more" : shown
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
module CLI
|
|
5
|
+
# The `.gitignore` lines for Proscenium's own state (#154): everything under .proscenium/
|
|
6
|
+
# ignored but the generated contexts, which are committed.
|
|
7
|
+
module Gitignore
|
|
8
|
+
# Each level down to a context's package.json is re-included: Git never looks inside a
|
|
9
|
+
# directory an earlier rule (`.proscenium/`, `.*`) ignores, and `.proscenium/**` ignores
|
|
10
|
+
# every level below it.
|
|
11
|
+
IGNORES = ['!.proscenium/', '.proscenium/*', '!.proscenium/packages/',
|
|
12
|
+
'!.proscenium/packages/*/', '!.proscenium/packages/*/package.json',
|
|
13
|
+
'.proscenium/packages/*/node_modules/'].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Appends the lines Proscenium owns, and a node_modules rule if the app has none (Bun moves
|
|
18
|
+
# an old tree to node_modules/.old_modules-<hash> when its linker changes).
|
|
19
|
+
def splice(text)
|
|
20
|
+
lines = text.lines(chomp: true)
|
|
21
|
+
# Only the lines together, in order, and with no rule after them that could hide the
|
|
22
|
+
# contexts are enough; otherwise all of them again. Git applies the last matching rule.
|
|
23
|
+
size = IGNORES.size
|
|
24
|
+
at = (0..(lines.size - size)).reverse_each.find { lines[it, size] == IGNORES }
|
|
25
|
+
hidden = at.nil? || lines[(at + size)..].any? { hides_contexts?(it) }
|
|
26
|
+
wanted = hidden ? IGNORES.dup : []
|
|
27
|
+
wanted.unshift('node_modules/') unless lines.any? { it.match?(%r{\A/?node_modules/?\z}) }
|
|
28
|
+
return text if wanted.empty?
|
|
29
|
+
|
|
30
|
+
nl = text.include?("\r\n") ? "\r\n" : "\n"
|
|
31
|
+
separator = text.empty? || text.end_with?("\n") ? '' : nl
|
|
32
|
+
"#{text}#{separator}#{nl unless text.empty?}# Proscenium#{nl}#{wanted.join(nl)}#{nl}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
FLAGS = [File::FNM_DOTMATCH, File::FNM_DOTMATCH | File::FNM_PATHNAME].freeze
|
|
36
|
+
COMMITTED = %w[.proscenium .proscenium/packages].freeze
|
|
37
|
+
|
|
38
|
+
# Whether an ignore rule could match a path the contexts are committed under. Generous: `*`
|
|
39
|
+
# crosses `/` here, so at worst the lines are added once more than needed.
|
|
40
|
+
def hides_contexts?(line)
|
|
41
|
+
pattern = line.strip.delete_prefix('/').chomp('/')
|
|
42
|
+
return false if pattern.empty? || pattern.start_with?('#', '!')
|
|
43
|
+
|
|
44
|
+
# Git's `**/` also matches no directory at all. Ruby's does too with FNM_PATHNAME, inside a
|
|
45
|
+
# pattern, but not at its start, so that prefix is also dropped; without FNM_PATHNAME, a
|
|
46
|
+
# trailing `**` (and `*`) crosses `/` as Git's trailing `/**` does.
|
|
47
|
+
# Any segment of the rule may name a gem's directory, such as `.proscenium/packages/hue/**`,
|
|
48
|
+
# and one with a wildcard, class or brace, such as `[ah]ue`, may match any of them.
|
|
49
|
+
names = ['gem', *pattern.split('/')]
|
|
50
|
+
wide = pattern.split('/').map { it.match?(/[*?\[{]/) ? '*' : it }.join('/')
|
|
51
|
+
paths = COMMITTED + names.flat_map do |name|
|
|
52
|
+
["#{COMMITTED.last}/#{name}", "#{COMMITTED.last}/#{name}/package.json"]
|
|
53
|
+
end
|
|
54
|
+
[pattern, wide].flat_map { [it, it.delete_prefix('**/')] }.uniq.any? do |glob|
|
|
55
|
+
paths.any? do |path|
|
|
56
|
+
FLAGS.any? { File.fnmatch?(glob, path, it) } ||
|
|
57
|
+
File.fnmatch?(glob, File.basename(path), File::FNM_DOTMATCH)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'bundler'
|
|
5
|
+
require_relative '../bundled_gems'
|
|
6
|
+
require_relative 'contexts'
|
|
7
|
+
require_relative 'gitignore'
|
|
8
|
+
require_relative 'manager'
|
|
9
|
+
require_relative 'registration'
|
|
10
|
+
require_relative 'verify'
|
|
11
|
+
require_relative '../stale_contexts'
|
|
12
|
+
|
|
13
|
+
module Proscenium
|
|
14
|
+
module CLI
|
|
15
|
+
# `proscenium inspect [gem]` (#154): read-only. What each participating gem contributes, where
|
|
16
|
+
# it is locked from, whether its committed context is current, and which gems ship a
|
|
17
|
+
# package.json without taking part. With --json, one document.
|
|
18
|
+
class Inspect
|
|
19
|
+
CREDENTIALS = %r{(://)[^/@\s]+@}
|
|
20
|
+
# A query parameter whose name says it holds a secret, which Bundler keeps in a git source.
|
|
21
|
+
SECRET_PARAMS = /([?&][^=&#\s]*(?:token|key|auth|pass|secret|sig)[^=&#\s]*=)[^&#\s]+/i
|
|
22
|
+
|
|
23
|
+
# Each context status, as a person reads it.
|
|
24
|
+
STATUS = {
|
|
25
|
+
'current' => ['up to date', :green],
|
|
26
|
+
'stale' => ['out of date: run `bundle exec proscenium install`', :yellow],
|
|
27
|
+
'edited' => ['edited by hand: run `bundle exec proscenium install` to restore it', :yellow],
|
|
28
|
+
'missing' => ['not written yet: run `bundle exec proscenium install`', :yellow],
|
|
29
|
+
'invalid' => ["can't be installed: see the problems below", :red]
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(root, reporter, gem: nil)
|
|
33
|
+
@root = root
|
|
34
|
+
@reporter = reporter
|
|
35
|
+
@only = gem
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
report = build
|
|
40
|
+
if @reporter.json?
|
|
41
|
+
@reporter.document(report)
|
|
42
|
+
else
|
|
43
|
+
@reporter.result(text(report), event: 'inspect')
|
|
44
|
+
end
|
|
45
|
+
report['problems'].empty? && report['buildRefused'].empty? ? 0 : Error::EXIT.fetch(:drift)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build
|
|
51
|
+
overrides = BundledGems.overrides(@root)
|
|
52
|
+
specs = BundledGems.installed_specs
|
|
53
|
+
participating = BundledGems.participating(specs, overrides:)
|
|
54
|
+
if @only && !participating.key?(@only)
|
|
55
|
+
raise Error.new('PSM-E-USAGE', detail: "#{@only} doesn't install NPM " \
|
|
56
|
+
'dependencies through Proscenium')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
contexts = Contexts.new(@root, participating)
|
|
60
|
+
committed = Contexts.committed(@root)
|
|
61
|
+
gems = participating.keys.select { @only.nil? || it == @only }.map do |name|
|
|
62
|
+
gem_report(name, participating[name], contexts, committed)
|
|
63
|
+
end
|
|
64
|
+
manager, problems = select_manager
|
|
65
|
+
# An app that uses none of this has nothing to set up, whatever its manager.
|
|
66
|
+
if participating.any? || committed.any? || ContextMap.registrar(@root)
|
|
67
|
+
problems += project_problems(manager, contexts, committed)
|
|
68
|
+
else
|
|
69
|
+
problems = []
|
|
70
|
+
end
|
|
71
|
+
problems += contexts.problems.map { |code, args| Error.new(code, **args) }
|
|
72
|
+
{ 'schema' => 1, 'manager' => manager_report(manager), 'gems' => gems,
|
|
73
|
+
'notParticipating' => BundledGems.unparticipating_with_manifest(specs, overrides:),
|
|
74
|
+
# Every reason the engine refuses to build: orphans and split peers as well.
|
|
75
|
+
'buildRefused' => StaleContexts.problems(@root, specs),
|
|
76
|
+
'problems' => problems.map { problem_report(it) } }
|
|
77
|
+
rescue BundledGems::ConfigError => e
|
|
78
|
+
raise Error.new('PSM-E-CONFIG', detail: e.message)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def gem_report(name, spec, contexts, committed)
|
|
82
|
+
context = contexts.contexts[name]
|
|
83
|
+
current = context && JSON.parse(context.json)
|
|
84
|
+
path = committed[name]
|
|
85
|
+
on_disk = path && File.exist?(path) ? File.read(path) : nil
|
|
86
|
+
{
|
|
87
|
+
'gem' => name, 'version' => spec.version.to_s, 'source' => redact(source(name)),
|
|
88
|
+
'context' => ".proscenium/packages/#{name}",
|
|
89
|
+
'status' => status(context, on_disk, path),
|
|
90
|
+
'projectionSha256' => current&.dig('proscenium', 'projectionSha256'),
|
|
91
|
+
'kept' => current ? (current.keys & DependencyContext::FIELDS) : [],
|
|
92
|
+
'gitAndUrl' => (context&.git_and_url || []).map { redact(it) },
|
|
93
|
+
'optionalPeers' => optional_peers(current)
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# `stale` exactly when the engine refuses to build: the projection hash differs. Different
|
|
98
|
+
# bytes with the same hash are a hand edit, which only `install --frozen` refuses.
|
|
99
|
+
def status(context, on_disk, path)
|
|
100
|
+
return 'invalid' unless context
|
|
101
|
+
return 'missing' unless on_disk
|
|
102
|
+
return 'current' if on_disk == context.json
|
|
103
|
+
|
|
104
|
+
reason = StaleContexts.compare(context, path)
|
|
105
|
+
reason.nil? || reason == StaleContexts::EDITED ? 'edited' : 'stale'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Where Gemfile.lock takes the gem from.
|
|
109
|
+
def source(name)
|
|
110
|
+
locked = Bundler.locked_gems&.specs&.find { it.name == name }
|
|
111
|
+
return 'not locked' unless locked
|
|
112
|
+
|
|
113
|
+
locked.source.to_s
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Optional peers that are other gems, beside the version Bundler locked: Bundler checks a
|
|
117
|
+
# gemspec dependency's version, but nothing checks an optional peer's range.
|
|
118
|
+
def optional_peers(context)
|
|
119
|
+
return [] unless context
|
|
120
|
+
|
|
121
|
+
(context['peerDependenciesMeta'] || {}).filter_map do |name, meta|
|
|
122
|
+
next unless meta.is_a?(Hash) && meta['optional'] && name.start_with?('@rubygems/')
|
|
123
|
+
|
|
124
|
+
gem = name.delete_prefix('@rubygems/')
|
|
125
|
+
locked = Bundler.locked_gems&.specs&.find { it.name == gem }
|
|
126
|
+
{ 'peer' => name, 'range' => context.dig('peerDependencies', name),
|
|
127
|
+
'locked' => locked&.version&.to_s }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The manager, and what stops install using it, as install would check it: [manager or nil,
|
|
132
|
+
# problems].
|
|
133
|
+
def select_manager
|
|
134
|
+
manager = Manager.select(@root)
|
|
135
|
+
manager.check_version!
|
|
136
|
+
manager.check_project!(frozen: Registration.registered?(@root, manager.name))
|
|
137
|
+
[manager, []]
|
|
138
|
+
rescue Error => e
|
|
139
|
+
[manager, [e]]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# What stops the app's gem dependencies working once installed: the registration, the
|
|
143
|
+
# .gitignore lines, the lockfile and the install itself. Before the first install, only that
|
|
144
|
+
# it has not happened.
|
|
145
|
+
def project_problems(manager, contexts, committed)
|
|
146
|
+
return gitignore_problems unless manager
|
|
147
|
+
|
|
148
|
+
file = manager.name == 'bun' ? 'package.json' : 'pnpm-workspace.yaml'
|
|
149
|
+
registered = Registration.registered?(@root, manager.name)
|
|
150
|
+
unregistered = Error.new('PSM-E-NOT-REGISTERED', file:, manager: manager.name)
|
|
151
|
+
return [unregistered] if !registered && committed.empty?
|
|
152
|
+
|
|
153
|
+
found = gitignore_problems
|
|
154
|
+
found << unregistered unless registered
|
|
155
|
+
lockfile = Manager::LOCKFILES.key(manager.name)
|
|
156
|
+
lock = File.join(@root, lockfile)
|
|
157
|
+
return found << Error.new('PSM-E-LOCKFILE-MISSING', lockfile:) unless File.exist?(lock)
|
|
158
|
+
unless File.directory?(File.join(@root, 'node_modules'))
|
|
159
|
+
return found << Error.new('PSM-E-NOT-INSTALLED')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
found + (registered ? installed_problems(manager, contexts, File.read(lock)) : [])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def gitignore_problems
|
|
166
|
+
path = File.join(@root, '.gitignore')
|
|
167
|
+
text = File.exist?(path) ? File.read(path) : ''
|
|
168
|
+
Gitignore.splice(text) == text ? [] : [Error.new('PSM-E-GITIGNORE')]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A gem context the lock takes from a registry, or one the manager did not install. A split
|
|
172
|
+
# peer is in buildRefused, as the engine refuses to build for it.
|
|
173
|
+
def installed_problems(manager, contexts, lock)
|
|
174
|
+
verify = Verify.new(@root, manager, contexts.contexts.keys)
|
|
175
|
+
%i[check_registry check_workspaces].filter_map do |check|
|
|
176
|
+
verify.public_send(check, lock)
|
|
177
|
+
nil
|
|
178
|
+
rescue Error => e
|
|
179
|
+
e
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def problem_report(error)
|
|
184
|
+
{ 'code' => error.code, 'message' => error.message,
|
|
185
|
+
'fix' => error.fix }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def manager_report(manager)
|
|
189
|
+
return nil unless manager
|
|
190
|
+
|
|
191
|
+
linker = manager.name == 'bun' ? manager.bun_linker : pnpm_linker
|
|
192
|
+
{ 'name' => manager.name, 'linker' => linker }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# pnpm's `node-linker` from .npmrc, `isolated` by default.
|
|
196
|
+
def pnpm_linker
|
|
197
|
+
npmrc = File.join(@root, '.npmrc')
|
|
198
|
+
setting = File.exist?(npmrc) && File.read(npmrc)[/^\s*node-linker\s*=\s*(\S+)/, 1]
|
|
199
|
+
setting || 'isolated'
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def redact(text)
|
|
203
|
+
text.to_s.gsub(CREDENTIALS, '\1<redacted>@').gsub(SECRET_PARAMS, '\1<redacted>')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def text(report)
|
|
207
|
+
lines = []
|
|
208
|
+
if (manager = report['manager'])
|
|
209
|
+
linker = manager['linker'] ? "#{manager['linker']} linker" : 'linker not set'
|
|
210
|
+
lines << "#{bold('Package manager:')} #{manager['name']} (#{linker})" << ''
|
|
211
|
+
end
|
|
212
|
+
lines.concat(gem_lines(report['gems']))
|
|
213
|
+
if report['notParticipating'].any?
|
|
214
|
+
lines << '' << bold('These gems ship a package.json but have not opted in:')
|
|
215
|
+
lines.concat(report['notParticipating'].map { " #{it}" })
|
|
216
|
+
lines << ' To opt one in, add `"proscenium": {"gemOverrides": {"<gem>": ' \
|
|
217
|
+
'{"participate": true}}}` to package.json.'
|
|
218
|
+
end
|
|
219
|
+
report['problems'].each do |problem|
|
|
220
|
+
lines << '' << "#{@reporter.paint('Problem:', :bold, :red)} #{problem['message']}"
|
|
221
|
+
lines << "#{bold('To fix:')} #{problem['fix']}"
|
|
222
|
+
end
|
|
223
|
+
if report['buildRefused'].any?
|
|
224
|
+
lines << '' << @reporter.paint('Rails will refuse to build assets until you fix:', :bold,
|
|
225
|
+
:red)
|
|
226
|
+
lines.concat(report['buildRefused'].map { " - #{it}" })
|
|
227
|
+
end
|
|
228
|
+
if report['problems'].empty? && report['buildRefused'].empty?
|
|
229
|
+
lines << '' << @reporter.paint('No problems found.', :bold, :green)
|
|
230
|
+
end
|
|
231
|
+
lines.join("\n")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def gem_lines(gems)
|
|
235
|
+
return ['No gems install NPM dependencies through Proscenium.'] if gems.empty?
|
|
236
|
+
|
|
237
|
+
lines = [bold("Gems whose NPM dependencies Proscenium installs (#{gems.size}):")]
|
|
238
|
+
gems.each do |gem|
|
|
239
|
+
status, colour = STATUS.fetch(gem['status'])
|
|
240
|
+
lines << " #{gem['gem']} #{gem['version']}: #{@reporter.paint(status, colour)}"
|
|
241
|
+
lines << " from: #{gem['source']}"
|
|
242
|
+
lines << " uses: #{gem['kept'].join(', ')}" if gem['kept'].any?
|
|
243
|
+
if gem['gitAndUrl'].any?
|
|
244
|
+
lines << " Git and URL dependencies: #{gem['gitAndUrl'].join(', ')}"
|
|
245
|
+
end
|
|
246
|
+
gem['optionalPeers'].each do |peer|
|
|
247
|
+
locked = peer['locked'] ? "#{peer['locked']} is locked" : 'not locked'
|
|
248
|
+
lines << " optional peer #{peer['peer']} #{peer['range']} (#{locked})"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
lines
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def bold(text) = @reporter.paint(text, :bold)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|