importmap-plus 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,12 @@
1
1
  require "thor"
2
2
  require "importmap/packager"
3
+ require "importmap/package_graph"
4
+ require "importmap/vendored_graph"
3
5
  require "importmap/npm"
6
+ require "importmap/provider_chain"
7
+ require "importmap/batch_resolver"
8
+ require "importmap/integrity"
9
+ require "importmap/doctor"
4
10
 
5
11
  class Importmap::Commands < Thor
6
12
  include Thor::Actions
@@ -15,11 +21,36 @@ class Importmap::Commands < Thor
15
21
  option :preload, type: :string, repeatable: true, desc: "Can be used multiple times"
16
22
  option :remote, type: :boolean, default: false, desc: "Pin to the remote URL instead of vendoring a download"
17
23
  option :minify, type: :boolean, desc: "Minify the vendored download with bun, esbuild or terser"
24
+ option :lock, type: :boolean, desc: "Lock the pin at this version; update, pin and pristine leave it there until unlocked"
25
+ option :force, type: :boolean, default: false, desc: "Re-pin locked packages, keeping each lock at the new version"
26
+ option :vendor, type: :boolean, default: false, desc: "Vendor the download even when it looks like it needs sibling files; converts a pin kept remote back"
27
+ option :integrity, type: :boolean, default: true, desc: "Write a subresource-integrity hash on a pin kept remote; --no-integrity skips the fetch that computes it"
18
28
  def pin(*packages)
19
- for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from]) do |package, url|
29
+ packages = resolve_latest_versions(without_locked(packages, lock: options[:lock], force: options[:force]))
30
+ # jspm resolves a package together with its dependencies; --lock and
31
+ # --no-lock are about the packages that were asked for, not those.
32
+ requested = packages.map { |spec| packager.package_key_for(spec) }
33
+
34
+ for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from], fallback: true) do |package, url|
35
+ next if keep_locked_dependency(package, requested)
36
+
20
37
  pin_package(package, url, preload: options[:preload], remote: options[:remote], env: options[:env],
21
- minify: options[:minify], from: options[:from])
38
+ minify: options[:minify], from: options[:from],
39
+ lock: requested.include?(package) ? options[:lock] : nil,
40
+ vendor: requested.include?(package) && options[:vendor])
22
41
  end
42
+
43
+ exit 1 if skipped.any? || resolver.unresolved.any?
44
+ end
45
+
46
+ desc "lock [*PACKAGES]", "Lock packages at their pinned version"
47
+ def lock(*packages)
48
+ exit 1 unless packages.map { |package| lock_package(package) }.all?
49
+ end
50
+
51
+ desc "unlock [*PACKAGES]", "Let locked packages be updated again"
52
+ def unlock(*packages)
53
+ exit 1 unless packages.map { |package| unlock_package(package) }.all?
23
54
  end
24
55
 
25
56
  desc "unpin [*PACKAGES]", "Unpin existing packages"
@@ -40,19 +71,21 @@ class Importmap::Commands < Thor
40
71
  option :minify, type: :boolean, desc: "Minify every download; defaults to what each vendored file already is"
41
72
  def pristine
42
73
  packages = prepare_packages_with_versions
74
+ unrestored = []
43
75
 
44
76
  for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from]) do |package, url|
45
77
  if packager.remote_pin?(package)
46
78
  puts %(Skipping "#{package}" (pinned to remote URL))
79
+ elsif (resolved = version_drift_of_locked(package, url))
80
+ puts %(Skipping "#{package}" (locked at #{packager.pin_provenance(package)[:version]}, CDN resolved #{resolved}))
47
81
  else
48
- minify = options[:minify].nil? ? vendored_minified?(package) : options[:minify]
49
-
50
- puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}#{" (minified)" if minify})
51
-
52
- pin_esm_run_dependencies packager.download(package, url, minify: minify), minify: minify
53
- record_provenance(package, url, minify) if provenance_changed?(package, url, minify)
82
+ unrestored << package unless restore_package(package, url)
54
83
  end
55
84
  end
85
+
86
+ # skipped: an esm.run bundle's dependency is pinned on the way, and one the
87
+ # CDN failed on is skipped the same way pin skips it.
88
+ exit 1 if unrestored.any? || skipped.any? || resolver.unresolved.any?
56
89
  end
57
90
 
58
91
  desc "json", "Show the full importmap in json"
@@ -85,28 +118,56 @@ class Importmap::Commands < Thor
85
118
  desc "outdated", "Check for outdated packages"
86
119
  def outdated
87
120
  if (outdated_packages = npm.outdated_packages).any?
88
- table = [["Package", "Current", "Latest"]]
89
- outdated_packages.each { |p| table << [p.name, p.current_version, p.latest_version || p.error] }
121
+ locked = outdated_packages.select { |p| locked_pin_covering(p.name) }
122
+
123
+ table = [["Package", "Current", "Latest", "Locked"]]
124
+ outdated_packages.each { |p| table << [p.name, p.current_version, p.latest_version || p.error, locked.include?(p) ? "yes" : ""] }
90
125
 
91
126
  puts_table(table)
92
127
  packages = 'package'.pluralize(outdated_packages.size)
93
- puts " #{outdated_packages.size} outdated #{packages} found"
128
+ puts " #{outdated_packages.size} outdated #{packages} found#{" (#{locked.size} locked)" if locked.any?}"
94
129
 
95
- exit 1
130
+ # A lock is a version the app chose, so only the rest count as drift.
131
+ exit 1 if locked.size < outdated_packages.size
96
132
  else
97
133
  puts "No outdated packages found"
98
134
  end
99
135
  end
100
136
 
101
- desc "update", "Update outdated package pins"
102
- def update
103
- if (outdated_packages = npm.outdated_packages).any?
104
- for_each_import_grouped_by_provider(outdated_packages.map(&:name), env: "production") do |package, url|
137
+ desc "update [*PACKAGES]", "Update outdated package pins"
138
+ option :all, type: :boolean, default: false, desc: "Update every outdated package (the default when no names are given)"
139
+ option :force, type: :boolean, default: false, desc: "Update locked packages too, keeping each lock at the new version"
140
+ def update(*packages)
141
+ if packages.any? && options[:all]
142
+ puts "Pass package names or --all, not both"
143
+ exit 1
144
+ end
145
+
146
+ # A package the registry couldn't answer for has no latest_version, so
147
+ # nothing established that it moved: re-pinning would let a blip
148
+ # re-resolve the pin against the CDN and carry it somewhere new. Each
149
+ # pin is independent of the others, so the rest still update; the exit
150
+ # code says the command didn't do all it was asked.
151
+ outdated_packages, unchecked_packages = npm.outdated_packages(only: packages.presence).partition(&:latest_version)
152
+ unchecked_packages.each { |p| puts %(Couldn't check "#{p.name}": #{p.error}) }
153
+
154
+ exit 1 unless every_package_known?(packages, outdated_packages, unchecked_packages)
155
+
156
+ if outdated_packages.empty?
157
+ puts "No outdated packages found" if unchecked_packages.empty?
158
+ elsif (names = without_locked_updates(outdated_packages.map(&:name), force: options[:force])).empty?
159
+ puts "Nothing to update (every outdated package is locked; pass --force)"
160
+ else
161
+ keys = packages.any? ? requested_keys_for(packages, names) : outdated_keys_for(names)
162
+
163
+ for_each_import_grouped_by_provider(keys, env: "production", fallback: true) do |package, url|
164
+ next if keep_locked_dependency(package, keys)
165
+
105
166
  pin_package(package, url)
106
167
  end
107
- else
108
- puts "No outdated packages found"
109
168
  end
169
+
170
+ exit 1 if unchecked_packages.any? || skipped.any? || resolver.unresolved.any?
110
171
  end
111
172
 
112
173
  desc "packages", "Print out packages with version numbers"
@@ -114,7 +175,32 @@ class Importmap::Commands < Thor
114
175
  puts npm.packages_with_versions.map { |x| x.join(' ') }
115
176
  end
116
177
 
178
+ desc "doctor", "Check the importmap and the vendored files for problems"
179
+ option :online, type: :boolean, default: false, desc: "Also fetch every remote pin and check its integrity hash"
180
+ def doctor
181
+ require Rails.root.join("config/environment")
182
+
183
+ doctor = Importmap::Doctor.new(
184
+ importmap: Rails.application.importmap,
185
+ resolver: ActionController::Base.helpers,
186
+ root: Rails.root,
187
+ asset_paths: asset_paths,
188
+ online: options[:online]
189
+ )
190
+
191
+ doctor.diagnose.each { |finding| puts finding }
192
+ puts doctor.summary
193
+
194
+ exit 1 if doctor.errors?
195
+ end
196
+
117
197
  private
198
+ # Where a logical path is looked for on disk. Every engine adds its own, so
199
+ # a file a gem serves resolves here exactly as it does in a request.
200
+ def asset_paths
201
+ Rails.application.config.respond_to?(:assets) ? Rails.application.config.assets.paths : []
202
+ end
203
+
118
204
  def packager
119
205
  @packager ||= Importmap::Packager.new
120
206
  end
@@ -123,32 +209,277 @@ class Importmap::Commands < Thor
123
209
  @npm ||= Importmap::Npm.new
124
210
  end
125
211
 
126
- def pin_package(package, url, preload: nil, remote: false, env: "production", minify: nil, from: nil)
212
+ # A lock outlives a rewrite unless the caller says otherwise, so update
213
+ # and --force re-lock at the version they move to.
214
+ def pin_package(package, url, preload: nil, remote: false, env: "production", minify: nil, from: nil, lock: nil, vendor: false)
127
215
  existing_options = packager.extract_existing_pin_options(package)[package] || {}
128
216
  preload = existing_options[:preload] if preload.nil?
217
+ integrity = existing_options[:integrity]
218
+ locked = lock.nil? ? packager.locked?(package) : lock
219
+ # A pin vendored on purpose stays vendored: without this an update would
220
+ # inspect the download again, refuse it again, and quietly undo --vendor.
221
+ vendor ||= packager.vendored?(package)
222
+ # A pin kept remote only because its file imports siblings is vendored
223
+ # again now that the whole graph can be. The reason its comment records
224
+ # is one this gem has since learned to answer, and without this an app
225
+ # that hit the check once would never see the fix arrive.
226
+ regraph = packager.remote_reason(package) == Importmap::PackageGraph::REASON
129
227
  existing_url = existing_options[:to] if existing_options[:to].to_s.match?(Importmap::Packager::REMOTE_URL_REGEXP)
130
228
 
131
- if existing_url
132
- repin_remote_package(package, url, existing_url, preload, env: env, from: from)
229
+ report_graph_shadowing(package)
230
+
231
+ if existing_url && !vendor && !regraph
232
+ repin_remote_package(package, url, existing_url, preload, env: env, from: from, integrity: integrity, locked: locked)
233
+ elsif existing_url && from.nil? && packager.provider_for_url(existing_url).nil?
234
+ keep_custom_url(package, existing_url)
133
235
  elsif remote
134
- pin_remote_package(package, url, preload)
236
+ pin_remote_package(package, url, preload, integrity: integrity, locked: locked)
135
237
  else
136
- pin_vendored_package(package, url, preload, minify: minify)
238
+ pin_vendored_package(package, url, preload, minify: minify, integrity: integrity, locked: locked, vendor: vendor)
137
239
  end
138
240
  end
139
241
 
140
- def pin_vendored_package(package, url, preload, minify: nil)
242
+ # Nothing is said about a download until it has arrived and been found fit
243
+ # to serve on its own, so a package kept remote reports that and only that.
244
+ def pin_vendored_package(package, url, preload, minify: nil, integrity: nil, locked: false, vendor: false)
141
245
  minify = vendored_minified?(package) if minify.nil?
142
246
 
143
- puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}#{" (minified)" if minify})
247
+ begin
248
+ dependencies = packager.download(package, url, minify: minify, force: vendor, graph: !vendor)
249
+ rescue Importmap::Packager::Unvendorable, Importmap::Packager::NotAnEsModule => refusal
250
+ return pin_remote_package(package, url, preload, integrity: integrity, locked: locked, kept_remote: refusal)
251
+ rescue Importmap::VendoredGraph::Occupied => occupied
252
+ # Nothing is written, including the pin: the app is asked to move its
253
+ # own directory rather than told afterwards that this gem took it.
254
+ return skip(package, occupied.message)
255
+ rescue Importmap::Packager::Error => error
256
+ # The CDN couldn't be read once its retries were spent. The pin is left
257
+ # exactly as it was: a package that vendors today must not turn into a
258
+ # remote pin — losing the files that make it work — because of a 503.
259
+ return skip(package, error.message)
260
+ end
144
261
 
145
- dependencies = packager.download(package, url, minify: minify)
262
+ graph = packager.last_graph
146
263
 
147
- update_importmap_with_pin(package, packager.vendored_pin_for(package, url, preload, minify: minify))
264
+ puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}#{" (minified)" if minify}#{graph_note(graph)})
265
+
266
+ update_importmap_with_pin(package, packager.vendored_pin_for(package, url, preload, minify: minify, integrity: integrity, locked: locked, vendored: vendor))
267
+
268
+ # The line that maps the graph directory goes with the download: a
269
+ # package that stopped needing one takes its line and its files with it,
270
+ # or pin_all_from keeps mapping files nothing imports.
271
+ if graph
272
+ update_importmap_with_graph_pin(package, packager.graph_pin_for(package, url, preload))
273
+ else
274
+ packager.remove_graph(package)
275
+ end
276
+
277
+ report_lock(package) if locked
148
278
 
149
279
  pin_esm_run_dependencies(dependencies, preload: preload, minify: minify)
150
280
  end
151
281
 
282
+ # A package that couldn't be pinned has said why on stdout; this is what
283
+ # makes the exit code say so too, or `pin x && git commit` commits a tree
284
+ # with no pin in it. Kept on the command rather than returned up through
285
+ # pin_package, because an esm.run bundle's dependencies are pinned two
286
+ # calls down and are skipped the same way.
287
+ def skip(package, reason)
288
+ puts %(Skipping "#{package}": #{reason})
289
+ skipped << package
290
+ end
291
+
292
+ def skipped
293
+ @skipped ||= []
294
+ end
295
+
296
+ # pristine restores what each pin already says, so a package vendored
297
+ # before the single-file check existed — or on purpose with --vendor — is
298
+ # downloaded again rather than converted, and only a pin that already maps
299
+ # a graph directory has one crawled again. pin is where both are decided.
300
+ #
301
+ # A package the CDN can no longer serve the way its pin describes stops
302
+ # that one package being restored, not the run: pristine is the repair
303
+ # command, and the packages after it in the batch still need repairing.
304
+ def restore_package(package, url)
305
+ minify = options[:minify].nil? ? vendored_minified?(package) : options[:minify]
306
+
307
+ puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}#{" (minified)" if minify})
308
+
309
+ dependencies = packager.download(package, url, minify: minify, force: true,
310
+ graph: packager.vendored_graph(package).mapped?)
311
+
312
+ # A download that came back as one file — a --from that moved the package
313
+ # to a bundling CDN — takes its graph line with it, or pin_all_from is
314
+ # left mapping the directory that download just removed.
315
+ packager.remove_graph(package) unless packager.last_graph
316
+
317
+ pin_esm_run_dependencies dependencies, minify: minify
318
+ record_provenance(package, url, minify) if provenance_changed?(package, url, minify)
319
+
320
+ true
321
+ rescue Importmap::Packager::Unvendorable, Importmap::Packager::NotAnEsModule => refusal
322
+ puts %(Couldn't restore "#{package}": it #{refusal.message})
323
+ false
324
+ rescue Importmap::Packager::Error => error
325
+ puts %(Couldn't restore "#{package}": #{error.message})
326
+ false
327
+ end
328
+
329
+ def lock_package(spec)
330
+ package = packager.package_key_for(spec)
331
+
332
+ if package != spec
333
+ puts %(Use "bin/importmap pin #{spec} --lock" to lock at a different version)
334
+ elsif !packager.packaged?(package)
335
+ puts %(Couldn't find a pin for "#{package}")
336
+ elsif packager.locked?(package)
337
+ puts %("#{package}" is already locked at #{packager.pin_provenance(package)[:version]})
338
+ return true
339
+ elsif (line = packager.locked_pin_line(package))
340
+ update_importmap_with_pin(package, line)
341
+ report_lock(package)
342
+ return true
343
+ else
344
+ puts %(Can't lock "#{package}": its pin has no version)
345
+ end
346
+
347
+ false
348
+ end
349
+
350
+ def unlock_package(spec)
351
+ package = packager.package_key_for(spec)
352
+
353
+ if !packager.packaged?(package)
354
+ puts %(Couldn't find a pin for "#{package}")
355
+ false
356
+ elsif !packager.locked?(package)
357
+ puts %("#{package}" isn't locked)
358
+ true
359
+ else
360
+ update_importmap_with_pin(package, packager.unlocked_pin_line(package))
361
+ puts %(Unlocked "#{package}")
362
+ true
363
+ end
364
+ end
365
+
366
+ def report_lock(package)
367
+ puts %(Locked "#{package}" at #{packager.pin_provenance(package)[:version]})
368
+ end
369
+
370
+ # Specs the user named that point at a locked pin are dropped, with a
371
+ # note, unless the lock is what they are here to change.
372
+ def without_locked(specs, lock: nil, force: false)
373
+ return specs if force || !lock.nil?
374
+
375
+ specs.reject do |spec|
376
+ package = packager.package_key_for(spec)
377
+
378
+ packager.locked?(package).tap { |locked| puts skip_locked_message(package) if locked }
379
+ end
380
+ end
381
+
382
+ # update sees npm names (apexcharts) where pins are import-map keys
383
+ # (apexcharts/core), so a lock on any pin of the package holds it.
384
+ def without_locked_updates(names, force: false)
385
+ return names if force
386
+
387
+ names.reject do |name|
388
+ (locked = locked_pin_covering(name)).tap { puts skip_locked_message(locked) if locked }
389
+ end
390
+ end
391
+
392
+ def skip_locked_message(package)
393
+ %(Skipping "#{package}" (locked at #{packager.pin_provenance(package)[:version]}; run bin/importmap unlock #{package} or pass --force))
394
+ end
395
+
396
+ # Says why a named package won't be updated. A name with no pin at all
397
+ # is a typo until proven otherwise, so nothing is updated in that case.
398
+ # One the registry couldn't be asked about was already reported and is
399
+ # no reason to hold back the others.
400
+ def every_package_known?(names, outdated_packages, unchecked_packages)
401
+ versioned = npm.packages_with_versions.to_h
402
+ outdated = outdated_packages.map(&:name)
403
+ unchecked = unchecked_packages.map(&:name)
404
+
405
+ names.map do |name|
406
+ key = packager.package_key_for(name)
407
+ package = packager.package_name_for(key)
408
+
409
+ if !packager.packaged?(key)
410
+ puts %(Couldn't find a pin for "#{name}")
411
+ next false
412
+ elsif outdated.include?(package) || unchecked.include?(package)
413
+ next true
414
+ elsif versioned.key?(package)
415
+ puts %("#{name}" is already up to date (#{versioned[package]}))
416
+ else
417
+ puts %(Can't tell whether "#{name}" is outdated: its pin has no version)
418
+ end
419
+
420
+ true
421
+ end.all?
422
+ end
423
+
424
+ # The registry knows a package by name and the import map by key: a pin of
425
+ # "photoswipe/lightbox" is outdated when "photoswipe" is. A named update
426
+ # re-pins the keys that were asked for: a package name means every pin
427
+ # that carries it, the way outdated reports it and a bare update moves it,
428
+ # while "photoswipe/lightbox" means that one key.
429
+ def requested_keys_for(specs, outdated_names)
430
+ specs.flat_map { |spec| keys_for(packager.package_key_for(spec)) }
431
+ .select { |key| outdated_names.include?(packager.package_name_for(key)) }
432
+ end
433
+
434
+ def keys_for(key)
435
+ return [ key ] unless key == packager.package_name_for(key)
436
+
437
+ versioned_keys_by_package[key] || [ key ]
438
+ end
439
+
440
+ # An outdated package is outdated in every pin that carries it, so a bare
441
+ # update re-pins those keys: "photoswipe" moving updates the app's
442
+ # "photoswipe/lightbox" pin rather than appending a bare one beside it.
443
+ #
444
+ # Only pins that declare a version count, the same ones outdated reports
445
+ # on. An app file pinned under a package's namespace — pin "md5/helpers",
446
+ # to: "md5/helpers.js" — names no version and is none of the registry's
447
+ # business; asking a CDN for it 404s the whole batch and nothing updates.
448
+ #
449
+ # A name no pin's key names is one whose version came from a URL that
450
+ # doesn't match its key (pin "buffer", to: ".../npm:jspm-core@..."); it is
451
+ # still the only handle there is, so it goes through as itself.
452
+ def outdated_keys_for(names)
453
+ names.flat_map { |name| versioned_keys_by_package[name] || [ name ] }
454
+ end
455
+
456
+ def versioned_keys_by_package
457
+ packager.pinned_packages.select { |key| packager.pin_version(key) }
458
+ .group_by { |key| packager.package_name_for(key) }
459
+ end
460
+
461
+ def locked_pin_covering(name)
462
+ packager.locked_pins.find { |key| key == name || key.start_with?("#{name}/") }
463
+ end
464
+
465
+ # A CDN resolves a package together with its dependencies. One the app has
466
+ # locked stays where it is: only a package named on the command line moves.
467
+ def keep_locked_dependency(package, requested)
468
+ return false if requested.include?(package) || !packager.locked?(package)
469
+
470
+ puts %(Keeping existing pin for "#{package}" (locked at #{packager.pin_provenance(package)[:version]}))
471
+ true
472
+ end
473
+
474
+ # pristine asks the CDN for the pinned version, so a locked package only
475
+ # drifts if the CDN answers with another one.
476
+ def version_drift_of_locked(package, url)
477
+ return unless packager.locked?(package)
478
+
479
+ resolved = packager.extract_package_version_from(url).to_s.delete_prefix("@")
480
+ resolved if resolved != packager.pin_provenance(package)[:version]
481
+ end
482
+
152
483
  # An esm.run bundle imports its dependencies as bare specifiers after
153
484
  # download, so each one needs a pin. Pins the app already has win: the
154
485
  # bundle then resolves to whatever version the app chose.
@@ -167,9 +498,12 @@ class Importmap::Commands < Thor
167
498
  # comment those are recorded in. Left alone otherwise: a pin may carry
168
499
  # options, such as integrity, that a rewrite would drop.
169
500
  def record_provenance(package, url, minify)
170
- preload = (packager.extract_existing_pin_options(package)[package] || {})[:preload]
501
+ existing_options = packager.extract_existing_pin_options(package)[package] || {}
171
502
 
172
- update_importmap_with_pin(package, packager.vendored_pin_for(package, url, preload, minify: minify))
503
+ update_importmap_with_pin(package, packager.vendored_pin_for(package, url, existing_options[:preload],
504
+ minify: minify, integrity: existing_options[:integrity],
505
+ vendored: packager.vendored?(package),
506
+ locked: packager.locked?(package)))
173
507
  end
174
508
 
175
509
  def provenance_changed?(package, url, minify)
@@ -186,45 +520,165 @@ class Importmap::Commands < Thor
186
520
  # A vendored package keeps coming from the CDN its pin comment names, the
187
521
  # way a remote pin keeps its provider, unless --from says otherwise.
188
522
  # Packages that aren't pinned yet resolve from jspm.
189
- def for_each_import_grouped_by_provider(packages, env:, from: nil, &block)
190
- packages.group_by { |spec| from || vendored_provider_for(spec) || "jspm" }.each do |provider, group|
191
- for_each_import(group, env: env, from: provider, &block)
523
+ #
524
+ # +fallback+ lets the group that named no CDN try the rest of the chain
525
+ # when jspm can't build it, which is what pin and update want. pristine
526
+ # doesn't: it restores what each pin already says, and a pin that moved to
527
+ # another CDN would be a rewrite, not a restore.
528
+ def for_each_import_grouped_by_provider(packages, env:, from: nil, fallback: false, &block)
529
+ packages.group_by { |spec| from || vendored_provider_for(spec) || Importmap::ProviderChain::DEFAULT }.each do |provider, group|
530
+ resolver.each_import(group, env: env, from: provider,
531
+ fallback: fallback && from.nil? && Importmap::ProviderChain.default?(provider), &block)
192
532
  end
193
533
  end
194
534
 
535
+ # Shared by every group of a single run, so the exit code can ask it once
536
+ # whether anything was left unresolved.
537
+ def resolver
538
+ @resolver ||= Importmap::BatchResolver.new(packager, on_miss: method(:handle_package_not_found))
539
+ end
540
+
541
+ # A spec with no version means the latest, and the registry is what knows
542
+ # which that is: a CDN answers with the latest it has got round to indexing,
543
+ # which lags npm by anything from hours to a major release. Resolving it up
544
+ # front also pins the version for the whole chain, so falling back to
545
+ # another CDN can't quietly land the app on a different one. A registry
546
+ # that can't be reached leaves the spec alone, and the CDN chooses as before.
547
+ def resolve_latest_versions(specs)
548
+ specs.map do |spec|
549
+ name, version, _subpath = spec.to_s.match(Importmap::Packager::PACKAGE_SPEC_REGEXP)&.captures
550
+ next spec if version || name.nil?
551
+
552
+ if (latest = npm.latest_version(name))
553
+ puts %(Resolved "#{name}" to #{latest} from the npm registry)
554
+ packager.package_spec_for(spec, "@#{latest}")
555
+ else
556
+ spec
557
+ end
558
+ end
559
+ end
560
+
561
+ # A subpath pin that names no CDN of its own comes from wherever its
562
+ # package's pin came from: pdfjs-dist and pdfjs-dist/build/pdf.worker.min.mjs
563
+ # were pinned together, and jspm, the default, can't resolve either.
564
+ # A pin answers for itself first and only then for its subpaths: the URL on
565
+ # pin "photoswipe/lightbox" says more about where that file comes from than
566
+ # its package's pin does. Asking the package first grouped the two together,
567
+ # and one spec the CDN can't answer for fails the whole batch.
195
568
  def vendored_provider_for(spec)
196
- packager.pin_provenance(packager.package_key_for(spec))&.dig(:provider)
569
+ key = packager.package_key_for(spec)
570
+ name = packager.package_name_for(key)
571
+
572
+ return packager.pin_provenance(key)&.dig(:provider) || kept_remote_provider(key) if key == name
573
+
574
+ provider_of_pin(key) || provider_of_pin(name)
575
+ end
576
+
577
+ # A pin the single-file check kept remote records its CDN only in its URL:
578
+ # the comment's provider slot is taken by the reason it was kept. Asking the
579
+ # URL keeps such a pin on its own CDN when it is re-pinned — and, now that a
580
+ # chunked package can be vendored whole, when it converts to a download.
581
+ # A pin the app made remote itself carries no reason and is left to
582
+ # repin_remote_package, which resolves it from its URL as it always did.
583
+ def kept_remote_provider(key)
584
+ return unless packager.remote_reason(key)
585
+
586
+ packager.provider_for_url(packager.extract_existing_pin_options(key).dig(key, :to))
197
587
  end
198
588
 
199
- def pin_remote_package(package, url, preload)
200
- puts %(Pinning "#{package}" to #{url})
589
+ # A vendored pin records its CDN in the version comment; a remote one
590
+ # carries it in the URL and, unlocked, has no comment at all. Only a subpath
591
+ # asks this, and a bare package pin above stops at its comment: reading the
592
+ # URL of every pin would change where a plain remote pin resolves its
593
+ # dependencies from, which is not this lookup's business.
594
+ def provider_of_pin(package)
595
+ packager.pin_provenance(package)&.dig(:provider) ||
596
+ packager.provider_for_url(packager.extract_existing_pin_options(package).dig(package, :to))
597
+ end
598
+
599
+ # +kept_remote+ is the Packager's refusal to vendor a download it just
600
+ # fetched — its reasons, and the hash of the bytes it already has; without
601
+ # it the reason the pin already records is carried over, so update and a
602
+ # plain pin don't drop it.
603
+ def pin_remote_package(package, url, preload, integrity: nil, locked: false, kept_remote: nil)
604
+ if compute_integrity?
605
+ integrity = remote_integrity_for(url, integrity, kept_remote&.integrity)
606
+ elsif packager.integrity_hash?(package)
607
+ puts %(Dropping the integrity hash on "#{package}" (--no-integrity))
608
+ end
609
+
610
+ notes = +""
611
+ notes << %( (kept remote: #{kept_remote.reasons.to_sentence})) if kept_remote
612
+ notes << %( (integrity #{integrity})) if Importmap::Integrity.hash?(integrity)
613
+
614
+ puts %(Pinning "#{package}" to #{url}#{notes})
615
+
616
+ remote = kept_remote&.reasons&.first || packager.remote_reason(package)
201
617
 
202
618
  packager.remove_existing_package_file(package)
619
+ packager.remove_graph(package)
203
620
 
204
- update_importmap_with_pin(package, packager.pin_for(package, url, preloads: preload))
621
+ update_importmap_with_pin(package, packager.pin_for(package, url, preloads: preload, integrity: integrity,
622
+ locked: locked, remote: remote))
623
+ report_lock(package) if locked
205
624
  end
206
625
 
207
- def repin_remote_package(package, url, existing_url, preload, env:, from: nil)
626
+ # A URL on a host no provider answers for is the app's own choice, and the
627
+ # branch above skips it — but only while `vendor` is false. --vendor is
628
+ # about overriding the check that refuses a download, not about moving a
629
+ # pin to another source, so it lands here instead of vendoring whatever the
630
+ # spec happened to resolve to. Naming a CDN with --from is how a pin is
631
+ # moved on purpose, and that still goes through repin_remote_package.
632
+ def keep_custom_url(package, existing_url)
633
+ puts %(Skipping "#{package}" pinned to custom URL #{existing_url} (--vendor doesn't move a pin to another source; pass --from to choose one))
634
+ end
635
+
636
+ def repin_remote_package(package, url, existing_url, preload, env:, from: nil, integrity: nil, locked: false)
208
637
  # `url` was already resolved from the requested CDN, so an explicit
209
638
  # --from moves the pin instead of being overruled by its current one.
210
- return pin_remote_package(package, url, preload) if from
639
+ return pin_remote_package(package, url, preload, integrity: integrity, locked: locked) if from
211
640
 
212
641
  provider = packager.provider_for_url(existing_url)
213
642
 
214
643
  if provider.nil?
215
644
  puts %(Skipping "#{package}" pinned to custom URL #{existing_url})
216
645
  elsif provider == packager.provider_for_url(url)
217
- pin_remote_package(package, url, preload)
646
+ pin_remote_package(package, url, preload, integrity: integrity, locked: locked)
218
647
  elsif (provider_url = resolve_url_from_provider(package, url, provider, env: env))
219
- pin_remote_package(package, provider_url, preload)
648
+ pin_remote_package(package, provider_url, preload, integrity: integrity, locked: locked)
220
649
  else
221
650
  puts %(Keeping "#{package}" pinned to #{existing_url} (couldn't resolve it from #{provider}))
222
651
  end
223
652
  end
224
653
 
654
+ # Read from the command's own options rather than threaded through every
655
+ # pin path: update, pristine and an esm.run bundle's dependency pins all
656
+ # reach pin_remote_package too, and a keyword one of them forgets to pass
657
+ # silently hashes a pin the app asked not to hash. Only pin declares the
658
+ # flag, so anything else sees nil and hashes.
659
+ def compute_integrity?
660
+ options["integrity"] != false
661
+ end
662
+
663
+ # A remote pin is the one place an app trusts a CDN at runtime, so the pin
664
+ # records the hash of what was resolved and the browser checks the bytes it
665
+ # gets against it. integrity: false is an app's decision and stays; a hash
666
+ # the line already carried was computed for the old URL and was dropped
667
+ # when the line was read, so nil here means "hash this". +computed+ is the
668
+ # hash a download that was just refused already carries — the file isn't
669
+ # fetched again for it.
670
+ def remote_integrity_for(url, integrity, computed = nil)
671
+ return integrity if integrity == false
672
+
673
+ computed || Importmap::Integrity.for(packager.fetch_remote(url))
674
+ rescue Importmap::Packager::Error => error
675
+ puts %(Couldn't hash #{url} (#{error.message}); pinning it without an integrity hash)
676
+ nil
677
+ end
678
+
225
679
  def resolve_url_from_provider(package, reference_url, provider, env:)
226
680
  version = packager.extract_package_version_from(reference_url)
227
- response = packager.import("#{package}#{version}", env: env, from: provider)
681
+ response = packager.import(packager.package_spec_for(package, version), env: env, from: provider)
228
682
 
229
683
  response && response[:imports][package]
230
684
  rescue Importmap::Packager::Error => error
@@ -232,8 +686,52 @@ class Importmap::Commands < Thor
232
686
  nil
233
687
  end
234
688
 
689
+ # Importmap::Map expands a directory over the packages, so a key some other
690
+ # package's graph already maps resolves to that file whatever the pin about
691
+ # to be written says. Say so rather than write a line that does nothing.
692
+ def report_graph_shadowing(package)
693
+ mapped = Importmap::VendoredGraph.mapping_for(importmap_source, package)
694
+
695
+ puts %(Note: the graph of "#{mapped}" already maps "#{package}", and a mapped directory wins over a pin) if mapped
696
+ end
697
+
698
+ # Two directories mapping one package write their files under the same
699
+ # prefix, so at two versions the app gets one of them for every file the
700
+ # two share, and which one depends on the order of the lines.
701
+ def report_graph_conflict(pin)
702
+ directory, under, version = Importmap::VendoredGraph.mappings_in(pin).first
703
+ conflict = Importmap::VendoredGraph.conflict_in(importmap_source, under: under, version: version, except: directory)
704
+
705
+ puts %(Note: #{conflict.first} already maps "#{under}" at #{conflict.last}; a file both carry resolves to one of them) if conflict
706
+ end
707
+
708
+ def importmap_source
709
+ File.exist?("config/importmap.rb") ? File.read("config/importmap.rb") : ""
710
+ end
711
+
712
+ # How many files came down with the entry, so an app can see that a chunked
713
+ # package was vendored whole rather than as the one file that would 404.
714
+ def graph_note(graph)
715
+ %( (with #{graph.size} sibling #{"file".pluralize(graph.size)})) if graph && graph.size.positive?
716
+ end
717
+
718
+ def update_importmap_with_graph_pin(package, pin)
719
+ report_graph_conflict(pin)
720
+
721
+ if packager.vendored_graph(package).mapped?
722
+ gsub_file("config/importmap.rb", packager.vendored_graph(package).line_regexp, pin, verbose: false)
723
+ else
724
+ append_to_file("config/importmap.rb", "#{pin}\n", verbose: false)
725
+ end
726
+
727
+ packager.reload!
728
+ end
729
+
235
730
  def update_importmap_with_pin(package, pin)
236
731
  new_pin = "#{pin}\n"
732
+ # Two esm.run bundles can need one dependency: skipped on the first
733
+ # bundle's turn and pinned on the second's, it is in the tree after all.
734
+ skipped.delete(package)
237
735
 
238
736
  if packager.packaged?(package)
239
737
  gsub_file("config/importmap.rb", Importmap::Map.pin_line_regexp_for(package), pin, verbose: false)
@@ -244,8 +742,13 @@ class Importmap::Commands < Thor
244
742
  packager.reload!
245
743
  end
246
744
 
247
- def handle_package_not_found(packages, from)
248
- puts "Couldn't find any packages in #{packages.inspect} on #{from}"
745
+ # jspm answers 401 with its generator's reason in the body — the subpath a
746
+ # dependency doesn't export, the module it couldn't find — and that is the
747
+ # one thing that tells an app developer whether to try another CDN.
748
+ def handle_package_not_found(packages, from, reason: packager.last_import_error)
749
+ detail = Importmap::ProviderChain.tidy_reason(reason)
750
+
751
+ puts "Couldn't find any packages in #{packages.inspect} on #{from}#{" (#{detail})" if detail}"
249
752
  end
250
753
 
251
754
  def remove_line_from_file(path, pattern)