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.
- 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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative '../context_map'
|
|
5
|
+
|
|
6
|
+
module Proscenium
|
|
7
|
+
module CLI
|
|
8
|
+
# One install at a time per project, and a marker while one is under way (#154).
|
|
9
|
+
#
|
|
10
|
+
# The lock is `flock` on `.proscenium/lock`, so the operating system releases it when its
|
|
11
|
+
# holder dies and there is nothing stale to reclaim. The marker,
|
|
12
|
+
# `.proscenium/installing`, outlives an interrupted install: the engine refuses to build while
|
|
13
|
+
# it exists, and the next install removes it once it has finished.
|
|
14
|
+
#
|
|
15
|
+
# The holder's name goes in `.proscenium/holder`, not in the lock file: Windows locks are
|
|
16
|
+
# mandatory, so a second install could not read a locked file to say who holds it.
|
|
17
|
+
#
|
|
18
|
+
# The lock is not handed to the manager: every process the manager starts would inherit it, so
|
|
19
|
+
# a daemon a lifecycle script left running would hold it. So a killed CLI releases the lock
|
|
20
|
+
# while its manager runs on; the manager's pid goes in `.proscenium/manager` while it runs, and
|
|
21
|
+
# an install that finds that process alive is refused as if the lock were held.
|
|
22
|
+
class ProjectLock
|
|
23
|
+
DIR = '.proscenium'
|
|
24
|
+
|
|
25
|
+
NOFOLLOW = File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0
|
|
26
|
+
|
|
27
|
+
# Writes one of install's own files, never through a link: a checkout could point one at any
|
|
28
|
+
# file the user can write. The open refuses a link itself where the OS can (O_NOFOLLOW), so
|
|
29
|
+
# one swapped in after the check is refused too.
|
|
30
|
+
def self.write(path, text)
|
|
31
|
+
raise Error.new('PSM-E-OWNED-LINK', paths: path) if File.symlink?(path)
|
|
32
|
+
|
|
33
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC | NOFOLLOW, 0o644) { it.write(text) }
|
|
34
|
+
rescue Errno::ELOOP, Errno::EMLINK
|
|
35
|
+
raise Error.new('PSM-E-OWNED-LINK', paths: path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(root)
|
|
39
|
+
@root = root
|
|
40
|
+
@dir = File.join(root, DIR)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The engine probes these two to refuse builds during an install, so both sides share them.
|
|
44
|
+
def lock_path = File.join(@root, ContextMap::LOCK)
|
|
45
|
+
def marker_path = File.join(@root, ContextMap::INSTALLING)
|
|
46
|
+
def holder_path = File.join(@dir, 'holder')
|
|
47
|
+
def manager_path = File.join(@dir, 'manager')
|
|
48
|
+
|
|
49
|
+
# Takes the lock for the block, or raises PSM-E-BUSY naming the install that holds it.
|
|
50
|
+
def synchronize(command)
|
|
51
|
+
FileUtils.mkdir_p(@dir)
|
|
52
|
+
@io = File.open(lock_path, File::RDWR | File::CREAT, 0o644)
|
|
53
|
+
unless acquired? && !(manager = running_manager)
|
|
54
|
+
holder = File.exist?(holder_path) ? File.read(holder_path).strip : ''
|
|
55
|
+
holder = "the package manager (pid #{manager}) of a stopped #{holder}" if manager
|
|
56
|
+
@io.close
|
|
57
|
+
raise Error.new('PSM-E-BUSY', holder: holder.empty? ? 'another install' : holder)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
self.class.write(holder_path, "#{command} (pid #{Process.pid})\n")
|
|
61
|
+
yield self
|
|
62
|
+
ensure
|
|
63
|
+
@io&.close unless @io&.closed?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The lock, retried for a second: a running Rails app probes it with a shared lock for a few
|
|
67
|
+
# microseconds at a time, and that must not read as another install.
|
|
68
|
+
def acquired?
|
|
69
|
+
20.times do
|
|
70
|
+
return true if @io.flock(File::LOCK_EX | File::LOCK_NB)
|
|
71
|
+
|
|
72
|
+
sleep 0.05
|
|
73
|
+
end
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The pid of a manager an earlier install started that is still running, or nil.
|
|
78
|
+
# ponytail: a reused pid reads as busy until that process exits; a process start time would
|
|
79
|
+
# tell them apart.
|
|
80
|
+
def running_manager
|
|
81
|
+
return unless File.exist?(manager_path)
|
|
82
|
+
|
|
83
|
+
pid = File.read(manager_path).to_i
|
|
84
|
+
return unless pid.positive?
|
|
85
|
+
|
|
86
|
+
Process.kill(0, pid)
|
|
87
|
+
pid
|
|
88
|
+
rescue Errno::ESRCH
|
|
89
|
+
nil
|
|
90
|
+
rescue Errno::EPERM
|
|
91
|
+
pid
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def manager_started(pid) = self.class.write(manager_path, "#{pid}\n")
|
|
95
|
+
|
|
96
|
+
def manager_finished = FileUtils.rm_f(manager_path)
|
|
97
|
+
|
|
98
|
+
# Whether an earlier install was interrupted before it finished.
|
|
99
|
+
def interrupted? = File.exist?(marker_path)
|
|
100
|
+
|
|
101
|
+
def mark! = self.class.write(marker_path, "#{Process.pid}\n")
|
|
102
|
+
|
|
103
|
+
def unmark! = FileUtils.rm_f(marker_path)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../context_map'
|
|
5
|
+
require_relative 'bun_linker'
|
|
6
|
+
require_relative 'brackets'
|
|
7
|
+
require_relative 'gitignore'
|
|
8
|
+
require_relative 'pnpm_workspace'
|
|
9
|
+
require_relative 'manager'
|
|
10
|
+
|
|
11
|
+
module Proscenium
|
|
12
|
+
module CLI
|
|
13
|
+
# The one-time edits that register `.proscenium/packages/*` with the app's package manager, and
|
|
14
|
+
# the `.gitignore` lines for Proscenium's own state (#154, Gitignore). Each is a textual
|
|
15
|
+
# splice, so the file's other settings, order and comments survive; each is computed without
|
|
16
|
+
# writing, so `install` can print it as a diff first.
|
|
17
|
+
module Registration
|
|
18
|
+
PATTERN = ContextMap::REGISTRATION
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# The edits the project at `root` needs: [path, current text, new text] for each file that
|
|
23
|
+
# changes. Empty once registered.
|
|
24
|
+
def edits(root, manager)
|
|
25
|
+
file = manager == 'bun' ? 'package.json' : 'pnpm-workspace.yaml'
|
|
26
|
+
registration = if manager == 'bun' then method(:splice_package_json)
|
|
27
|
+
else PnpmWorkspace.method(:splice)
|
|
28
|
+
end
|
|
29
|
+
splices = [[file, registration],
|
|
30
|
+
['.gitignore', Gitignore.method(:splice)]]
|
|
31
|
+
if manager == 'bun' && !Manager.new('bun', root).bun_linker
|
|
32
|
+
linker = BunLinker.current(root)
|
|
33
|
+
splices << ['bunfig.toml', ->(text) { BunLinker.splice(text, linker) }]
|
|
34
|
+
end
|
|
35
|
+
splices.filter_map do |name, splice|
|
|
36
|
+
path = File.join(root, name)
|
|
37
|
+
text = File.exist?(path) ? File.read(path) : ''
|
|
38
|
+
spliced = splice.call(text)
|
|
39
|
+
next if spliced == text
|
|
40
|
+
# Writing through a link could change a file outside the app.
|
|
41
|
+
raise Error.new('PSM-E-OWNED-LINK', paths: name) if File.symlink?(path)
|
|
42
|
+
|
|
43
|
+
[path, text, spliced]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the app has adopted RubyGem NPM dependencies: its committed registration names the
|
|
48
|
+
# contexts.
|
|
49
|
+
def registered?(root, manager)
|
|
50
|
+
ContextMap.registers?(root, manager == 'bun' ? 'package.json' : 'pnpm-workspace.yaml')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def refused(file) = Error.new('PSM-E-REGISTRATION', file:)
|
|
54
|
+
|
|
55
|
+
# Adds the pattern to package.json's `workspaces`, as an array or an object's `packages`,
|
|
56
|
+
# or adds the key. Refuses rather than guess at JSON it cannot splice. A byte order mark is
|
|
57
|
+
# kept.
|
|
58
|
+
def splice_package_json(text)
|
|
59
|
+
return text if ContextMap.registered_in?('package.json', text, strict: true)
|
|
60
|
+
|
|
61
|
+
bom = text.start_with?("\uFEFF") ? "\uFEFF" : ''
|
|
62
|
+
spliced = splice_json(text.delete_prefix(bom))
|
|
63
|
+
if ContextMap.excludes?(ContextMap.json_workspaces(spliced))
|
|
64
|
+
raise Error.new('PSM-E-REGISTRATION-EXCLUDED', file: 'package.json')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
"#{bom}#{spliced}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def splice_json(text)
|
|
71
|
+
json = text.empty? ? {} : JSON.parse(text)
|
|
72
|
+
raise refused('package.json') unless json.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
current = json['workspaces']
|
|
75
|
+
spliced = if current.nil?
|
|
76
|
+
add_key(text, json)
|
|
77
|
+
elsif current.is_a?(Array)
|
|
78
|
+
insert_into_array(text, key_end(text, /"workspaces"\s*:\s*\[/, 1))
|
|
79
|
+
elsif current.is_a?(Hash) && current['packages'].is_a?(Array)
|
|
80
|
+
object = key_end(text, /"workspaces"\s*:\s*\{/, 1)
|
|
81
|
+
insert_into_array(text, key_end(text, /"packages"\s*:\s*\[/, 2, object))
|
|
82
|
+
else
|
|
83
|
+
raise refused('package.json')
|
|
84
|
+
end
|
|
85
|
+
verify_json(json, spliced)
|
|
86
|
+
spliced
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
raise refused('package.json')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def key_end(...) = Brackets.key_end(...) || raise(refused('package.json'))
|
|
92
|
+
|
|
93
|
+
def verify_json(before, spliced)
|
|
94
|
+
after = JSON.parse(spliced)
|
|
95
|
+
expected = expected_workspaces(before)
|
|
96
|
+
actual = after['workspaces']
|
|
97
|
+
actual = actual['packages'] if actual.is_a?(Hash)
|
|
98
|
+
same = after.except('workspaces') == before.except('workspaces')
|
|
99
|
+
raise refused('package.json') unless actual == expected && same
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def expected_workspaces(json)
|
|
103
|
+
current = json['workspaces']
|
|
104
|
+
current = current['packages'] if current.is_a?(Hash)
|
|
105
|
+
(current || []) + [PATTERN]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Appends the pattern as the last element of the array whose `[` ends at `start`.
|
|
109
|
+
def insert_into_array(text, start)
|
|
110
|
+
close = Brackets.array_end(text, start) or raise refused('package.json')
|
|
111
|
+
body = text[start...close]
|
|
112
|
+
if body.strip.empty?
|
|
113
|
+
"#{text[0...start]}\"#{PATTERN}\"#{text[close..]}"
|
|
114
|
+
else
|
|
115
|
+
last = start + body.rstrip.length
|
|
116
|
+
indent = body[/\n([ \t]*)\S/, 1]
|
|
117
|
+
separator = indent ? ",#{eol(text)}#{indent}" : ', '
|
|
118
|
+
"#{text[0...last]}#{separator}\"#{PATTERN}\"#{text[last..]}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Adds `"workspaces": [pattern]` as the first key, in the file's own indentation.
|
|
123
|
+
def add_key(text, json)
|
|
124
|
+
return "#{JSON.pretty_generate('workspaces' => [PATTERN])}\n" if text.strip.empty?
|
|
125
|
+
|
|
126
|
+
open = text.index('{') + 1
|
|
127
|
+
indent = text[/\{\s*\n([ \t]+)/, 1]
|
|
128
|
+
if indent
|
|
129
|
+
"#{text[0...open]}#{eol(text)}#{indent}\"workspaces\": [\"#{PATTERN}\"]" \
|
|
130
|
+
"#{',' unless json.empty?}#{text[open..]}"
|
|
131
|
+
else
|
|
132
|
+
"#{text[0...open]}\"workspaces\": [\"#{PATTERN}\"]#{', ' unless json.empty?}" \
|
|
133
|
+
"#{text[open..]}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def eol(text) = text.include?("\r\n") ? "\r\n" : "\n"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Proscenium
|
|
6
|
+
module CLI
|
|
7
|
+
# Writes what the CLI tells the user: human text, or with `--json` newline-delimited,
|
|
8
|
+
# schema-versioned events on stdout. Errors go to stderr as text, or to stdout as an event.
|
|
9
|
+
# Human text is coloured only on a terminal, and never with NO_COLOR set or TERM=dumb.
|
|
10
|
+
class Reporter
|
|
11
|
+
SCHEMA = 1
|
|
12
|
+
STYLES = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, cyan: 36 }.freeze
|
|
13
|
+
DIFF_STYLES = { '---' => [:bold], '+++' => [:bold], '@@' => [:cyan], '+' => [:green],
|
|
14
|
+
'-' => [:red] }.freeze
|
|
15
|
+
|
|
16
|
+
# "1 gem", "2 gems".
|
|
17
|
+
def self.count(number, one, many = "#{one}s") = "#{number} #{number == 1 ? one : many}"
|
|
18
|
+
|
|
19
|
+
def initialize(out:, err:, json: false, quiet: false)
|
|
20
|
+
@out = out
|
|
21
|
+
@err = err
|
|
22
|
+
@json = json
|
|
23
|
+
@quiet = quiet
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def json? = @json
|
|
27
|
+
|
|
28
|
+
# `text` in `styles` for stdout, or as it is when stdout is not a terminal or is JSON.
|
|
29
|
+
def paint(text, *styles) = @json ? text : style(@out, text, *styles)
|
|
30
|
+
|
|
31
|
+
# A unified diff with removed lines red, added lines green and hunk headers cyan.
|
|
32
|
+
def paint_diff(diff)
|
|
33
|
+
diff.lines(chomp: true).map do |line|
|
|
34
|
+
prefix = DIFF_STYLES.keys.find { line.start_with?(it) }
|
|
35
|
+
prefix ? paint(line, *DIFF_STYLES[prefix]) : line
|
|
36
|
+
end.join("\n") + (diff.end_with?("\n") ? "\n" : '')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A line of the human summary, or an event in JSON mode.
|
|
40
|
+
def info(message, event: 'info', phase: nil, **details)
|
|
41
|
+
if @json
|
|
42
|
+
emit(event:, phase:, status: 'ok', message:, details:)
|
|
43
|
+
elsif !@quiet
|
|
44
|
+
@out.puts message
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A command's result rather than its progress (the version, the usage, inspect's report):
|
|
49
|
+
# printed even with --quiet, which would otherwise leave such a command saying nothing.
|
|
50
|
+
def result(message, event:, **details)
|
|
51
|
+
@json ? emit(event:, status: 'ok', message:, details:) : @out.puts(message)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One JSON document on stdout, for a command whose output is a report (`inspect --json`).
|
|
55
|
+
def document(hash) = @out.puts(JSON.generate(hash))
|
|
56
|
+
|
|
57
|
+
# A problem that does not stop the command.
|
|
58
|
+
def warning(error, phase: nil)
|
|
59
|
+
if @json
|
|
60
|
+
emit(event: 'warning', phase:, status: 'warning', code: error.code,
|
|
61
|
+
message: error.message, fix: error.fix, details: error.details)
|
|
62
|
+
else
|
|
63
|
+
problem('Warning:', :yellow, error)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def error(error, phase: nil, manager: nil)
|
|
68
|
+
if @json
|
|
69
|
+
emit(event: 'error', phase:, status: 'error', code: error.code, message: error.message,
|
|
70
|
+
fix: error.fix, manager:, details: error.details)
|
|
71
|
+
else
|
|
72
|
+
problem('Error:', :red, error)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# A problem for a person: what happened, a blank line, what to do, and the code to search
|
|
79
|
+
# the docs for. A blank line ends it, apart from whatever follows.
|
|
80
|
+
def problem(label, colour, error)
|
|
81
|
+
@err.puts "#{style(@err, label, :bold, colour)} #{error.message}"
|
|
82
|
+
@err.puts
|
|
83
|
+
@err.puts "#{style(@err, 'To fix:', :bold)} #{error.fix}"
|
|
84
|
+
@err.puts style(@err, "(#{error.code})", :dim)
|
|
85
|
+
@err.puts
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def style(io, text, *styles)
|
|
89
|
+
return text if styles.empty? || !colour?(io)
|
|
90
|
+
|
|
91
|
+
"\e[#{styles.map { STYLES.fetch(it) }.join(';')}m#{text}\e[0m"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def colour?(io)
|
|
95
|
+
io.respond_to?(:tty?) && io.tty? && ENV['NO_COLOR'].to_s.empty? && ENV['TERM'] != 'dumb'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def emit(**event)
|
|
99
|
+
@out.puts JSON.generate({ schema: SCHEMA }.merge(event.compact.reject { |_, v| v == {} }))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
module CLI
|
|
5
|
+
# The author contract for a participating gem's package.json (#154): what `gem check` and
|
|
6
|
+
# `install` refuse. Each rule yields a problem as [error code, format arguments]; the caller
|
|
7
|
+
# decides whether a problem is fatal and how to word the escape.
|
|
8
|
+
module Rules
|
|
9
|
+
DEPENDENCY_FIELDS = %w[dependencies peerDependencies optionalDependencies].freeze
|
|
10
|
+
HOOKS = %w[preinstall install postinstall prepare].freeze
|
|
11
|
+
REACT = %w[react react-dom].freeze
|
|
12
|
+
# Problems install reports as warnings: the gem still gets its context.
|
|
13
|
+
WARNINGS = %w[PSM-E-REACT].freeze
|
|
14
|
+
|
|
15
|
+
# npm's rules for a package name, as `@rubygems/<gem>` must satisfy them: lowercase, so no two
|
|
16
|
+
# contexts can differ only in case, and at most 214 characters.
|
|
17
|
+
VALID_NAME = %r{\A@rubygems/[a-z0-9][a-z0-9._~-]*\z}
|
|
18
|
+
NAME_LIMIT = 214
|
|
19
|
+
|
|
20
|
+
# Names Windows reserves for devices, with or without an extension: COM1-COM9 and LPT1-LPT9,
|
|
21
|
+
# not COM0 or LPT0. A context directory so named cannot exist there, so the gem is refused on
|
|
22
|
+
# every host alike (C27).
|
|
23
|
+
RESERVED = /\A(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?\z/
|
|
24
|
+
|
|
25
|
+
# URL specs Proscenium accepts. With RANGE_OR_TAG and npm: aliases, these are the only specs
|
|
26
|
+
# accepted; anything else is refused.
|
|
27
|
+
GIT_URL = %r{\A(?:github:[\w.-]+/[\w.-]+|git\+(?:https|ssh)://\S+)(?:#\S+)?\z}
|
|
28
|
+
TARBALL = %r{\Ahttps://\S+\z}
|
|
29
|
+
# A `user:password` before the host of any URL, anything before the host of an http(s) URL
|
|
30
|
+
# (a token), or a token-like query parameter. `git+ssh://git@host` names the SSH user, not a
|
|
31
|
+
# credential.
|
|
32
|
+
CREDENTIAL = Regexp.union(%r{://[^/@\s]*:[^/@\s]*@}, %r{https?://[^/@\s]*@}i,
|
|
33
|
+
/[?&][^=&#]*(?:token|key|auth|pass|secret|sig)[^=&#]*=/i)
|
|
34
|
+
# A semver range or a dist-tag. Everything else a manager would read as a path, a Git
|
|
35
|
+
# repository or a protocol, so it is refused.
|
|
36
|
+
RANGE_OR_TAG = /\A(?:[\w*^~<>=|+.-]|(?<=\S) (?=\S))*\z/
|
|
37
|
+
# `npm:<package>[@<range or tag>]`: a registry package by npm's name rules, never a path,
|
|
38
|
+
# link or URL.
|
|
39
|
+
ALIAS = %r{\Anpm:(?<name>(?:@[a-z0-9][\w.~-]*/)?[a-z0-9][\w.~-]*)(?:@(?<range>.*))?\z}mi
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# Problems with the manifest of `gem`, rooted at `root` (where binding.gyp would be), and
|
|
44
|
+
# named `path` in errors. `binding_gyp` says a built gem, which has no root on disk, ships
|
|
45
|
+
# one.
|
|
46
|
+
def check(gem, manifest, root: nil, path: 'package.json', binding_gyp: false)
|
|
47
|
+
shape = shape_problem(manifest)
|
|
48
|
+
return [['PSM-E-MANIFEST', { gem:, path:, cause: shape }]] if shape
|
|
49
|
+
|
|
50
|
+
problems = []
|
|
51
|
+
problems << ['PSM-E-NAME', { gem: }] unless valid_name?(gem)
|
|
52
|
+
problems << ['PSM-E-WORKSPACES', { gem: }] if manifest.key?('workspaces')
|
|
53
|
+
|
|
54
|
+
hooks = HOOKS & (manifest['scripts'] || {}).keys
|
|
55
|
+
binding_gyp ||= root && File.exist?(File.join(root, 'binding.gyp'))
|
|
56
|
+
hooks << 'binding.gyp' if binding_gyp
|
|
57
|
+
problems << ['PSM-E-HOOK', { gem:, hooks: hooks.join(', ') }] if hooks.any?
|
|
58
|
+
|
|
59
|
+
DEPENDENCY_FIELDS.each do |field|
|
|
60
|
+
(manifest[field] || {}).each do |name, spec|
|
|
61
|
+
problem = spec_problem(gem, name, spec)
|
|
62
|
+
problems << problem if problem
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# An optional dependency is the gem's own copy too, and so is React under an alias.
|
|
67
|
+
react = REACT & %w[dependencies optionalDependencies].flat_map do |field|
|
|
68
|
+
(manifest[field] || {}).flat_map { |name, spec| [name, ALIAS.match(spec)&.[](:name)] }
|
|
69
|
+
end
|
|
70
|
+
problems << ['PSM-E-REACT', { gem:, packages: react.join(' and ') }] if react.any?
|
|
71
|
+
problems
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Why a field every rule reads is the wrong type, or nil. Checked first, so nothing after it
|
|
75
|
+
# meets a string where it expects an object.
|
|
76
|
+
def shape_problem(manifest)
|
|
77
|
+
%w[scripts peerDependenciesMeta].each do |field|
|
|
78
|
+
return "#{field} is not an object" unless [Hash, NilClass].include?(manifest[field].class)
|
|
79
|
+
end
|
|
80
|
+
DEPENDENCY_FIELDS.each do |field|
|
|
81
|
+
deps = manifest[field]
|
|
82
|
+
next if deps.nil?
|
|
83
|
+
return "#{field} is not an object" unless deps.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
name = deps.find { |_, spec| !spec.is_a?(String) }&.first
|
|
86
|
+
return "#{field}.#{name} is not a string" if name
|
|
87
|
+
end
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Each "<gem>: <name> (<spec>)" whose name the app trusts but whose spec is not that registry
|
|
92
|
+
# package: Git, a URL, or an alias to another package. Bun trusts a dependency by name,
|
|
93
|
+
# whatever its source (probed on Bun 1.4.2), so a gem could run its own install script under
|
|
94
|
+
# the app's trust. `manifests` is each gem's parsed context.
|
|
95
|
+
def trusted_substitutions(trusted, manifests)
|
|
96
|
+
substitutions(manifests).filter_map do |gem, name, spec|
|
|
97
|
+
"#{gem}: #{name} (#{spec})" if trusted == :all || trusted.include?(name)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Each [gem, name, spec] that is not that registry package: Git, a URL, or an alias to
|
|
102
|
+
# another package.
|
|
103
|
+
def substitutions(manifests)
|
|
104
|
+
manifests.flat_map do |gem, manifest|
|
|
105
|
+
DEPENDENCY_FIELDS.flat_map { (manifest[it] || {}).to_a }.filter_map do |name, spec|
|
|
106
|
+
[gem, name, spec] if substitutes?(name, spec)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def substitutes?(name, spec)
|
|
112
|
+
target = ALIAS.match(spec)&.[](:name)
|
|
113
|
+
GIT_URL.match?(spec) || TARBALL.match?(spec) || (target && target.downcase != name.downcase)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def valid_name?(gem)
|
|
117
|
+
name = "@rubygems/#{gem}"
|
|
118
|
+
VALID_NAME.match?(name) && name.length <= NAME_LIMIT && !RESERVED.match?(gem) &&
|
|
119
|
+
!gem.end_with?('.')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Larger than any real package.json, small enough to read without a second thought.
|
|
123
|
+
MANIFEST_LIMIT = 1024 * 1024
|
|
124
|
+
|
|
125
|
+
# Why the package.json at `path`, in a gem rooted at `gem_root`, cannot be read safely, or
|
|
126
|
+
# nil (C29): it must be a regular file, inside the gem once links are followed, and no larger
|
|
127
|
+
# than MANIFEST_LIMIT. A FIFO or device would block or never end, and a link out of the gem
|
|
128
|
+
# would read a file the gem does not ship.
|
|
129
|
+
def manifest_file_problem(path, gem_root)
|
|
130
|
+
return 'it is not a regular file' unless File.file?(path)
|
|
131
|
+
|
|
132
|
+
real = File.realpath(path)
|
|
133
|
+
return 'it links outside the gem' unless real.start_with?("#{File.realpath(gem_root)}/")
|
|
134
|
+
return 'it is larger than 1 MB' if File.size(real) > MANIFEST_LIMIT
|
|
135
|
+
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Why `spec` for dependency `name` is refused, or nil. A gem-to-gem reference
|
|
140
|
+
# (`@rubygems/<other>`) is checked against the bundle at install time, not here.
|
|
141
|
+
def spec_problem(gem, name, spec)
|
|
142
|
+
return nil if name.start_with?('@rubygems/')
|
|
143
|
+
|
|
144
|
+
# A credential in a URL would be committed with the context (C31). A server decodes
|
|
145
|
+
# percent-escapes, so `%74oken=` is `token=`.
|
|
146
|
+
if [spec, unescape(spec)].any? { CREDENTIAL.match?(it) }
|
|
147
|
+
return ['PSM-E-CREDENTIAL', { gem:, name: }]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if spec.start_with?('npm:')
|
|
151
|
+
return ['PSM-E-ALIAS', { gem:, name:, spec: }] if spec.start_with?('npm:@rubygems/')
|
|
152
|
+
|
|
153
|
+
target = ALIAS.match(spec)
|
|
154
|
+
return if target && range_or_tag?(target[:range].to_s)
|
|
155
|
+
|
|
156
|
+
return ['PSM-E-SPEC', { gem:, name:, spec: }]
|
|
157
|
+
end
|
|
158
|
+
return nil if GIT_URL.match?(spec) || TARBALL.match?(spec) || range_or_tag?(spec)
|
|
159
|
+
|
|
160
|
+
['PSM-E-SPEC', { gem:, name:, spec: }]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def unescape(spec) = spec.gsub(/%(\h\h)/) { ::Regexp.last_match(1).hex.chr }
|
|
164
|
+
|
|
165
|
+
# A leading dot is a path to every manager, though the characters are a range's.
|
|
166
|
+
def range_or_tag?(spec) = RANGE_OR_TAG.match?(spec) && !spec.start_with?('.')
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler'
|
|
4
|
+
|
|
5
|
+
module Proscenium
|
|
6
|
+
module CLI
|
|
7
|
+
# Runs the package manager (#154): an argument array, never a shell, so no package name is ever
|
|
8
|
+
# interpreted; in the project root; with the environment a direct native install would have,
|
|
9
|
+
# not Bundler's (as the Rakefile does for `gem build`), so a dependency's lifecycle script that
|
|
10
|
+
# runs Ruby sees the app's own setup. The project lock is not passed down: every process the
|
|
11
|
+
# manager starts would inherit it, so a daemon a lifecycle script left behind would hold it.
|
|
12
|
+
# The manager's pid record keeps a manager that outlives a killed CLI from being joined.
|
|
13
|
+
module Runner
|
|
14
|
+
SIGNALS = %w[INT TERM].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Runs `executable` with `args` in `root` and returns when it exits. Options: `on_spawn`
|
|
19
|
+
# (called with the manager's pid), `out` (default: stderr, so stdout stays the CLI's
|
|
20
|
+
# summary), and `manager` (its name in errors).
|
|
21
|
+
# Raises PSM-E-INTERRUPTED if a signal stopped it, PSM-E-NATIVE if it failed.
|
|
22
|
+
# Its output is also kept, the last TAIL bytes of it, so a failure can say which gem it
|
|
23
|
+
# involves; the error carries it as `output`.
|
|
24
|
+
def run(executable, args, root:, **opts)
|
|
25
|
+
reader, writer = IO.pipe
|
|
26
|
+
spawn = { chdir: root, out: writer, err: writer, in: File::NULL }
|
|
27
|
+
pid = Bundler.with_unbundled_env { Process.spawn(ENV.to_h, executable, *args, **spawn) }
|
|
28
|
+
writer.close
|
|
29
|
+
record(pid, opts[:on_spawn])
|
|
30
|
+
kept = +''
|
|
31
|
+
relaying = relay(reader, opts.fetch(:out, $stderr), kept)
|
|
32
|
+
interrupted = []
|
|
33
|
+
previous = forward_signals(pid, interrupted)
|
|
34
|
+
_, status = Process.wait2(pid)
|
|
35
|
+
await_interrupt(status, interrupted)
|
|
36
|
+
# A process the manager left running may hold its output open: once the manager has
|
|
37
|
+
# exited, its output gets a moment to drain, and no more.
|
|
38
|
+
relaying.kill unless relaying.join(DRAIN)
|
|
39
|
+
check(status, opts.fetch(:manager) { File.basename(executable) }, args, kept.dup,
|
|
40
|
+
interrupted.first)
|
|
41
|
+
ensure
|
|
42
|
+
previous&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
43
|
+
reader&.close unless reader&.closed?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Hands the manager's pid to `on_spawn`, which records it. A manager whose pid was not
|
|
47
|
+
# recorded is stopped: the install is failing and releasing its lock, and a manager left
|
|
48
|
+
# running unrecorded would run beside the next install.
|
|
49
|
+
def record(pid, on_spawn)
|
|
50
|
+
recorded = false
|
|
51
|
+
on_spawn&.call(pid)
|
|
52
|
+
recorded = true
|
|
53
|
+
ensure
|
|
54
|
+
unless recorded
|
|
55
|
+
begin
|
|
56
|
+
Process.kill(:KILL, pid)
|
|
57
|
+
rescue Errno::ESRCH
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
Process.wait(pid)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
TAIL = 64 * 1024
|
|
65
|
+
# Seconds the manager's output may keep draining after it exits.
|
|
66
|
+
DRAIN = 1
|
|
67
|
+
|
|
68
|
+
# Copies the manager's output to `destination` as it arrives, keeping its tail. A destination
|
|
69
|
+
# that fails, such as a closed `| head`, is dropped but the pipe is still drained: a manager
|
|
70
|
+
# left blocked on a full pipe would never exit, and the lock would stay held.
|
|
71
|
+
def relay(reader, destination, kept)
|
|
72
|
+
Thread.new do
|
|
73
|
+
loop do
|
|
74
|
+
chunk = reader.readpartial(4096)
|
|
75
|
+
destination = write(destination, chunk)
|
|
76
|
+
kept << chunk
|
|
77
|
+
kept.replace(kept.byteslice(-TAIL, TAIL)) if kept.bytesize > TAIL
|
|
78
|
+
end
|
|
79
|
+
rescue IOError # EOFError included: the manager closed its output
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Writes `chunk` to `destination`, returning it, or nil once it has failed.
|
|
85
|
+
def write(destination, chunk)
|
|
86
|
+
destination&.write(chunk)
|
|
87
|
+
destination
|
|
88
|
+
rescue IOError, SystemCallError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# INT and TERM reach the child, and the CLI waits for it rather than leaving it orphaned. On
|
|
93
|
+
# Windows the console already delivers Ctrl-C to the manager, and Process.kill cannot send
|
|
94
|
+
# it, so there the CLI only notes the interruption and waits. Ctrl-Break ends the CLI
|
|
95
|
+
# outright: Ruby has no SIGBREAK to trap. The marker and the manager's pid record then
|
|
96
|
+
# keep the next install and the engine safe, as after any killed install.
|
|
97
|
+
def forward_signals(pid, interrupted)
|
|
98
|
+
SIGNALS.to_h do |signal|
|
|
99
|
+
handler = Signal.trap(signal) do
|
|
100
|
+
interrupted << signal
|
|
101
|
+
Process.kill(signal, pid) unless Gem.win_platform?
|
|
102
|
+
rescue Errno::ESRCH
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
[signal, handler]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Seconds a failed manager waits, on Windows, for the CLI's Ctrl-C handler.
|
|
110
|
+
GRACE = 0.5
|
|
111
|
+
|
|
112
|
+
# On Windows Ctrl-C reaches the manager and the CLI alike, and the manager can exit before the
|
|
113
|
+
# CLI's handler has run, so a failed manager waits a moment for it: an interrupted install is
|
|
114
|
+
# then reported as one, not as a failed manager. A sleep lets a pending handler run.
|
|
115
|
+
def await_interrupt(status, interrupted)
|
|
116
|
+
return if status.success? || !interrupted.empty? || !Gem.win_platform?
|
|
117
|
+
|
|
118
|
+
deadline = now + GRACE
|
|
119
|
+
sleep 0.01 while interrupted.empty? && now < deadline
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
123
|
+
|
|
124
|
+
# An interrupted install stops, however the manager ended: on Windows it exits with a status,
|
|
125
|
+
# not a signal, and may even exit 0, and Ctrl-C still means stop before verifying anything.
|
|
126
|
+
def check(status, manager, args, output, interrupted = nil)
|
|
127
|
+
return status if status.success? && !interrupted
|
|
128
|
+
|
|
129
|
+
command = [manager, *args].join(' ')
|
|
130
|
+
error = if status.signaled? || interrupted
|
|
131
|
+
Error.new('PSM-E-INTERRUPTED', command:,
|
|
132
|
+
details: { signal: status.termsig || interrupted })
|
|
133
|
+
else
|
|
134
|
+
Error.new('PSM-E-NATIVE', command:, status: status.exitstatus,
|
|
135
|
+
details: { exitStatus: status.exitstatus })
|
|
136
|
+
end
|
|
137
|
+
error.output = output
|
|
138
|
+
raise error
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|