proscenium 0.26.0-aarch64-linux-gnu → 0.27.0-aarch64-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.
- checksums.yaml +4 -4
- data/README.md +50 -3
- data/lib/proscenium/app_package.rb +8 -4
- data/lib/proscenium/builder/request.rb +72 -0
- data/lib/proscenium/builder.rb +10 -74
- data/lib/proscenium/cli/bun_linker.rb +2 -2
- data/lib/proscenium/cli/collisions.rb +7 -9
- data/lib/proscenium/cli/error.rb +58 -265
- data/lib/proscenium/cli/gem_archive.rb +4 -2
- data/lib/proscenium/cli/gem_check.rb +13 -11
- data/lib/proscenium/cli/gitignore.rb +2 -1
- data/lib/proscenium/cli/inspect.rb +12 -11
- data/lib/proscenium/cli/install.rb +35 -46
- data/lib/proscenium/cli/manager.rb +6 -6
- data/lib/proscenium/cli/platform_variants.rb +8 -8
- data/lib/proscenium/cli/pnpm_approvals.rb +3 -3
- data/lib/proscenium/cli/pnpm_workspace.rb +15 -14
- data/lib/proscenium/cli/project_lock.rb +3 -3
- data/lib/proscenium/cli/registration.rb +5 -5
- data/lib/proscenium/cli/reporter.rb +2 -2
- data/lib/proscenium/cli/verify.rb +9 -50
- data/lib/proscenium/cli.rb +5 -4
- data/lib/proscenium/contexts/map.rb +183 -0
- data/lib/proscenium/contexts/mapping_generation.rb +68 -0
- data/lib/proscenium/contexts/messages.rb +276 -0
- data/lib/proscenium/{cli/contexts.rb → contexts/plan.rb} +12 -12
- data/lib/proscenium/contexts/projection.rb +77 -0
- data/lib/proscenium/{cli → contexts}/rules.rb +1 -1
- data/lib/proscenium/contexts/stale.rb +174 -0
- data/lib/proscenium/contexts.rb +16 -0
- data/lib/proscenium/css_module/path.rb +1 -0
- data/lib/proscenium/css_module/transformer.rb +15 -0
- data/lib/proscenium/css_module.rb +3 -2
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/helper.rb +6 -1
- data/lib/proscenium/importer.rb +13 -1
- data/lib/proscenium/middleware/base.rb +0 -4
- data/lib/proscenium/middleware.rb +0 -2
- data/lib/proscenium/railtie.rb +4 -4
- data/lib/proscenium/resolver.rb +3 -1
- data/lib/proscenium/side_load.rb +6 -2
- data/lib/proscenium/source_path.rb +2 -0
- data/lib/proscenium/utils.rb +2 -1
- data/lib/proscenium/version.rb +1 -1
- data/lib/proscenium.rb +5 -5
- metadata +10 -7
- data/lib/proscenium/context_map.rb +0 -181
- data/lib/proscenium/dependency_context.rb +0 -74
- data/lib/proscenium/mapping_generation.rb +0 -64
- data/lib/proscenium/stale_contexts.rb +0 -118
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative '../app_package'
|
|
6
|
+
require_relative '../bundled_gems'
|
|
7
|
+
require_relative '../yaml_tree'
|
|
8
|
+
|
|
9
|
+
module Proscenium
|
|
10
|
+
module Contexts
|
|
11
|
+
# What the engine passes Go about gem dependency contexts (#154): which gems resolve their
|
|
12
|
+
# bare imports from `.proscenium/packages/<gem>/`, and which of the app's own packages keep
|
|
13
|
+
# their link paths. Built from committed files and Bundler only. Like BundledGems, it loads
|
|
14
|
+
# nothing the `proscenium` CLI may not.
|
|
15
|
+
module Map
|
|
16
|
+
CONTEXTS = File.join('.proscenium', 'packages')
|
|
17
|
+
REGISTRATION = '.proscenium/packages/*'
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Where the `proscenium` CLI keeps contexts, its lock and its marker: the bundle's root, which
|
|
22
|
+
# need not be Rails.root.
|
|
23
|
+
def project_root = Bundler.root.to_s
|
|
24
|
+
|
|
25
|
+
# The config keys Go reads, for the app at `root`. Read once per mapping generation: a value
|
|
26
|
+
# read during an older one is kept under that one's key, never handed to the next.
|
|
27
|
+
def config(root, generation = 0)
|
|
28
|
+
@config ||= {}
|
|
29
|
+
@config[[root, generation]] ||= { DependencyContexts: contexts(root),
|
|
30
|
+
AppLocalPackages: AppPackage.local_packages(root) }.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset! = @config = {}
|
|
34
|
+
|
|
35
|
+
# Each registering file, with the lockfile and the name of the manager that reads it.
|
|
36
|
+
REGISTRARS = { 'pnpm-workspace.yaml' => %w[pnpm-lock.yaml pnpm],
|
|
37
|
+
'package.json' => %w[bun.lock bun] }.freeze
|
|
38
|
+
|
|
39
|
+
# Whether the app at `root` adopted gem dependency contexts: pnpm's workspace file registers
|
|
40
|
+
# them, or package.json does in a Bun app. Each counts only where its manager installs: when
|
|
41
|
+
# packageManager names no other manager, and with that manager's lockfile or no other
|
|
42
|
+
# manager's, as an app missing its lockfile is an incomplete checkout, which must not silently
|
|
43
|
+
# switch the contexts off.
|
|
44
|
+
def adopted?(root) = !registrar(root).nil?
|
|
45
|
+
|
|
46
|
+
# The file whose registration makes the app at `root` adopted, or nil.
|
|
47
|
+
def registrar(root)
|
|
48
|
+
named = AppPackage.package_manager(root)
|
|
49
|
+
REGISTRARS.find do |file, (own, manager)|
|
|
50
|
+
# bun.lockb is Bun's too, until bun.lock replaces it.
|
|
51
|
+
others = [*UNSUPPORTED.keys, *REGISTRARS.values.map(&:first)] - [own] +
|
|
52
|
+
(manager == 'bun' ? [] : ['bun.lockb'])
|
|
53
|
+
registers?(root, file) && [nil, manager].include?(named) &&
|
|
54
|
+
(File.exist?(File.join(root, own)) || others.none? { File.exist?(File.join(root, it)) })
|
|
55
|
+
end&.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether `file` lists the contexts: pnpm-workspace.yaml's `packages`, or package.json's
|
|
59
|
+
# `workspaces` (an array, or an object's `packages`). Parsed, so a commented-out line does not
|
|
60
|
+
# count. For the engine a file that does not parse counts if it names them, so a parse failure
|
|
61
|
+
# never silently switches off the context map and the stale check; `strict` (the CLI, which
|
|
62
|
+
# must register a file its manager can read) counts only a file that parses.
|
|
63
|
+
def registers?(root, file)
|
|
64
|
+
path = File.join(root, file)
|
|
65
|
+
File.exist?(path) && registered_in?(file, File.read(path))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def registered_in?(file, text, strict: false)
|
|
69
|
+
text = utf8(text).delete_prefix(BOM)
|
|
70
|
+
list = file.end_with?('.yaml') ? yaml_packages(text) : json_workspaces(text)
|
|
71
|
+
list.is_a?(Array) && list.any? { registration?(it) } && !excludes?(list)
|
|
72
|
+
rescue Psych::SyntaxError, JSON::ParserError
|
|
73
|
+
return false if strict
|
|
74
|
+
|
|
75
|
+
file.end_with?('.yaml') ? text.include?(REGISTRATION) : AppPackage.workspaces?(text)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Whether the list has a `!` pattern that takes a context out: pnpm and Bun apply one whatever
|
|
79
|
+
# its order, so the contexts would never be installed. Generous: `*` crosses `/`, and any
|
|
80
|
+
# segment of the pattern may be a gem's directory.
|
|
81
|
+
def excludes?(list)
|
|
82
|
+
Array(list).grep(/\A!/).any? do |entry|
|
|
83
|
+
pattern = entry[1..].delete_prefix('./').chomp('/')
|
|
84
|
+
# A segment with a wildcard, class or brace, such as `[ah]ue`, may match any gem.
|
|
85
|
+
wide = pattern.split('/').map { it.match?(/[*?\[{]/) ? '*' : it }.join('/')
|
|
86
|
+
['gem', *pattern.split('/')].product([pattern, wide]).any? do |name, glob|
|
|
87
|
+
File.fnmatch?(glob, "#{CONTEXTS}/#{name}", GLOB)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
GLOB = File::FNM_EXTGLOB | File::FNM_DOTMATCH
|
|
93
|
+
|
|
94
|
+
def json_workspaces(text)
|
|
95
|
+
parsed = JSON.parse(text)
|
|
96
|
+
list = parsed['workspaces'] if parsed.is_a?(Hash)
|
|
97
|
+
list.is_a?(Hash) ? list['packages'] : list
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# pnpm-workspace.yaml's `packages`: each entry's scalar text, or nil for one that is not a
|
|
101
|
+
# scalar; nil when the document is not a mapping or the key is missing or not a list. Read
|
|
102
|
+
# from the parse tree and never built into Ruby objects, so no alias can make it slow and no
|
|
103
|
+
# tag or date can raise. Raises Psych::SyntaxError.
|
|
104
|
+
def yaml_packages(text)
|
|
105
|
+
list, anchors = YamlTree.value(text, 'packages')
|
|
106
|
+
return unless list.is_a?(Psych::Nodes::Sequence)
|
|
107
|
+
|
|
108
|
+
list.children.map do |entry|
|
|
109
|
+
entry = YamlTree.node(entry, anchors)
|
|
110
|
+
entry.value if entry.is_a?(Psych::Nodes::Scalar)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# pnpm and Bun read `./<pattern>` and `<pattern>/` as the pattern.
|
|
115
|
+
def registration?(entry)
|
|
116
|
+
entry.is_a?(String) && entry.delete_prefix('./').chomp('/') == REGISTRATION
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
BOM = "\uFEFF"
|
|
120
|
+
|
|
121
|
+
# A JSON file as Node, pnpm and Bun read it, a leading byte order mark included.
|
|
122
|
+
def read_json(path) = parse_json(File.read(path))
|
|
123
|
+
def parse_json(text) = JSON.parse(utf8(text).delete_prefix(BOM))
|
|
124
|
+
|
|
125
|
+
# Text as UTF-8, whatever encoding the caller's locale gave it.
|
|
126
|
+
def utf8(text) = text.dup.force_encoding(Encoding::UTF_8)
|
|
127
|
+
|
|
128
|
+
# Gem name => absolute context directory for each participating gem, when the app at `root`
|
|
129
|
+
# adopted dependency contexts; empty before. A gem whose context is missing is mapped all the
|
|
130
|
+
# same: its imports then fail by name rather than fall back to the app's packages.
|
|
131
|
+
def contexts(root, specs = BundledGems.installed_specs)
|
|
132
|
+
return {} unless adopted?(root)
|
|
133
|
+
|
|
134
|
+
BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys.to_h do |gem|
|
|
135
|
+
[gem, File.join(root, CONTEXTS, gem)]
|
|
136
|
+
end
|
|
137
|
+
rescue BundledGems::ConfigError
|
|
138
|
+
{} # Stale reports it, so every build is refused with the reason
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
INSTALLING = File.join('.proscenium', 'installing')
|
|
142
|
+
LOCK = File.join('.proscenium', 'lock')
|
|
143
|
+
INSTALLING_MESSAGE = '`bundle exec proscenium install` is running, or stopped before it ' \
|
|
144
|
+
'finished. Wait for it, or run it again.'
|
|
145
|
+
|
|
146
|
+
# Whether an install is under way in the app at `root`, or stopped before it finished: its
|
|
147
|
+
# marker is there, or another process holds the project lock. The engine refuses to build or
|
|
148
|
+
# resolve meanwhile (C34). The probe takes a shared lock, so it never stops an install
|
|
149
|
+
# starting; the CLI retries a lock it finds taken for a moment. About 16 µs.
|
|
150
|
+
def installing?(root)
|
|
151
|
+
return true if File.exist?(File.join(root, INSTALLING))
|
|
152
|
+
|
|
153
|
+
lock = File.join(root, LOCK)
|
|
154
|
+
File.exist?(lock) && File.open(lock, File::RDONLY) { !it.flock(File::LOCK_SH | File::LOCK_NB) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Each file that says the app uses an unsupported manager, as Manager.select reads them.
|
|
158
|
+
UNSUPPORTED = { 'yarn.lock' => 'Yarn', '.yarnrc.yml' => 'Yarn', 'package-lock.json' => 'npm',
|
|
159
|
+
'npm-shrinkwrap.json' => 'npm' }.freeze
|
|
160
|
+
|
|
161
|
+
# The one notice a process logs at boot when gems opt in but the app has not adopted
|
|
162
|
+
# dependency contexts, or nil. Those gems' imports keep resolving as they always have, so
|
|
163
|
+
# upgrading Proscenium never breaks an app that has not run `proscenium install` (C52).
|
|
164
|
+
def adoption_notice(root, specs = BundledGems.installed_specs)
|
|
165
|
+
return if adopted?(root)
|
|
166
|
+
|
|
167
|
+
gems = BundledGems.participating(specs, overrides: BundledGems.overrides(root)).keys
|
|
168
|
+
return if gems.empty?
|
|
169
|
+
|
|
170
|
+
opted = "#{gems.join(', ')} opt in to installing their NPM dependencies " \
|
|
171
|
+
'through Proscenium'
|
|
172
|
+
manager = UNSUPPORTED.find { |file, _| File.exist?(File.join(root, file)) }&.last
|
|
173
|
+
if manager
|
|
174
|
+
"#{opted}, but #{manager} is not supported, so the app keeps managing them."
|
|
175
|
+
else
|
|
176
|
+
"#{opted}. Run `bundle exec proscenium install` to install them."
|
|
177
|
+
end
|
|
178
|
+
rescue BundledGems::ConfigError => e
|
|
179
|
+
e.message
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../bundled_gems'
|
|
4
|
+
require_relative 'map'
|
|
5
|
+
require_relative 'stale'
|
|
6
|
+
|
|
7
|
+
module Proscenium
|
|
8
|
+
module Contexts
|
|
9
|
+
# Which mapping generation a build uses (#154). A generation is the context map and the
|
|
10
|
+
# staleness verdict, read once and kept, so every build sees one consistent view. In production
|
|
11
|
+
# a generation lasts the process: restart after a deploy. In development and test, `refresh`
|
|
12
|
+
# looks at most once a second for a change to the files the map is built from, and starts a new
|
|
13
|
+
# generation when one changed, such as `proscenium install` finishing.
|
|
14
|
+
#
|
|
15
|
+
# Gemfile.lock is watched, but a gem added or upgraded still needs a restart: Bundler loads the
|
|
16
|
+
# bundle once per process.
|
|
17
|
+
module MappingGeneration
|
|
18
|
+
# The lockfiles too: with contexts adopted, resolved paths carry each package's version.
|
|
19
|
+
WATCHED = ['Gemfile.lock', 'package.json', 'pnpm-workspace.yaml', '.npmrc',
|
|
20
|
+
'bunfig.toml', 'pnpm-lock.yaml', 'bun.lock', Map::INSTALLING].freeze
|
|
21
|
+
EVERY = 1.0
|
|
22
|
+
LOCK = Mutex.new
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# The current generation number, starting a new one first if a watched file changed and the
|
|
27
|
+
# last look was at least `every` seconds ago. Returns [number, whether it just changed].
|
|
28
|
+
def refresh(root, every: EVERY)
|
|
29
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
30
|
+
LOCK.synchronize do
|
|
31
|
+
@looked ||= {}
|
|
32
|
+
@number ||= 0
|
|
33
|
+
at, before = @looked[root]
|
|
34
|
+
return [@number, false] if at && now - at < every
|
|
35
|
+
|
|
36
|
+
after = signature(root)
|
|
37
|
+
@looked[root] = [now, after]
|
|
38
|
+
return [@number, false] if before.nil? || before == after
|
|
39
|
+
|
|
40
|
+
Map.reset!
|
|
41
|
+
Stale.reset!
|
|
42
|
+
[@number += 1, true]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The modification time of every file the map is built from, by path, nil for a missing one.
|
|
47
|
+
def signature(root)
|
|
48
|
+
files = WATCHED.map { File.join(root, it) } +
|
|
49
|
+
Dir.glob(File.join(root, Map::CONTEXTS, '*', 'package.json')) +
|
|
50
|
+
manifests(root)
|
|
51
|
+
files.sort.to_h { [it, File.exist?(it) ? File.mtime(it).to_f : nil] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Each participating gem's package.json: editing a path gem's manifest makes its context
|
|
55
|
+
# stale.
|
|
56
|
+
def manifests(root)
|
|
57
|
+
participating = BundledGems.participating(BundledGems.installed_specs,
|
|
58
|
+
overrides: BundledGems.overrides(root))
|
|
59
|
+
participating.values.filter_map { BundledGems.manifest_root(it) }
|
|
60
|
+
.map { File.join(it, 'package.json') }
|
|
61
|
+
rescue BundledGems::ConfigError
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reset! = LOCK.synchronize { @looked = {} }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
module Contexts
|
|
5
|
+
# The text of every Proscenium error and warning, by code: the problem, its cause and the fix.
|
|
6
|
+
# Templates take keyword arguments. The model reports a problem as a `[code, args]` pair and
|
|
7
|
+
# renders it here; the CLI's Error adds the exit status (cli/error.rb). Golden-output tests pin
|
|
8
|
+
# the text of each entry (test/cli/golden/).
|
|
9
|
+
class Messages
|
|
10
|
+
# code => [message template, fix template].
|
|
11
|
+
TEXT = {
|
|
12
|
+
'PSM-E-USAGE' => [
|
|
13
|
+
'%<detail>s',
|
|
14
|
+
'Run `bundle exec proscenium --help` to see the commands and their options.'
|
|
15
|
+
],
|
|
16
|
+
'PSM-E-GEMSPEC' => [
|
|
17
|
+
'Expected one gemspec in %<path>s, but found %<found>s.',
|
|
18
|
+
"Run `proscenium gem check` from the gem's own directory, or pass it that directory " \
|
|
19
|
+
'or a built .gem file.'
|
|
20
|
+
],
|
|
21
|
+
'PSM-E-NOT-OPTED-IN' => [
|
|
22
|
+
'%<gem>s has not opted in to having Proscenium install its NPM ' \
|
|
23
|
+
'dependencies.',
|
|
24
|
+
"Add `spec.metadata['proscenium.dependencies'] = 'true'` to its gemspec."
|
|
25
|
+
],
|
|
26
|
+
'PSM-E-FRONTEND-ROOT' => [
|
|
27
|
+
"%<gem>s's proscenium.frontend_root (%<root>s) points outside the gem.",
|
|
28
|
+
"Set proscenium.frontend_root to a directory inside the gem, relative to the gem's root."
|
|
29
|
+
],
|
|
30
|
+
'PSM-E-MANIFEST' => [
|
|
31
|
+
"%<gem>s's %<path>s can't be used, because %<cause>s.",
|
|
32
|
+
'Ship a valid package.json there: a JSON object whose dependency fields map package ' \
|
|
33
|
+
'names to version strings.'
|
|
34
|
+
],
|
|
35
|
+
'PSM-E-GEM-FILES' => [
|
|
36
|
+
"%<gem>s's gemspec doesn't list %<files>s in spec.files, so the built gem " \
|
|
37
|
+
"won't include it.",
|
|
38
|
+
'Add package.json, and every frontend file the gem serves, to spec.files.'
|
|
39
|
+
],
|
|
40
|
+
'PSM-E-NAME' => [
|
|
41
|
+
"The gem name %<gem>s can't be used as a JavaScript package name " \
|
|
42
|
+
'(@rubygems/%<gem>s).',
|
|
43
|
+
'JavaScript package names use only lowercase letters, digits and - . _ ~, are at most ' \
|
|
44
|
+
"214 characters long, and can't end in a dot or be a Windows device name such as con " \
|
|
45
|
+
'or nul. The gem needs a valid name before Proscenium can install its dependencies.'
|
|
46
|
+
],
|
|
47
|
+
'PSM-E-WORKSPACES' => [
|
|
48
|
+
"%<gem>s's package.json declares workspaces, which a gem's package.json " \
|
|
49
|
+
"can't do.",
|
|
50
|
+
"Remove \"workspaces\" from the gem's package.json."
|
|
51
|
+
],
|
|
52
|
+
'PSM-E-HOOK' => [
|
|
53
|
+
'%<gem>s needs build steps at install time (%<hooks>s), and Proscenium never ' \
|
|
54
|
+
'runs them.',
|
|
55
|
+
"Ship the gem's assets already built, and remove those install-time hooks."
|
|
56
|
+
],
|
|
57
|
+
'PSM-E-SPEC' => [
|
|
58
|
+
"%<gem>s depends on %<name>s as \"%<spec>s\", which Proscenium doesn't allow.",
|
|
59
|
+
'Use a version range or tag, an npm:<package> alias, a github:, git+https: or git+ssh: ' \
|
|
60
|
+
'URL, or an https: tarball URL.'
|
|
61
|
+
],
|
|
62
|
+
'PSM-E-CREDENTIAL' => [
|
|
63
|
+
"%<gem>s's dependency %<name>s has a password or token in its URL, which " \
|
|
64
|
+
'would be committed to your repository.',
|
|
65
|
+
'Remove it from the URL. Apps log in to private registries in their own .npmrc.'
|
|
66
|
+
],
|
|
67
|
+
'PSM-E-ALIAS' => [
|
|
68
|
+
"%<gem>s aliases %<name>s to \"%<spec>s\", which is another gem's " \
|
|
69
|
+
'dependency context.',
|
|
70
|
+
"Add the other gem to the gemspec's dependencies, and use @rubygems/<gem> directly."
|
|
71
|
+
],
|
|
72
|
+
'PSM-E-REACT' => [
|
|
73
|
+
'%<gem>s lists %<packages>s as dependencies, so it would get its own copy and ' \
|
|
74
|
+
'the page would load two.',
|
|
75
|
+
"List %<packages>s under peerDependencies instead, so the gem uses the app's copy."
|
|
76
|
+
],
|
|
77
|
+
'PSM-E-CROSS-GEM' => [
|
|
78
|
+
"%<gem>s's package.json uses @rubygems/%<other>s, but its gemspec doesn't " \
|
|
79
|
+
'depend on the %<other>s gem.',
|
|
80
|
+
"Add `spec.add_dependency '%<other>s'` to the gemspec, or stop using " \
|
|
81
|
+
'@rubygems/%<other>s.'
|
|
82
|
+
],
|
|
83
|
+
'PSM-E-NO-MANAGER' => [
|
|
84
|
+
"Proscenium can't tell which package manager this app uses: there is no " \
|
|
85
|
+
'packageManager field in package.json, and no pnpm-lock.yaml or bun.lock.',
|
|
86
|
+
'Run again with --manager pnpm or --manager bun, or add a packageManager field to ' \
|
|
87
|
+
'package.json.'
|
|
88
|
+
],
|
|
89
|
+
'PSM-E-MANAGER-CONFLICT' => [
|
|
90
|
+
'This app points to more than one package manager: %<signals>s.',
|
|
91
|
+
'Keep just one: delete the other lockfile, or make packageManager in package.json ' \
|
|
92
|
+
'match the lockfile. (--manager only decides when nothing else does.)'
|
|
93
|
+
],
|
|
94
|
+
'PSM-E-UNSUPPORTED-MANAGER' => [
|
|
95
|
+
'This app uses %<manager>s, but Proscenium can only install ' \
|
|
96
|
+
"gems' NPM dependencies with pnpm or Bun.",
|
|
97
|
+
"Switch the app to pnpm or Bun, or keep adding gems' NPM dependencies to " \
|
|
98
|
+
'package.json yourself.'
|
|
99
|
+
],
|
|
100
|
+
'PSM-E-BUN-LOCKB' => [
|
|
101
|
+
"This app only has bun.lockb, Bun's older binary lockfile, which " \
|
|
102
|
+
"Proscenium can't read.",
|
|
103
|
+
'Run `bun install --save-text-lockfile` to write bun.lock, then commit it.'
|
|
104
|
+
],
|
|
105
|
+
'PSM-E-MANAGER-MISSING' => [
|
|
106
|
+
"%<manager>s isn't installed, or isn't on your PATH.",
|
|
107
|
+
'Install %<manager>s the way this project normally does (Corepack, mise, Volta or its ' \
|
|
108
|
+
'own installer), then try again.'
|
|
109
|
+
],
|
|
110
|
+
'PSM-E-MANAGER-VERSION' => [
|
|
111
|
+
"Proscenium doesn't support %<manager>s %<version>s.",
|
|
112
|
+
'Use a supported version of %<manager>s (%<supported>s). To experiment locally with ' \
|
|
113
|
+
'another, add --experimental-manager-version.'
|
|
114
|
+
],
|
|
115
|
+
'PSM-E-EXPERIMENTAL-FROZEN' => [
|
|
116
|
+
"--experimental-manager-version can't be combined with --frozen.",
|
|
117
|
+
'Use a supported package manager version wherever --frozen runs, such as CI.'
|
|
118
|
+
],
|
|
119
|
+
'PSM-E-BUN-LINKER' => [
|
|
120
|
+
"bunfig.toml doesn't say which linker Bun uses (hoisted or isolated), " \
|
|
121
|
+
'and adding gem dependency contexts could make Bun switch linkers.',
|
|
122
|
+
'Add `linker = "hoisted"` (or "isolated", whichever your app uses today) to the ' \
|
|
123
|
+
'`[install]` table in bunfig.toml, creating the table if there is none.'
|
|
124
|
+
],
|
|
125
|
+
'PSM-E-NESTED-WORKSPACE' => [
|
|
126
|
+
'This app is inside the JavaScript workspace at %<enclosing>s, and ' \
|
|
127
|
+
"Proscenium doesn't support that yet.",
|
|
128
|
+
"Keep adding gems' NPM dependencies to that workspace yourself, as you do today."
|
|
129
|
+
],
|
|
130
|
+
'PSM-E-REGISTRATION' => [
|
|
131
|
+
"Proscenium couldn't add the gem dependency contexts to %<file>s without " \
|
|
132
|
+
'rewriting the file.',
|
|
133
|
+
'Check that %<file>s is valid (package.json must be strict JSON, with no comments or ' \
|
|
134
|
+
'trailing commas). Then add ".proscenium/packages/*" yourself, to `packages` in ' \
|
|
135
|
+
'pnpm-workspace.yaml or `workspaces` in package.json, and run ' \
|
|
136
|
+
'`bundle exec proscenium install` again.'
|
|
137
|
+
],
|
|
138
|
+
'PSM-E-REGISTRATION-EXCLUDED' => [
|
|
139
|
+
'%<file>s excludes .proscenium/packages/ with a "!" pattern, so your package ' \
|
|
140
|
+
'manager would never install the gem dependency contexts.',
|
|
141
|
+
'Remove that pattern from %<file>s, then run `bundle exec proscenium install` again.'
|
|
142
|
+
],
|
|
143
|
+
'PSM-E-BUSY' => [
|
|
144
|
+
'%<holder>s is already running in this project.',
|
|
145
|
+
'Wait for it to finish, then run your command again. If nothing is actually ' \
|
|
146
|
+
'installing, delete .proscenium/manager, which an earlier run left behind.'
|
|
147
|
+
],
|
|
148
|
+
'PSM-E-NATIVE' => [
|
|
149
|
+
'`%<command>s` failed (exit status %<status>s).',
|
|
150
|
+
'Its output is above. Fix what it reports, then run `bundle exec proscenium install` ' \
|
|
151
|
+
"again. Proscenium hasn't changed your package manager or regenerated your lockfile."
|
|
152
|
+
],
|
|
153
|
+
'PSM-E-INTERRUPTED' => [
|
|
154
|
+
'`%<command>s` was stopped before it finished.',
|
|
155
|
+
'Run `bundle exec proscenium install` again to finish.'
|
|
156
|
+
],
|
|
157
|
+
'PSM-E-CROSS-GEM-TARGET' => [
|
|
158
|
+
"%<gem>s's package.json uses @rubygems/%<other>s, but %<other>s hasn't " \
|
|
159
|
+
'opted in to having Proscenium install its NPM dependencies.',
|
|
160
|
+
'Opt %<other>s in: its author adds proscenium.dependencies to its gemspec, or you add ' \
|
|
161
|
+
'`"proscenium": {"gemOverrides": {"%<other>s": {"participate": true}}}` to your ' \
|
|
162
|
+
'package.json.'
|
|
163
|
+
],
|
|
164
|
+
'PSM-E-CONFIG' => [
|
|
165
|
+
"Proscenium can't read its settings: %<detail>s.",
|
|
166
|
+
'Fix package.json. Proscenium reads one setting from it: ' \
|
|
167
|
+
'`"proscenium": {"gemOverrides": {"<gem>": {"participate": true}}}`, with true or ' \
|
|
168
|
+
'false for each gem.'
|
|
169
|
+
],
|
|
170
|
+
'PSM-E-PROBLEMS' => [
|
|
171
|
+
'The install stopped because of the problems above. Nothing was changed.',
|
|
172
|
+
'Fix each one as it says, then run `bundle exec proscenium install` again.'
|
|
173
|
+
],
|
|
174
|
+
'PSM-E-DRIFT' => [
|
|
175
|
+
"Some files are out of date with your bundle:\n%<list>s",
|
|
176
|
+
'Run `bundle exec proscenium install`, then commit the files it lists.'
|
|
177
|
+
],
|
|
178
|
+
'PSM-E-NOT-REGISTERED' => [
|
|
179
|
+
"%<file>s doesn't list .proscenium/packages/*, so %<manager>s won't install " \
|
|
180
|
+
"your gems' NPM dependencies.",
|
|
181
|
+
'Run `bundle exec proscenium install`, then commit %<file>s.'
|
|
182
|
+
],
|
|
183
|
+
'PSM-E-GITIGNORE' => [
|
|
184
|
+
".gitignore is missing some of Proscenium's lines, or a later rule hides the " \
|
|
185
|
+
'dependency contexts you need to commit.',
|
|
186
|
+
'Run `bundle exec proscenium install` to add them, then commit .gitignore.'
|
|
187
|
+
],
|
|
188
|
+
'PSM-E-LOCKFILE-MISSING' => [
|
|
189
|
+
'%<lockfile>s is missing.',
|
|
190
|
+
'Run `bundle exec proscenium install`, then commit %<lockfile>s.'
|
|
191
|
+
],
|
|
192
|
+
'PSM-E-NOT-INSTALLED' => [
|
|
193
|
+
"Your NPM dependencies aren't installed: there is no node_modules " \
|
|
194
|
+
'directory.',
|
|
195
|
+
'Run `bundle exec proscenium install`, or `bundle exec proscenium install --frozen` on ' \
|
|
196
|
+
'a fresh checkout or in CI.'
|
|
197
|
+
],
|
|
198
|
+
'PSM-E-REGISTRY-TARBALL' => [
|
|
199
|
+
'The lockfile installs %<packages>s from a registry or Git, instead of ' \
|
|
200
|
+
'from the gem itself.',
|
|
201
|
+
'Remove the registry or GitHub dependency on it from your package.json, since ' \
|
|
202
|
+
'Gemfile.lock provides the gem now, then run `bundle exec proscenium install`.'
|
|
203
|
+
],
|
|
204
|
+
'PSM-E-WORKSPACE-MISSING' => [
|
|
205
|
+
"%<manager>s didn't install the dependency contexts for %<gems>s.",
|
|
206
|
+
'Check that `packages` in pnpm-workspace.yaml, or `workspaces` in package.json, ' \
|
|
207
|
+
'lists ".proscenium/packages/*", then run `bundle exec proscenium install` again.'
|
|
208
|
+
],
|
|
209
|
+
'PSM-E-PEER-SPLIT' => [
|
|
210
|
+
'%<gem>s and your app use different copies of %<package>s, so the page ' \
|
|
211
|
+
'would load it twice.',
|
|
212
|
+
"Change your app's %<package>s to a version within %<gem>s's peer range, then run " \
|
|
213
|
+
'`bundle exec proscenium install` again.'
|
|
214
|
+
],
|
|
215
|
+
'PSM-E-COLLISION' => [
|
|
216
|
+
"Some of your app's dependencies use a name a gem's dependency context " \
|
|
217
|
+
"needs:\n%<list>s",
|
|
218
|
+
'Remove each one, since Gemfile.lock provides the gem now, or rename the package.'
|
|
219
|
+
],
|
|
220
|
+
'PSM-E-BUN-DEFAULT-TRUSTED' => [
|
|
221
|
+
"`bun pm default-trusted` didn't print Bun's default list of trusted " \
|
|
222
|
+
"packages, so Proscenium can't check whose install scripts Bun would run.",
|
|
223
|
+
'Add `"trustedDependencies": []` to package.json, listing any packages whose install ' \
|
|
224
|
+
'scripts your app needs, so Bun uses that list instead of its default.'
|
|
225
|
+
],
|
|
226
|
+
'PSM-E-TRUSTED-SOURCE' => [
|
|
227
|
+
'Some gems install a package from Git, a URL or an alias to another package, ' \
|
|
228
|
+
'under a name your package manager trusts to run install scripts, so that ' \
|
|
229
|
+
"code's install script would run:\n%<list>s",
|
|
230
|
+
"Ask the gem's author to depend on the registry package instead, or opt the gem out " \
|
|
231
|
+
'with `"proscenium": {"gemOverrides": {"<gem>": {"participate": false}}}` in ' \
|
|
232
|
+
'package.json. pnpm trusts the packages pnpm-workspace.yaml approves builds for; Bun ' \
|
|
233
|
+
'trusts trustedDependencies in package.json, or its own default list without one.'
|
|
234
|
+
],
|
|
235
|
+
'PSM-E-PLATFORM-VARIANT' => [
|
|
236
|
+
'The platform-specific builds of %<variants>s differ from the one ' \
|
|
237
|
+
'installed here in their NPM dependencies or whether they opt ' \
|
|
238
|
+
"in, or don't pass `proscenium gem check`.",
|
|
239
|
+
'Make every platform build of the gem ship the same package.json dependencies and ' \
|
|
240
|
+
'proscenium.dependencies setting, each passing `proscenium gem check`. Your lockfile ' \
|
|
241
|
+
'can only hold one set.'
|
|
242
|
+
],
|
|
243
|
+
'PSM-E-UNC-SHIM' => [
|
|
244
|
+
"%<manager>s is installed as a .cmd script, and cmd.exe can't run in " \
|
|
245
|
+
"%<root>s because it's a network (UNC) path: it would run in " \
|
|
246
|
+
'C:\\Windows instead.',
|
|
247
|
+
'Map a drive letter to the share (`net use Z: \\\\server\\share`) and run from that ' \
|
|
248
|
+
'drive, or install %<manager>s as a native executable.'
|
|
249
|
+
],
|
|
250
|
+
'PSM-E-OWNED-LINK' => [
|
|
251
|
+
'%<paths>s is a symbolic link, so installing would write outside your app.',
|
|
252
|
+
'Replace it with a real directory. Proscenium only writes inside .proscenium/.'
|
|
253
|
+
],
|
|
254
|
+
'PSM-E-OWNED-DIR' => [
|
|
255
|
+
".proscenium/packages/ contains things Proscenium didn't create: %<entries>s.",
|
|
256
|
+
'Move them somewhere else. Proscenium manages that directory, and treats everything ' \
|
|
257
|
+
'in it as a gem dependency context.'
|
|
258
|
+
],
|
|
259
|
+
'PSM-W-END-OF-LIFE' => [
|
|
260
|
+
'%<manager>s %<version>s reaches end of life on %<eol>s, and a future ' \
|
|
261
|
+
'Proscenium release may stop supporting it.',
|
|
262
|
+
'Plan an upgrade to a newer %<manager>s version; the upgrade guide explains the steps.'
|
|
263
|
+
],
|
|
264
|
+
'PSM-E-INTERNAL' => [
|
|
265
|
+
'Something went wrong inside Proscenium: %<detail>s',
|
|
266
|
+
'This is a bug in Proscenium. Please report it at ' \
|
|
267
|
+
'https://github.com/joelmoss/proscenium/issues, including the output above.'
|
|
268
|
+
]
|
|
269
|
+
}.freeze
|
|
270
|
+
|
|
271
|
+
def self.message(code, **args) = format(TEXT.fetch(code).first, **args)
|
|
272
|
+
|
|
273
|
+
def self.fix(code, **args) = format(TEXT.fetch(code).last, **args)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'map'
|
|
5
5
|
require_relative '../bundled_gems'
|
|
6
|
-
require_relative '
|
|
6
|
+
require_relative 'projection'
|
|
7
7
|
require_relative 'rules'
|
|
8
8
|
|
|
9
9
|
module Proscenium
|
|
10
|
-
module
|
|
11
|
-
# The contexts the bundle calls for (#154): one per participating, installed gem, projected
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
module Contexts
|
|
11
|
+
# The contexts the bundle calls for (#154): one per participating, installed gem, projected from
|
|
12
|
+
# its package.json after the author contract and the gem-to-gem rules. Computes; never writes.
|
|
13
|
+
# Shared by the engine (Stale) and the CLI (install, inspect), so it loads nothing of the
|
|
14
|
+
# CLI; problems are `[code, args]` pairs, rendered by whoever reports them.
|
|
15
|
+
class Plan
|
|
16
16
|
ESCAPE = "Or, to install %<gem>s's NPM dependencies yourself, opt it out with " \
|
|
17
17
|
'`"proscenium": {"gemOverrides": {"%<gem>s": {"participate": false}}}` in ' \
|
|
18
18
|
"package.json, then add the packages it needs to your app's own dependencies."
|
|
@@ -36,7 +36,7 @@ module Proscenium
|
|
|
36
36
|
|
|
37
37
|
# The committed context directories under the app, by gem name.
|
|
38
38
|
def self.committed(root)
|
|
39
|
-
dir = File.join(root,
|
|
39
|
+
dir = File.join(root, Map::CONTEXTS)
|
|
40
40
|
return {} unless Dir.exist?(dir)
|
|
41
41
|
|
|
42
42
|
Dir.children(dir).select { File.directory?(File.join(dir, it)) }.sort
|
|
@@ -61,8 +61,8 @@ module Proscenium
|
|
|
61
61
|
return unless fatal.empty?
|
|
62
62
|
|
|
63
63
|
manifest = link_gems(gem, spec, manifest) or return
|
|
64
|
-
context =
|
|
65
|
-
@contexts[gem] = Context.new(gem:, json:
|
|
64
|
+
context = Projection.project(gem, manifest)
|
|
65
|
+
@contexts[gem] = Context.new(gem:, json: Projection.to_json(context),
|
|
66
66
|
git_and_url: git_and_url(context))
|
|
67
67
|
end
|
|
68
68
|
|
|
@@ -75,7 +75,7 @@ module Proscenium
|
|
|
75
75
|
return problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause:)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
manifest =
|
|
78
|
+
manifest = Map.read_json(path)
|
|
79
79
|
return manifest if manifest.is_a?(Hash)
|
|
80
80
|
|
|
81
81
|
problem('PSM-E-MANIFEST', gem:, path: 'package.json', cause: 'it is not a JSON object')
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Proscenium
|
|
7
|
+
module Contexts
|
|
8
|
+
# A participating gem's dependency context (#154): its package.json through the
|
|
9
|
+
# `dependency-context-v1` projection, which keeps installation metadata only and is committed at
|
|
10
|
+
# `.proscenium/packages/<gem>/package.json`.
|
|
11
|
+
#
|
|
12
|
+
# The one owner of the projection and its hash, shared by the CLI and the engine, so it must not
|
|
13
|
+
# load ActiveSupport or anything else the CLI may not.
|
|
14
|
+
module Projection
|
|
15
|
+
PROJECTION = 'dependency-context-v1'
|
|
16
|
+
|
|
17
|
+
# The manifest fields a context keeps, in the order it writes them. Everything else (scripts,
|
|
18
|
+
# devDependencies, version, entry points, workspaces) is left in the gem's own package.json.
|
|
19
|
+
FIELDS = %w[dependencies peerDependencies peerDependenciesMeta optionalDependencies engines os
|
|
20
|
+
cpu libc].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def name_for(gem) = "@rubygems/#{gem}"
|
|
25
|
+
|
|
26
|
+
def description_for(gem)
|
|
27
|
+
"Generated by Proscenium from the #{gem} gem. Do not edit; " \
|
|
28
|
+
'run bundle exec proscenium install.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The context for `gem` from its parsed `manifest`. An empty field is left out, as an absent
|
|
32
|
+
# one is: the two install the same, so they hash the same.
|
|
33
|
+
def project(gem, manifest)
|
|
34
|
+
fields = FIELDS.each_with_object({}) do |key, kept|
|
|
35
|
+
value = manifest[key]
|
|
36
|
+
kept[key] = value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
{ 'name' => name_for(gem), 'private' => true, 'description' => description_for(gem),
|
|
40
|
+
**fields,
|
|
41
|
+
'proscenium' => { 'projection' => PROJECTION,
|
|
42
|
+
'projectionSha256' => digest(gem, fields) } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The bytes committed for a context: fixed key order, two-space indent, a trailing newline,
|
|
46
|
+
# and nothing that varies by host or time.
|
|
47
|
+
def to_json(context) = "#{JSON.pretty_generate(context)}\n"
|
|
48
|
+
|
|
49
|
+
# The projection hash. It hashes a fixed-field encoding of the projected fields, not JSON, so
|
|
50
|
+
# it does not move with the json gem's output, key order, whitespace or escaping, and an edit
|
|
51
|
+
# to anything the projection drops leaves it alone.
|
|
52
|
+
def digest(gem, fields)
|
|
53
|
+
Digest::SHA256.hexdigest("#{PROJECTION}\n#{encode(name_for(gem))}#{encode(fields)}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A self-delimiting encoding: a type tag, a length where there is one, then the value. Hash
|
|
57
|
+
# keys are sorted by their bytes, so key order in the manifest does not matter; array order
|
|
58
|
+
# does, as it does to npm.
|
|
59
|
+
def encode(value)
|
|
60
|
+
case value
|
|
61
|
+
when String then "s#{value.bytesize}:#{value.b}"
|
|
62
|
+
when true then 't'
|
|
63
|
+
when false then 'f'
|
|
64
|
+
when nil then 'n'
|
|
65
|
+
when Integer then "i#{value};"
|
|
66
|
+
when Float then "d#{value};"
|
|
67
|
+
when Array then "a#{value.size}:#{value.map { encode(it) }.join}"
|
|
68
|
+
when Hash
|
|
69
|
+
pairs = value.sort_by { |key, _| key.to_s.b }
|
|
70
|
+
"h#{value.size}:#{pairs.map { |key, val| encode(key.to_s) + encode(val) }.join}"
|
|
71
|
+
else
|
|
72
|
+
raise ArgumentError, "cannot encode #{value.class} in a dependency context"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|