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.



52
53
54
55
# File 'lib/shadwire/dependencies.rb', line 52

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

Class Method Details

.bundle_add_command(result) ⇒ Object

bundle add <names...> for a result, with its group when it has one.



47
48
49
50
# File 'lib/shadwire/dependencies.rb', line 47

def self.bundle_add_command(result)
  suffix = result[:group] ? " --group #{result[:group]}" : ""
  "bundle add #{Array(result[:failed]).join(" ")}#{suffix}"
end

.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. Each result carries its own group:, so the recovery command it suggests matches the one that failed. Collapsing them would tell the user to bundle add shadwire, landing the development-only CLI in the app's runtime dependencies.

Raises:



35
36
37
38
39
40
41
42
43
44
# File 'lib/shadwire/dependencies.rb', line 35

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

  names = failing.flat_map { |result| Array(result[:failed]) }.uniq
  commands = failing.map { |result| bundle_add_command(result) }

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

Instance Method Details

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

Ensures each gem name is present, running bundle add <missing...>. group: adds them to a bundler group — used for the shadwire CLI itself, which belongs in development, not in the app's runtime dependencies.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/shadwire/dependencies.rb', line 60

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

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

  command = ["bundle", "add", *missing]
  command.push("--group", group.to_s) if group

  # `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, group:) unless @runner.call(command, chdir: @project.root)

  result(applied: missing, skipped: present, group:)
end

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

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



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/shadwire/dependencies.rb', line 81

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.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/shadwire/dependencies.rb', line 108

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