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,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