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.
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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require_relative 'registration'
6
+
7
+ module Proscenium
8
+ module CLI
9
+ # The app's own packages that already take a gem context's name, `@rubygems/<gem>` (#154,
10
+ # C05): a dependency on it that is not the context, such as the GitHub pin an app used before
11
+ # adopting, and a workspace package so named. Install reads them before it writes anything.
12
+ module Collisions
13
+ module_function
14
+
15
+ DEPENDENCY_FIELDS = %w[dependencies devDependencies optionalDependencies
16
+ peerDependencies].freeze
17
+
18
+ # One line for each package of the app's own taking a context's name.
19
+ def find(root, manager, gems)
20
+ names = gems.to_h { ["@rubygems/#{it}", it] }
21
+ app = read_json(File.join(root, 'package.json')) || {}
22
+ pinned = DEPENDENCY_FIELDS.flat_map do |field|
23
+ (app[field] || {}).filter_map do |name, spec|
24
+ next if !names.key?(name) || context_workspace?(spec, name) ||
25
+ context_link?(root, spec, names[name])
26
+
27
+ "package.json #{field} has #{name} as \"#{spec}\""
28
+ end
29
+ end
30
+ pinned + workspace_packages(root, manager, app).filter_map do |file, name|
31
+ "#{file} is named #{name}" if names.key?(name)
32
+ end
33
+ end
34
+
35
+ # Whether `spec` is the workspace package `name`, the context: `workspace:<range>`, not an
36
+ # alias to another package, `workspace:<other>@<range>`.
37
+ def context_workspace?(spec, name)
38
+ range = spec.to_s.delete_prefix('workspace:')
39
+ return false if range == spec.to_s
40
+
41
+ aliased = range[/\A(@?[^@]+)@/, 1]
42
+ aliased.nil? || aliased == name
43
+ end
44
+
45
+ # Whether `spec` links to the gem's own context, however the path is spelled.
46
+ def context_link?(root, spec, gem)
47
+ return false unless spec.to_s.start_with?('link:')
48
+
49
+ context = File.join(File.expand_path(root), ContextMap::CONTEXTS, gem)
50
+ File.expand_path(spec.to_s.delete_prefix('link:'), root) == context
51
+ end
52
+
53
+ # [package.json path, package name] for each workspace package the app declares, other than
54
+ # the contexts. A pattern starting with `!` excludes. `./` is dropped from each, as pnpm and
55
+ # Bun read `./packages/*` and `packages/*` alike.
56
+ def workspace_packages(root, manager, app)
57
+ excluded, included = workspace_patterns(root, manager, app)
58
+ .grep(String).reject { ContextMap.registration?(it) }
59
+ .map { it.sub(%r{\A(!?)\./}, '\\1') }
60
+ .partition { it.start_with?('!') }
61
+ files = included.flat_map { Dir.glob(File.join(it, 'package.json'), base: root) }.uniq
62
+ files.filter_map do |file|
63
+ dir = File.dirname(file)
64
+ next if dir.split('/').include?('node_modules')
65
+
66
+ # FNM_EXTGLOB for braces, which pnpm and Bun expand, as Dir.glob does for the inclusions.
67
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
68
+ next if excluded.any? { File.fnmatch?(it[1..], dir, flags) }
69
+
70
+ [file, read_json(File.join(root, file))&.dig('name')]
71
+ end
72
+ end
73
+
74
+ def workspace_patterns(root, manager, app)
75
+ if manager == 'bun'
76
+ workspaces = app['workspaces']
77
+ Array(workspaces.is_a?(Hash) ? workspaces['packages'] : workspaces)
78
+ else
79
+ path = File.join(root, 'pnpm-workspace.yaml')
80
+ File.exist?(path) ? Array(ContextMap.yaml_packages(File.read(path))) : []
81
+ end
82
+ rescue Psych::SyntaxError
83
+ [] # the manager reports it
84
+ end
85
+
86
+ def read_json(path)
87
+ json = File.exist?(path) && ContextMap.read_json(path)
88
+ json.is_a?(Hash) ? json : nil
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../context_map'
5
+ require_relative '../bundled_gems'
6
+ require_relative '../dependency_context'
7
+ require_relative 'rules'
8
+
9
+ module Proscenium
10
+ module CLI
11
+ # The contexts the bundle calls for (#154): one per participating, installed gem, projected
12
+ # from its package.json after the author contract and the gem-to-gem rules. Computes; never
13
+ # writes.
14
+ class Contexts
15
+ DIR = File.join('.proscenium', 'packages')
16
+ ESCAPE = "Or, to install %<gem>s's NPM dependencies yourself, opt it out with " \
17
+ '`"proscenium": {"gemOverrides": {"%<gem>s": {"participate": false}}}` in ' \
18
+ "package.json, then add the packages it needs to your app's own dependencies."
19
+
20
+ # A gem's context: its JSON text, and the Git and URL dependencies it introduces.
21
+ Context = Struct.new(:gem, :json, :git_and_url, keyword_init: true)
22
+
23
+ attr_reader :contexts, :problems, :warnings
24
+
25
+ # `specs` are the participating installed specs by name; `root` is the app.
26
+ def initialize(root, specs)
27
+ @root = root
28
+ @specs = specs
29
+ @contexts = {}
30
+ @problems = []
31
+ @warnings = []
32
+ specs.each_value { add(it) }
33
+ end
34
+
35
+ def ok? = @problems.empty?
36
+
37
+ # The committed context directories under the app, by gem name.
38
+ def self.committed(root)
39
+ dir = File.join(root, DIR)
40
+ return {} unless Dir.exist?(dir)
41
+
42
+ Dir.children(dir).select { File.directory?(File.join(dir, it)) }.sort
43
+ .to_h { [it, File.join(dir, it, 'package.json')] }
44
+ end
45
+
46
+ private
47
+
48
+ def add(spec)
49
+ gem = spec.name
50
+ manifest_root = BundledGems.manifest_root(spec)
51
+ unless manifest_root
52
+ return problem('PSM-E-FRONTEND-ROOT', gem:,
53
+ root: spec.metadata['proscenium.frontend_root'])
54
+ end
55
+
56
+ manifest = read_manifest(gem, manifest_root, spec.full_gem_path) or return
57
+ fatal = Rules.check(gem, manifest, root: manifest_root)
58
+ warnings, fatal = fatal.partition { Rules::WARNINGS.include?(it.first) }
59
+ @warnings.concat(warnings)
60
+ fatal.each { |code, args| problem(code, **args) }
61
+ return unless fatal.empty?
62
+
63
+ manifest = link_gems(gem, spec, manifest) or return
64
+ context = DependencyContext.project(gem, manifest)
65
+ @contexts[gem] = Context.new(gem:, json: DependencyContext.to_json(context),
66
+ git_and_url: git_and_url(context))
67
+ end
68
+
69
+ def read_manifest(gem, manifest_root, gem_root)
70
+ path = File.join(manifest_root, 'package.json')
71
+ unless File.exist?(path)
72
+ return problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause: 'it is missing')
73
+ end
74
+ if (cause = Rules.manifest_file_problem(path, gem_root))
75
+ return problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause:)
76
+ end
77
+
78
+ manifest = ContextMap.read_json(path)
79
+ return manifest if manifest.is_a?(Hash)
80
+
81
+ problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause: 'it is not a JSON object')
82
+ rescue JSON::ParserError
83
+ problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause: 'it is not valid JSON')
84
+ end
85
+
86
+ # Rewrites references to other gems' contexts to `workspace:*`. A reference is kept only if
87
+ # the gemspec depends on the other gem and that gem participates: Bundler then guarantees it
88
+ # is locked at a compatible version. An optional peer whose gem does not participate is left
89
+ # out, as an absent optional peer would be.
90
+ def link_gems(gem, spec, manifest)
91
+ runtime = spec.runtime_dependencies.map(&:name)
92
+ optional = (manifest['peerDependenciesMeta'] || {}).select do |_, meta|
93
+ meta.is_a?(Hash) && meta['optional']
94
+ end.keys
95
+ ok = true
96
+ linked = Rules::DEPENDENCY_FIELDS.to_h do |field|
97
+ deps = (manifest[field] || {}).filter_map do |name, version|
98
+ next [name, version] unless name.start_with?('@rubygems/')
99
+
100
+ other = name.delete_prefix('@rubygems/')
101
+ participates = @specs.key?(other)
102
+ if field == 'peerDependencies' && optional.include?(name) && !participates
103
+ nil
104
+ elsif !runtime.include?(other)
105
+ ok = false
106
+ problem('PSM-E-CROSS-GEM', gem:, other:)
107
+ elsif !participates
108
+ ok = false
109
+ problem('PSM-E-CROSS-GEM-TARGET', gem:, other:)
110
+ else
111
+ [name, 'workspace:*']
112
+ end
113
+ end
114
+ [field, deps.to_h]
115
+ end
116
+ ok ? manifest.merge(linked) : nil
117
+ end
118
+
119
+ def git_and_url(context)
120
+ Rules::DEPENDENCY_FIELDS.flat_map { (context[it] || {}).to_a }
121
+ .select { |_, spec| spec.to_s.match?(%r{\A(?:github:|git\+|https://)}) }
122
+ .map { |name, spec| "#{name}@#{spec}" }
123
+ end
124
+
125
+ # Records a problem with the gem's own manifest, with the consumer's way out. Returns nil,
126
+ # so a caller can `return problem(...)` to stop.
127
+ def problem(code, **args)
128
+ @problems << [code, args.merge(escape: format(ESCAPE, gem: args.fetch(:gem)))]
129
+ nil
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module CLI
5
+ # A unified diff of a small text file's edit, which `install` prints before it writes one of
6
+ # the app's files. Line-based, over a longest common subsequence: fine for the files it is
7
+ # used on, which are a few dozen lines.
8
+ module Diff
9
+ MARKS = { same: ' ', del: '-', add: '+' }.freeze
10
+
11
+ module_function
12
+
13
+ # The diff of `before` to `after`, headed with `path`; empty when they are the same.
14
+ def unified(path, before, after, context: 3)
15
+ ops = lcs_ops(before.lines(chomp: true), after.lines(chomp: true))
16
+ hunks = hunks(ops, context)
17
+ return '' if hunks.empty?
18
+
19
+ header = "--- #{path}\n+++ #{path}\n"
20
+ header + hunks.map { |hunk| format_hunk(hunk) }.join
21
+ end
22
+
23
+ # Each line as [:same | :del | :add, text, line in old, line in new].
24
+ def lcs_ops(old, new)
25
+ table = lcs_table(old, new)
26
+ ops = []
27
+ i = j = 0
28
+ while i < old.size || j < new.size
29
+ if i < old.size && j < new.size && old[i] == new[j]
30
+ ops << [:same, old[i], i, j]
31
+ i += 1
32
+ j += 1
33
+ elsif j < new.size && (i == old.size || table[i][j + 1] >= table[i + 1][j])
34
+ ops << [:add, new[j], i, j]
35
+ j += 1
36
+ else
37
+ ops << [:del, old[i], i, j]
38
+ i += 1
39
+ end
40
+ end
41
+ ops
42
+ end
43
+
44
+ # The longest common subsequence lengths of every pair of suffixes.
45
+ def lcs_table(old, new)
46
+ table = Array.new(old.size + 1) { Array.new(new.size + 1, 0) }
47
+ (old.size - 1).downto(0) do |i|
48
+ (new.size - 1).downto(0) do |j|
49
+ table[i][j] = if old[i] == new[j]
50
+ table[i + 1][j + 1] + 1
51
+ else
52
+ [table[i + 1][j], table[i][j + 1]].max
53
+ end
54
+ end
55
+ end
56
+ table
57
+ end
58
+
59
+ def hunks(ops, context)
60
+ changed = ops.each_index.reject { ops[it][0] == :same }
61
+ return [] if changed.empty?
62
+
63
+ groups = changed.slice_when { |x, y| y - x > context * 2 }
64
+ groups.map do |group|
65
+ ops[[group.first - context, 0].max..[group.last + context, ops.size - 1].min]
66
+ end
67
+ end
68
+
69
+ def format_hunk(ops)
70
+ a_start = ops.first[2] + 1
71
+ b_start = ops.first[3] + 1
72
+ a_count = ops.count { it[0] != :add }
73
+ b_count = ops.count { it[0] != :del }
74
+ lines = ops.map { |op, text| "#{MARKS.fetch(op)}#{text}\n" }
75
+ "@@ -#{a_count.zero? ? a_start - 1 : a_start},#{a_count} " \
76
+ "+#{b_count.zero? ? b_start - 1 : b_start},#{b_count} @@\n#{lines.join}"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module CLI
5
+ # Every failure the CLI reports: a stable code, the exit status it maps to, and text that states
6
+ # the problem, its cause and the fix. The catalog is the single source; golden-output tests pin
7
+ # the human text and the JSON event for each entry (test/cli/golden/).
8
+ class Error < StandardError
9
+ EXIT = { success: 0, internal: 1, input: 2, unsupported: 3, drift: 4, integrity: 5,
10
+ native: 6, busy: 7, interrupted: 8 }.freeze
11
+
12
+ # code => [exit, message template, fix template]. Templates take keyword arguments.
13
+ CATALOG = {
14
+ 'PSM-E-USAGE' => [
15
+ :input, '%<detail>s',
16
+ 'Run `bundle exec proscenium --help` to see the commands and their options.'
17
+ ],
18
+ 'PSM-E-GEMSPEC' => [
19
+ :input, 'Expected one gemspec in %<path>s, but found %<found>s.',
20
+ "Run `proscenium gem check` from the gem's own directory, or pass it that directory " \
21
+ 'or a built .gem file.'
22
+ ],
23
+ 'PSM-E-NOT-OPTED-IN' => [
24
+ :input, '%<gem>s has not opted in to having Proscenium install its NPM ' \
25
+ 'dependencies.',
26
+ "Add `spec.metadata['proscenium.dependencies'] = 'true'` to its gemspec."
27
+ ],
28
+ 'PSM-E-FRONTEND-ROOT' => [
29
+ :input, "%<gem>s's proscenium.frontend_root (%<root>s) points outside the gem.",
30
+ "Set proscenium.frontend_root to a directory inside the gem, relative to the gem's root."
31
+ ],
32
+ 'PSM-E-MANIFEST' => [
33
+ :input, "%<gem>s's %<path>s can't be used, because %<cause>s.",
34
+ 'Ship a valid package.json there: a JSON object whose dependency fields map package ' \
35
+ 'names to version strings.'
36
+ ],
37
+ 'PSM-E-GEM-FILES' => [
38
+ :input, "%<gem>s's gemspec doesn't list %<files>s in spec.files, so the built gem " \
39
+ "won't include it.",
40
+ 'Add package.json, and every frontend file the gem serves, to spec.files.'
41
+ ],
42
+ 'PSM-E-NAME' => [
43
+ :input, "The gem name %<gem>s can't be used as a JavaScript package name " \
44
+ '(@rubygems/%<gem>s).',
45
+ 'JavaScript package names use only lowercase letters, digits and - . _ ~, are at most ' \
46
+ "214 characters long, and can't end in a dot or be a Windows device name such as con " \
47
+ 'or nul. The gem needs a valid name before Proscenium can install its dependencies.'
48
+ ],
49
+ 'PSM-E-WORKSPACES' => [
50
+ :input, "%<gem>s's package.json declares workspaces, which a gem's package.json " \
51
+ "can't do.",
52
+ "Remove \"workspaces\" from the gem's package.json."
53
+ ],
54
+ 'PSM-E-HOOK' => [
55
+ :input, '%<gem>s needs build steps at install time (%<hooks>s), and Proscenium never ' \
56
+ 'runs them.',
57
+ "Ship the gem's assets already built, and remove those install-time hooks."
58
+ ],
59
+ 'PSM-E-SPEC' => [
60
+ :input, "%<gem>s depends on %<name>s as \"%<spec>s\", which Proscenium doesn't allow.",
61
+ 'Use a version range or tag, an npm:<package> alias, a github:, git+https: or git+ssh: ' \
62
+ 'URL, or an https: tarball URL.'
63
+ ],
64
+ 'PSM-E-CREDENTIAL' => [
65
+ :input, "%<gem>s's dependency %<name>s has a password or token in its URL, which " \
66
+ 'would be committed to your repository.',
67
+ 'Remove it from the URL. Apps log in to private registries in their own .npmrc.'
68
+ ],
69
+ 'PSM-E-ALIAS' => [
70
+ :input, "%<gem>s aliases %<name>s to \"%<spec>s\", which is another gem's " \
71
+ 'dependency context.',
72
+ "Add the other gem to the gemspec's dependencies, and use @rubygems/<gem> directly."
73
+ ],
74
+ 'PSM-E-REACT' => [
75
+ :input, '%<gem>s lists %<packages>s as dependencies, so it would get its own copy and ' \
76
+ 'the page would load two.',
77
+ "List %<packages>s under peerDependencies instead, so the gem uses the app's copy."
78
+ ],
79
+ 'PSM-E-CROSS-GEM' => [
80
+ :input, "%<gem>s's package.json uses @rubygems/%<other>s, but its gemspec doesn't " \
81
+ 'depend on the %<other>s gem.',
82
+ "Add `spec.add_dependency '%<other>s'` to the gemspec, or stop using " \
83
+ '@rubygems/%<other>s.'
84
+ ],
85
+ 'PSM-E-NO-MANAGER' => [
86
+ :input, "Proscenium can't tell which package manager this app uses: there is no " \
87
+ 'packageManager field in package.json, and no pnpm-lock.yaml or bun.lock.',
88
+ 'Run again with --manager pnpm or --manager bun, or add a packageManager field to ' \
89
+ 'package.json.'
90
+ ],
91
+ 'PSM-E-MANAGER-CONFLICT' => [
92
+ :input, 'This app points to more than one package manager: %<signals>s.',
93
+ 'Keep just one: delete the other lockfile, or make packageManager in package.json ' \
94
+ 'match the lockfile. (--manager only decides when nothing else does.)'
95
+ ],
96
+ 'PSM-E-UNSUPPORTED-MANAGER' => [
97
+ :unsupported, 'This app uses %<manager>s, but Proscenium can only install ' \
98
+ "gems' NPM dependencies with pnpm or Bun.",
99
+ "Switch the app to pnpm or Bun, or keep adding gems' NPM dependencies to " \
100
+ 'package.json yourself.'
101
+ ],
102
+ 'PSM-E-BUN-LOCKB' => [
103
+ :unsupported, "This app only has bun.lockb, Bun's older binary lockfile, which " \
104
+ "Proscenium can't read.",
105
+ 'Run `bun install --save-text-lockfile` to write bun.lock, then commit it.'
106
+ ],
107
+ 'PSM-E-MANAGER-MISSING' => [
108
+ :unsupported, "%<manager>s isn't installed, or isn't on your PATH.",
109
+ 'Install %<manager>s the way this project normally does (Corepack, mise, Volta or its ' \
110
+ 'own installer), then try again.'
111
+ ],
112
+ 'PSM-E-MANAGER-VERSION' => [
113
+ :unsupported, "Proscenium doesn't support %<manager>s %<version>s.",
114
+ 'Use a supported version of %<manager>s (%<supported>s). To experiment locally with ' \
115
+ 'another, add --experimental-manager-version.'
116
+ ],
117
+ 'PSM-E-EXPERIMENTAL-FROZEN' => [
118
+ :unsupported, "--experimental-manager-version can't be combined with --frozen.",
119
+ 'Use a supported package manager version wherever --frozen runs, such as CI.'
120
+ ],
121
+ 'PSM-E-BUN-LINKER' => [
122
+ :unsupported, "bunfig.toml doesn't say which linker Bun uses (hoisted or isolated), " \
123
+ 'and adding gem dependency contexts could make Bun switch linkers.',
124
+ 'Add `linker = "hoisted"` (or "isolated", whichever your app uses today) to the ' \
125
+ '`[install]` table in bunfig.toml, creating the table if there is none.'
126
+ ],
127
+ 'PSM-E-NESTED-WORKSPACE' => [
128
+ :unsupported, 'This app is inside the JavaScript workspace at %<enclosing>s, and ' \
129
+ "Proscenium doesn't support that yet.",
130
+ "Keep adding gems' NPM dependencies to that workspace yourself, as you do today."
131
+ ],
132
+ 'PSM-E-REGISTRATION' => [
133
+ :input, "Proscenium couldn't add the gem dependency contexts to %<file>s without " \
134
+ 'rewriting the file.',
135
+ 'Check that %<file>s is valid (package.json must be strict JSON, with no comments or ' \
136
+ 'trailing commas). Then add ".proscenium/packages/*" yourself, to `packages` in ' \
137
+ 'pnpm-workspace.yaml or `workspaces` in package.json, and run ' \
138
+ '`bundle exec proscenium install` again.'
139
+ ],
140
+ 'PSM-E-REGISTRATION-EXCLUDED' => [
141
+ :input, '%<file>s excludes .proscenium/packages/ with a "!" pattern, so your package ' \
142
+ 'manager would never install the gem dependency contexts.',
143
+ 'Remove that pattern from %<file>s, then run `bundle exec proscenium install` again.'
144
+ ],
145
+ 'PSM-E-BUSY' => [
146
+ :busy, '%<holder>s is already running in this project.',
147
+ 'Wait for it to finish, then run your command again. If nothing is actually ' \
148
+ 'installing, delete .proscenium/manager, which an earlier run left behind.'
149
+ ],
150
+ 'PSM-E-NATIVE' => [
151
+ :native, '`%<command>s` failed (exit status %<status>s).',
152
+ 'Its output is above. Fix what it reports, then run `bundle exec proscenium install` ' \
153
+ "again. Proscenium hasn't changed your package manager or regenerated your lockfile."
154
+ ],
155
+ 'PSM-E-INTERRUPTED' => [
156
+ :interrupted, '`%<command>s` was stopped before it finished.',
157
+ 'Run `bundle exec proscenium install` again to finish.'
158
+ ],
159
+ 'PSM-E-CROSS-GEM-TARGET' => [
160
+ :input, "%<gem>s's package.json uses @rubygems/%<other>s, but %<other>s hasn't " \
161
+ 'opted in to having Proscenium install its NPM dependencies.',
162
+ 'Opt %<other>s in: its author adds proscenium.dependencies to its gemspec, or you add ' \
163
+ '`"proscenium": {"gemOverrides": {"%<other>s": {"participate": true}}}` to your ' \
164
+ 'package.json.'
165
+ ],
166
+ 'PSM-E-CONFIG' => [
167
+ :input, "Proscenium can't read its settings: %<detail>s.",
168
+ 'Fix package.json. Proscenium reads one setting from it: ' \
169
+ '`"proscenium": {"gemOverrides": {"<gem>": {"participate": true}}}`, with true or ' \
170
+ 'false for each gem.'
171
+ ],
172
+ 'PSM-E-PROBLEMS' => [
173
+ :input, 'The install stopped because of the problems above. Nothing was changed.',
174
+ 'Fix each one as it says, then run `bundle exec proscenium install` again.'
175
+ ],
176
+ 'PSM-E-DRIFT' => [
177
+ :drift, "Some files are out of date with your bundle:\n%<list>s",
178
+ 'Run `bundle exec proscenium install`, then commit the files it lists.'
179
+ ],
180
+ 'PSM-E-NOT-REGISTERED' => [
181
+ :drift, "%<file>s doesn't list .proscenium/packages/*, so %<manager>s won't install " \
182
+ "your gems' NPM dependencies.",
183
+ 'Run `bundle exec proscenium install`, then commit %<file>s.'
184
+ ],
185
+ 'PSM-E-GITIGNORE' => [
186
+ :drift, ".gitignore is missing some of Proscenium's lines, or a later rule hides the " \
187
+ 'dependency contexts you need to commit.',
188
+ 'Run `bundle exec proscenium install` to add them, then commit .gitignore.'
189
+ ],
190
+ 'PSM-E-LOCKFILE-MISSING' => [
191
+ :drift, '%<lockfile>s is missing.',
192
+ 'Run `bundle exec proscenium install`, then commit %<lockfile>s.'
193
+ ],
194
+ 'PSM-E-NOT-INSTALLED' => [
195
+ :drift, "Your NPM dependencies aren't installed: there is no node_modules " \
196
+ 'directory.',
197
+ 'Run `bundle exec proscenium install`, or `bundle exec proscenium install --frozen` on ' \
198
+ 'a fresh checkout or in CI.'
199
+ ],
200
+ 'PSM-E-REGISTRY-TARBALL' => [
201
+ :integrity, 'The lockfile installs %<packages>s from a registry or Git, instead of ' \
202
+ 'from the gem itself.',
203
+ 'Remove the registry or GitHub dependency on it from your package.json, since ' \
204
+ 'Gemfile.lock provides the gem now, then run `bundle exec proscenium install`.'
205
+ ],
206
+ 'PSM-E-WORKSPACE-MISSING' => [
207
+ :integrity, "%<manager>s didn't install the dependency contexts for %<gems>s.",
208
+ 'Check that `packages` in pnpm-workspace.yaml, or `workspaces` in package.json, ' \
209
+ 'lists ".proscenium/packages/*", then run `bundle exec proscenium install` again.'
210
+ ],
211
+ 'PSM-E-PEER-SPLIT' => [
212
+ :integrity, '%<gem>s and your app use different copies of %<package>s, so the page ' \
213
+ 'would load it twice.',
214
+ "Change your app's %<package>s to a version within %<gem>s's peer range, then run " \
215
+ '`bundle exec proscenium install` again.'
216
+ ],
217
+ 'PSM-E-COLLISION' => [
218
+ :input, "Some of your app's dependencies use a name a gem's dependency context " \
219
+ "needs:\n%<list>s",
220
+ 'Remove each one, since Gemfile.lock provides the gem now, or rename the package.'
221
+ ],
222
+ 'PSM-E-BUN-DEFAULT-TRUSTED' => [
223
+ :unsupported, "`bun pm default-trusted` didn't print Bun's default list of trusted " \
224
+ "packages, so Proscenium can't check whose install scripts Bun would run.",
225
+ 'Add `"trustedDependencies": []` to package.json, listing any packages whose install ' \
226
+ 'scripts your app needs, so Bun uses that list instead of its default.'
227
+ ],
228
+ 'PSM-E-TRUSTED-SOURCE' => [
229
+ :input, 'Some gems install a package from Git, a URL or an alias to another package, ' \
230
+ 'under a name your package manager trusts to run install scripts, so that ' \
231
+ "code's install script would run:\n%<list>s",
232
+ "Ask the gem's author to depend on the registry package instead, or opt the gem out " \
233
+ 'with `"proscenium": {"gemOverrides": {"<gem>": {"participate": false}}}` in ' \
234
+ 'package.json. pnpm trusts the packages pnpm-workspace.yaml approves builds for; Bun ' \
235
+ 'trusts trustedDependencies in package.json, or its own default list without one.'
236
+ ],
237
+ 'PSM-E-PLATFORM-VARIANT' => [
238
+ :unsupported, 'The platform-specific builds of %<variants>s differ from the one ' \
239
+ 'installed here in their NPM dependencies or whether they opt ' \
240
+ "in, or don't pass `proscenium gem check`.",
241
+ 'Make every platform build of the gem ship the same package.json dependencies and ' \
242
+ 'proscenium.dependencies setting, each passing `proscenium gem check`. Your lockfile ' \
243
+ 'can only hold one set.'
244
+ ],
245
+ 'PSM-E-UNC-SHIM' => [
246
+ :unsupported, "%<manager>s is installed as a .cmd script, and cmd.exe can't run in " \
247
+ "%<root>s because it's a network (UNC) path: it would run in " \
248
+ 'C:\\Windows instead.',
249
+ 'Map a drive letter to the share (`net use Z: \\\\server\\share`) and run from that ' \
250
+ 'drive, or install %<manager>s as a native executable.'
251
+ ],
252
+ 'PSM-E-OWNED-LINK' => [
253
+ :input, '%<paths>s is a symbolic link, so installing would write outside your app.',
254
+ 'Replace it with a real directory. Proscenium only writes inside .proscenium/.'
255
+ ],
256
+ 'PSM-E-OWNED-DIR' => [
257
+ :input, ".proscenium/packages/ contains things Proscenium didn't create: %<entries>s.",
258
+ 'Move them somewhere else. Proscenium manages that directory, and treats everything ' \
259
+ 'in it as a gem dependency context.'
260
+ ],
261
+ 'PSM-W-END-OF-LIFE' => [
262
+ :success, '%<manager>s %<version>s reaches end of life on %<eol>s, and a future ' \
263
+ 'Proscenium release may stop supporting it.',
264
+ 'Plan an upgrade to a newer %<manager>s version; the upgrade guide explains the steps.'
265
+ ],
266
+ 'PSM-E-INTERNAL' => [
267
+ :internal, 'Something went wrong inside Proscenium: %<detail>s',
268
+ 'This is a bug in Proscenium. Please report it at ' \
269
+ 'https://github.com/joelmoss/proscenium/issues, including the output above.'
270
+ ]
271
+ }.freeze
272
+
273
+ attr_reader :code, :fix, :details
274
+ # What the package manager printed, for a native failure: not shown, only searched.
275
+ attr_accessor :output
276
+
277
+ # `escape`, when given, is the consumer's way around a gem author's problem, appended to the
278
+ # fix.
279
+ def initialize(code, details: {}, escape: nil, **args)
280
+ @code = code
281
+ status, message, fix = CATALOG.fetch(code)
282
+ @status = status
283
+ @fix = [format(fix, **args), escape].compact.join(' ')
284
+ @details = details
285
+ super(format(message, **args))
286
+ end
287
+
288
+ def exit_status = EXIT.fetch(@status)
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems/package'
4
+ require 'zlib'
5
+ require_relative '../bundled_gems'
6
+ require_relative 'rules'
7
+
8
+ module Proscenium
9
+ module CLI
10
+ # A built gem's specification and package.json, read from its archive without unpacking it
11
+ # (#154). Nothing is extracted, so no entry name can write anywhere, and the manifest entry is
12
+ # read only if it is a regular file within the size limit (C29).
13
+ module GemArchive
14
+ module_function
15
+
16
+ # [spec, manifest text or nil, why it could not be read or nil].
17
+ def read(path)
18
+ spec = Gem::Package.new(path).spec
19
+ root = BundledGems.frontend_root(spec) || ''
20
+ wanted = root.empty? ? 'package.json' : File.join(root, 'package.json')
21
+ manifest = cause = nil
22
+ File.open(path, 'rb') do |io|
23
+ Gem::Package::TarReader.new(io).each do |entry|
24
+ next unless entry.full_name == 'data.tar.gz'
25
+
26
+ Zlib::GzipReader.wrap(entry) do |gz|
27
+ Gem::Package::TarReader.new(gz).each do |file|
28
+ manifest, cause = manifest_entry(file) if file.full_name == wanted
29
+ end
30
+ end
31
+ end
32
+ end
33
+ [spec, manifest, cause]
34
+ end
35
+
36
+ def manifest_entry(entry)
37
+ return [nil, 'it is not a regular file'] unless entry.file?
38
+ return [nil, 'it is larger than 1 MB'] if entry.header.size > Rules::MANIFEST_LIMIT
39
+
40
+ [entry.read, nil]
41
+ end
42
+ end
43
+ end
44
+ end