Class: Shadwire::Dependencies

Inherits:
Object
  • Object
show all
Defined in:
lib/shadwire/dependencies.rb

Overview

Applies a component's non-file dependencies to a consuming Rails app: gems (via bundle add), importmap pins, and the Tailwind @import. Each method detects what is missing, then applies only when told to (yes: or a truthy confirm: callback) — the command owns the prompting. On an unsupported stack (no importmap / no tailwindcss-rails) it returns manual instructions and mutates nothing.

Every method returns a normalized result hash:

{ applied: [...], skipped: [...], pending: [...], manual: [...], failed: [...] }

failed carries the things we tried to apply and could not — a bundle add that exited non-zero, above all. Callers must surface it and exit non-zero: reporting a gem as installed when it is not leaves the app raising uninitialized constant ViewComponent at request time, which looks unrelated to the install that caused it.

Constant Summary collapse

DEFAULT_RUNNER =

Runs a shell command in the app root, isolated from the CLI's own bundle.

lambda do |cmd, chdir:|
  require "bundler"
  Bundler.with_unbundled_env { system(*cmd, chdir: chdir) }
end

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project, runner: DEFAULT_RUNNER) ⇒ Dependencies

Returns a new instance of Dependencies.



39
40
41
42
# File 'lib/shadwire/dependencies.rb', line 39

def initialize(project, runner: DEFAULT_RUNNER)
  @project = project
  @runner = runner
end

Class Method Details

.raise_on_failure!(*results) ⇒ Object

Raises when any of the given result hashes reports something it failed to apply, so a command that could not install a dependency exits non-zero instead of claiming success.

Raises:



31
32
33
34
35
36
37
# File 'lib/shadwire/dependencies.rb', line 31

def self.raise_on_failure!(*results)
  failed = results.flat_map { |result| Array(result[:failed]) }.uniq
  return if failed.empty?

  raise Error, "Failed to install: #{failed.join(", ")}. " \
               "Run `bundle add #{failed.join(" ")}` in the app and re-run this command."
end

Instance Method Details

#ensure_gems(names, yes:, confirm: nil) ⇒ Object

Ensures each gem name is present, running bundle add <missing...>.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/shadwire/dependencies.rb', line 45

def ensure_gems(names, yes:, confirm: nil)
  present = names.select { |name| @project.gem?(name) }
  missing = names - present
  return result(skipped: present) if missing.empty?

  unless apply?(yes, confirm, "Add gems: #{missing.join(", ")} (bundle add)")
    return result(skipped: present, pending: missing)
  end

  # `system` returns false on a non-zero exit and nil when the command could
  # not be run at all; neither means the gems landed.
  return result(skipped: present, failed: missing) unless @runner.call(["bundle", "add", *missing], chdir: @project.root)

  result(applied: missing, skipped: present)
end

#ensure_importmap_pins(pins, yes:, confirm: nil) ⇒ Object

Ensures each pin ({ "name" =>, "to" => }) exists in config/importmap.rb.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/shadwire/dependencies.rb', line 62

def ensure_importmap_pins(pins, yes:, confirm: nil)
  # importmap? is true when the gem is in the Gemfile, but config/importmap.rb
  # only exists once `importmap:install` has run — guard the read so an
  # unconfigured app falls back to a manual instruction instead of crashing.
  path = @project.importmap_path
  unless @project.importmap? && File.exist?(path)
    return result(manual: pins.map { |p| manual_pin_instruction(p) })
  end

  content = File.read(path)
  present, missing = pins.partition { |pin| pin_present?(content, pin) }
  return result(skipped: present.map { |p| p["name"] }) if missing.empty?

  names = missing.map { |p| p["name"] }
  unless apply?(yes, confirm, "Add importmap pins: #{names.join(", ")}")
    return result(skipped: present.map { |p| p["name"] }, pending: names)
  end

  appended = content
  appended += "\n" unless appended.end_with?("\n")
  missing.each { |pin| appended += %(pin "#{pin["name"]}", to: "#{pin["to"]}"\n) }
  File.write(path, appended)
  result(applied: names, skipped: present.map { |p| p["name"] })
end

#ensure_tailwind_import(vendor_css_target, css_entry: "app/assets/tailwind/application.css", yes:, confirm: nil) ⇒ Object

Ensures @import "<relative>"; (pointing at the vendored shadwire.css) appears after @import "tailwindcss"; in the Tailwind css entry.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/shadwire/dependencies.rb', line 89

def ensure_tailwind_import(vendor_css_target, css_entry: "app/assets/tailwind/application.css", yes:, confirm: nil)
  css_path = @project.tailwind_css_path(css_entry)
  unless @project.tailwindcss_rails? && File.exist?(css_path)
    return result(manual: [manual_tailwind_instruction(vendor_css_target, css_entry)])
  end

  relative = relative_import(vendor_css_target, css_entry)
  content = File.read(css_path)
  return result(skipped: [relative]) if content.include?(%(@import "#{relative}"))

  unless apply?(yes, confirm, "Add Tailwind import: #{relative}")
    return result(pending: [relative])
  end

  File.write(css_path, insert_import(content, relative))
  result(applied: [relative])
end