shadwire 0.2.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.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Shadwire
6
+ # Applies a component's non-file dependencies to a consuming Rails app: gems
7
+ # (via `bundle add`), importmap pins, and the Tailwind `@import`. Each method
8
+ # detects what is missing, then applies only when told to (`yes:` or a truthy
9
+ # `confirm:` callback) — the command owns the prompting. On an unsupported
10
+ # stack (no importmap / no tailwindcss-rails) it returns manual instructions
11
+ # and mutates nothing.
12
+ #
13
+ # Every method returns a normalized result hash:
14
+ # { applied: [...], skipped: [...], pending: [...], manual: [...], failed: [...] }
15
+ #
16
+ # `failed` carries the things we tried to apply and could not — a `bundle add`
17
+ # that exited non-zero, above all. Callers must surface it and exit non-zero:
18
+ # reporting a gem as installed when it is not leaves the app raising
19
+ # `uninitialized constant ViewComponent` at request time, which looks unrelated
20
+ # to the install that caused it.
21
+ class Dependencies
22
+ # Runs a shell command in the app root, isolated from the CLI's own bundle.
23
+ DEFAULT_RUNNER = lambda do |cmd, chdir:|
24
+ require "bundler"
25
+ Bundler.with_unbundled_env { system(*cmd, chdir: chdir) }
26
+ end
27
+
28
+ # Raises when any of the given result hashes reports something it failed to
29
+ # apply, so a command that could not install a dependency exits non-zero
30
+ # instead of claiming success.
31
+ def self.raise_on_failure!(*results)
32
+ failed = results.flat_map { |result| Array(result[:failed]) }.uniq
33
+ return if failed.empty?
34
+
35
+ raise Error, "Failed to install: #{failed.join(", ")}. " \
36
+ "Run `bundle add #{failed.join(" ")}` in the app and re-run this command."
37
+ end
38
+
39
+ def initialize(project, runner: DEFAULT_RUNNER)
40
+ @project = project
41
+ @runner = runner
42
+ end
43
+
44
+ # Ensures each gem name is present, running `bundle add <missing...>`.
45
+ def ensure_gems(names, yes:, confirm: nil)
46
+ present = names.select { |name| @project.gem?(name) }
47
+ missing = names - present
48
+ return result(skipped: present) if missing.empty?
49
+
50
+ unless apply?(yes, confirm, "Add gems: #{missing.join(", ")} (bundle add)")
51
+ return result(skipped: present, pending: missing)
52
+ end
53
+
54
+ # `system` returns false on a non-zero exit and nil when the command could
55
+ # not be run at all; neither means the gems landed.
56
+ return result(skipped: present, failed: missing) unless @runner.call(["bundle", "add", *missing], chdir: @project.root)
57
+
58
+ result(applied: missing, skipped: present)
59
+ end
60
+
61
+ # Ensures each pin ({ "name" =>, "to" => }) exists in config/importmap.rb.
62
+ def ensure_importmap_pins(pins, yes:, confirm: nil)
63
+ # importmap? is true when the gem is in the Gemfile, but config/importmap.rb
64
+ # only exists once `importmap:install` has run — guard the read so an
65
+ # unconfigured app falls back to a manual instruction instead of crashing.
66
+ path = @project.importmap_path
67
+ unless @project.importmap? && File.exist?(path)
68
+ return result(manual: pins.map { |p| manual_pin_instruction(p) })
69
+ end
70
+
71
+ content = File.read(path)
72
+ present, missing = pins.partition { |pin| pin_present?(content, pin) }
73
+ return result(skipped: present.map { |p| p["name"] }) if missing.empty?
74
+
75
+ names = missing.map { |p| p["name"] }
76
+ unless apply?(yes, confirm, "Add importmap pins: #{names.join(", ")}")
77
+ return result(skipped: present.map { |p| p["name"] }, pending: names)
78
+ end
79
+
80
+ appended = content
81
+ appended += "\n" unless appended.end_with?("\n")
82
+ missing.each { |pin| appended += %(pin "#{pin["name"]}", to: "#{pin["to"]}"\n) }
83
+ File.write(path, appended)
84
+ result(applied: names, skipped: present.map { |p| p["name"] })
85
+ end
86
+
87
+ # Ensures `@import "<relative>";` (pointing at the vendored shadwire.css)
88
+ # appears after `@import "tailwindcss";` in the Tailwind css entry.
89
+ def ensure_tailwind_import(vendor_css_target, css_entry: "app/assets/tailwind/application.css", yes:, confirm: nil)
90
+ css_path = @project.tailwind_css_path(css_entry)
91
+ unless @project.tailwindcss_rails? && File.exist?(css_path)
92
+ return result(manual: [manual_tailwind_instruction(vendor_css_target, css_entry)])
93
+ end
94
+
95
+ relative = relative_import(vendor_css_target, css_entry)
96
+ content = File.read(css_path)
97
+ return result(skipped: [relative]) if content.include?(%(@import "#{relative}"))
98
+
99
+ unless apply?(yes, confirm, "Add Tailwind import: #{relative}")
100
+ return result(pending: [relative])
101
+ end
102
+
103
+ File.write(css_path, insert_import(content, relative))
104
+ result(applied: [relative])
105
+ end
106
+
107
+ private
108
+
109
+ def result(applied: [], skipped: [], pending: [], manual: [], failed: [])
110
+ { applied:, skipped:, pending:, manual:, failed: }
111
+ end
112
+
113
+ def apply?(yes, confirm, description)
114
+ yes || !!confirm&.call(description)
115
+ end
116
+
117
+ def pin_present?(content, pin)
118
+ content.match?(/^\s*pin\s+["']#{Regexp.escape(pin["name"])}["']/)
119
+ end
120
+
121
+ def relative_import(vendor_css_target, css_entry)
122
+ Pathname(vendor_css_target).relative_path_from(Pathname(css_entry).dirname).to_s
123
+ end
124
+
125
+ # Inserts the import right after the `@import "tailwindcss";` line, or at the
126
+ # top when that line is absent.
127
+ def insert_import(content, relative)
128
+ import_line = %(@import "#{relative}";)
129
+ lines = content.lines
130
+ index = lines.index { |line| line.match?(/^\s*@import\s+["']tailwindcss["']/) }
131
+ if index
132
+ lines.insert(index + 1, "#{import_line}\n")
133
+ else
134
+ lines.unshift("#{import_line}\n")
135
+ end
136
+ lines.join
137
+ end
138
+
139
+ def manual_pin_instruction(pin)
140
+ %(Add to config/importmap.rb: pin "#{pin["name"]}", to: "#{pin["to"]}")
141
+ end
142
+
143
+ def manual_tailwind_instruction(vendor_css_target, css_entry)
144
+ relative = relative_import(vendor_css_target, css_entry)
145
+ %(Add to #{css_entry} after `@import "tailwindcss";`: @import "#{relative}";)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadwire
4
+ # Minimal line-based unified diff, stdlib only — no diff gem. Used by `diff`
5
+ # and `update` to show how a locally-edited file drifts from the registry copy.
6
+ # Returns "" when the two texts are identical. Output normalizes line endings
7
+ # (each rendered line is chomped) since it is for human display, not patching;
8
+ # byte-exact drift detection is done by the caller comparing raw content.
9
+ module Differ
10
+ CONTEXT = 3
11
+
12
+ # An edit is [kind, old_index_or_nil, new_index_or_nil, text] where kind is
13
+ # one of :eq, :del, :ins.
14
+
15
+ module_function
16
+
17
+ def unified(old_text, new_text, path: nil)
18
+ return "" if old_text == new_text
19
+
20
+ edits = backtrack(lcs_table(old_text.lines, new_text.lines), old_text.lines, new_text.lines)
21
+ hunks = group(edits)
22
+ return "" if hunks.empty?
23
+
24
+ render(hunks, path)
25
+ end
26
+
27
+ # Longest-common-subsequence length table over the two line arrays.
28
+ def lcs_table(a, b)
29
+ table = Array.new(a.length + 1) { Array.new(b.length + 1, 0) }
30
+ a.each_index do |i|
31
+ b.each_index do |j|
32
+ table[i + 1][j + 1] = a[i] == b[j] ? table[i][j] + 1 : [table[i][j + 1], table[i + 1][j]].max
33
+ end
34
+ end
35
+ table
36
+ end
37
+
38
+ # Walks the table from the bottom-right to recover the edit sequence.
39
+ def backtrack(table, a, b)
40
+ edits = []
41
+ i = a.length
42
+ j = b.length
43
+ while i.positive? || j.positive?
44
+ if i.positive? && j.positive? && a[i - 1] == b[j - 1]
45
+ edits.unshift([:eq, i - 1, j - 1, a[i - 1]])
46
+ i -= 1
47
+ j -= 1
48
+ elsif j.positive? && (i.zero? || table[i][j - 1] >= table[i - 1][j])
49
+ edits.unshift([:ins, nil, j - 1, b[j - 1]])
50
+ j -= 1
51
+ else
52
+ edits.unshift([:del, i - 1, nil, a[i - 1]])
53
+ i -= 1
54
+ end
55
+ end
56
+ edits
57
+ end
58
+
59
+ # Groups changed edits into hunks, each padded with CONTEXT lines and merged
60
+ # when they overlap.
61
+ def group(edits)
62
+ ranges = []
63
+ edits.each_index.select { |k| edits[k][0] != :eq }.each do |k|
64
+ lo = [k - CONTEXT, 0].max
65
+ hi = [k + CONTEXT, edits.length - 1].min
66
+ if ranges.any? && lo <= ranges.last[1] + 1
67
+ ranges.last[1] = [ranges.last[1], hi].max
68
+ else
69
+ ranges << [lo, hi]
70
+ end
71
+ end
72
+ ranges.map { |lo, hi| edits[lo..hi] }
73
+ end
74
+
75
+ def render(hunks, path)
76
+ lines = []
77
+ lines << "--- a/#{path}" << "+++ b/#{path}" if path
78
+ hunks.each do |hunk|
79
+ lines << header(hunk)
80
+ hunk.each { |kind, _, _, text| lines << "#{prefix(kind)}#{text.chomp}" }
81
+ end
82
+ "#{lines.join("\n")}\n"
83
+ end
84
+
85
+ def header(hunk)
86
+ old = hunk.reject { |kind, *| kind == :ins }
87
+ new = hunk.reject { |kind, *| kind == :del }
88
+ old_start = (old.first&.at(1) || 0) + 1
89
+ new_start = (new.first&.at(2) || 0) + 1
90
+ "@@ -#{old_start},#{old.size} +#{new_start},#{new.size} @@"
91
+ end
92
+
93
+ def prefix(kind)
94
+ { eq: " ", del: "-", ins: "+" }.fetch(kind)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadwire
4
+ class Error < StandardError; end
5
+ class RegistryError < Error; end
6
+ class ConfigError < Error; end
7
+ class ProjectError < Error; end
8
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Shadwire
7
+ # Writes published registry file entries into a consuming Rails app, applying
8
+ # the same safety checks bin/sync_registry enforces. It is non-interactive:
9
+ # the overwrite policy and an optional confirm callable are injected so that
10
+ # commands own the UI and tests stay deterministic.
11
+ #
12
+ # A file entry is a Hash of the published shape:
13
+ # { "target" => "app/.../x.rb", "type" => "component", "content" => "..." }
14
+ class Installer
15
+ # @param root [String, Pathname] the consuming app root
16
+ # @param overwrite [Symbol] :always, :never, or :prompt
17
+ # @param confirm [#call, nil] called with the target string when :prompt;
18
+ # truthy result overwrites, falsy skips
19
+ def initialize(root, overwrite: :prompt, confirm: nil)
20
+ @root = Pathname(root).expand_path
21
+ @overwrite = overwrite
22
+ @confirm = confirm
23
+ end
24
+
25
+ # Validates all targets (fail-fast), then writes.
26
+ # @param files [Array<Hash>] flat array of published file entries
27
+ # @return [Hash] { written: [target strings], skipped: [target strings] }
28
+ def install(files)
29
+ planned = validate(files)
30
+
31
+ written = []
32
+ skipped = []
33
+
34
+ planned.each do |target, content|
35
+ path = @root.join(target)
36
+
37
+ if path.exist? && !overwrite?(target)
38
+ skipped << target
39
+ next
40
+ end
41
+
42
+ FileUtils.mkdir_p(path.dirname)
43
+ path.write(content)
44
+ written << target
45
+ end
46
+
47
+ { written:, skipped: }
48
+ end
49
+
50
+ private
51
+
52
+ # Runs the escape-path and conflict checks before any write, de-duplicating
53
+ # identical (target, content) entries. Returns target => content pairs in
54
+ # first-seen order.
55
+ def validate(files)
56
+ seen = {}
57
+
58
+ files.each do |file|
59
+ target = file.fetch("target")
60
+ content = file.fetch("content")
61
+ absolute = @root.join(target).expand_path
62
+
63
+ unless inside_directory?(absolute, @root)
64
+ raise RegistryError, "Unsafe install target escapes app root: #{target}"
65
+ end
66
+
67
+ if seen.key?(target) && seen[target] != content
68
+ raise RegistryError, "Conflicting install target: #{target} maps to two different contents"
69
+ end
70
+
71
+ seen[target] = content
72
+ end
73
+
74
+ seen
75
+ end
76
+
77
+ def overwrite?(target)
78
+ case @overwrite
79
+ when :always then true
80
+ when :never then false
81
+ when :prompt then !!@confirm&.call(target)
82
+ else false
83
+ end
84
+ end
85
+
86
+ def inside_directory?(path, directory)
87
+ relative = path.relative_path_from(directory)
88
+ relative.each_filename.first != ".."
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadwire
4
+ # Detects a consuming Rails app and its front-end stack by inspecting the
5
+ # Gemfile / Gemfile.lock and a few conventional files. Pure filesystem reads —
6
+ # never loads the app or Bundler.
7
+ class Project
8
+ attr_reader :root
9
+
10
+ def initialize(root)
11
+ @root = root.to_s
12
+ end
13
+
14
+ def rails?
15
+ File.exist?(path("config/application.rb")) || gem?("rails")
16
+ end
17
+
18
+ # True when the gem is declared in Gemfile.lock (authoritative when present)
19
+ # or the Gemfile.
20
+ def gem?(name)
21
+ (File.exist?(lockfile_path) && lock_declares?(name)) || gemfile_declares?(name)
22
+ end
23
+
24
+ def importmap?
25
+ gem?("importmap-rails") || File.exist?(importmap_path)
26
+ end
27
+
28
+ def tailwindcss_rails?
29
+ gem?("tailwindcss-rails")
30
+ end
31
+
32
+ def stimulus?
33
+ gem?("stimulus-rails") || File.exist?(path("app/javascript/controllers/index.js"))
34
+ end
35
+
36
+ def importmap_path
37
+ path("config/importmap.rb")
38
+ end
39
+
40
+ # Rails includes every app/helpers/**/*_helper.rb module into views unless an
41
+ # app opts out. When it does, the Ui::*Helper modules need per-controller
42
+ # `helper` calls, so `status` surfaces it.
43
+ def include_all_helpers?
44
+ config = path("config/application.rb")
45
+ return true unless File.exist?(config)
46
+
47
+ !File.read(config).match?(/include_all_helpers\s*=\s*false/)
48
+ end
49
+
50
+ # A monolithic app/helpers/ui_helper.rb left over from before helpers were
51
+ # split per item. No longer published; safe for the app to delete.
52
+ def legacy_helper?
53
+ File.exist?(path("app/helpers/ui_helper.rb"))
54
+ end
55
+
56
+ def tailwind_css_path(relative = "app/assets/tailwind/application.css")
57
+ path(relative)
58
+ end
59
+
60
+ # Raises ProjectError unless this looks like a Rails app.
61
+ def raise_unless_rails!
62
+ return if rails?
63
+
64
+ raise ProjectError,
65
+ "Not a Rails app: no config/application.rb or `gem \"rails\"` found in #{@root}"
66
+ end
67
+
68
+ private
69
+
70
+ def path(relative)
71
+ File.join(@root, relative)
72
+ end
73
+
74
+ def gemfile_path
75
+ path("Gemfile")
76
+ end
77
+
78
+ def lockfile_path
79
+ path("Gemfile.lock")
80
+ end
81
+
82
+ def gemfile_declares?(name)
83
+ File.exist?(gemfile_path) &&
84
+ File.read(gemfile_path).match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/)
85
+ end
86
+
87
+ def lock_declares?(name)
88
+ File.read(lockfile_path).match?(/^\s+#{Regexp.escape(name)} \(/)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ # Fetches the published registry over file:// or http(s)://.
7
+ # Both #index and #item are memoized within the instance.
8
+ class RegistryClient
9
+ def initialize(base_url)
10
+ # Normalise: drop any trailing slash so URL building is consistent.
11
+ @base_url = base_url.chomp("/")
12
+ @cache = {}
13
+ end
14
+
15
+ # Returns the parsed index.json hash (memoized).
16
+ def index
17
+ @cache[:__index__] ||= fetch("index.json")
18
+ end
19
+
20
+ # Returns the parsed {name}.json hash (memoized per name).
21
+ # Raises Shadwire::RegistryError if the item is not found.
22
+ def item(name)
23
+ @cache[name] ||= fetch("#{name}.json", name:)
24
+ end
25
+
26
+ private
27
+
28
+ def fetch(filename, name: nil)
29
+ url = "#{@base_url}/#{filename}"
30
+ if @base_url.start_with?("file://")
31
+ fetch_file(url, name:)
32
+ else
33
+ fetch_http(url, name:)
34
+ end
35
+ end
36
+
37
+ def fetch_file(url, name: nil)
38
+ # file:///abs/path/... → /abs/path/...
39
+ local_path = url.sub(/\Afile:\/\//, "")
40
+ unless File.exist?(local_path)
41
+ raise RegistryError, missing_message(name, url)
42
+ end
43
+
44
+ parse(File.read(local_path), url)
45
+ end
46
+
47
+ def fetch_http(url, name: nil)
48
+ require "net/http"
49
+ require "uri"
50
+
51
+ uri = URI.parse(url)
52
+ response = Net::HTTP.get_response(uri)
53
+
54
+ unless response.is_a?(Net::HTTPSuccess)
55
+ raise RegistryError, missing_message(name, url, code: response.code)
56
+ end
57
+
58
+ parse(response.body, url)
59
+ rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
60
+ # An offline machine, a DNS failure or a TLS problem is an ordinary
61
+ # condition here, not something to surface as a Ruby backtrace.
62
+ raise RegistryError, "Could not reach the registry at #{url}: #{e.message}"
63
+ end
64
+
65
+ # A registry that serves HTML (a 200 error page, a captive portal) would
66
+ # otherwise blow up as a bare JSON::ParserError.
67
+ def parse(body, url)
68
+ JSON.parse(body)
69
+ rescue JSON::ParserError => e
70
+ raise RegistryError, "Registry response from #{url} is not valid JSON: #{e.message}"
71
+ end
72
+
73
+ def missing_message(name, url, code: nil)
74
+ label = name ? "item #{name.inspect}" : "index"
75
+ suffix = code ? " (HTTP #{code})" : ""
76
+ "Registry #{label} not found at #{url}#{suffix}"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadwire
4
+ # Resolves a list of item names to a de-duplicated, dependency-first ordered
5
+ # array of parsed item hashes. Uses post-order DFS so every dependency
6
+ # appears before the item that requires it. Unknown names — whether
7
+ # top-level or pulled in transitively — raise Shadwire::RegistryError.
8
+ class Resolver
9
+ # Public API: returns an Array of item Hashes, dependency-first.
10
+ def self.expand(names, client)
11
+ new(client).expand(names)
12
+ end
13
+
14
+ def initialize(client)
15
+ @client = client
16
+ @visited = {} # name => true once fully processed
17
+ @result = []
18
+ end
19
+
20
+ def expand(names)
21
+ names.each { |name| visit(name) }
22
+ @result
23
+ end
24
+
25
+ private
26
+
27
+ def visit(name)
28
+ return if @visited[name]
29
+
30
+ # Mark before recursing to break potential cycles and prevent double-add.
31
+ @visited[name] = true
32
+
33
+ # Fetch the item; RegistryError propagates on 404 / missing file.
34
+ data = @client.item(name)
35
+
36
+ # Recurse into dependencies first (post-order).
37
+ Array(data["registryDependencies"]).each { |dep| visit(dep) }
38
+
39
+ # Add this item after all its deps are in the result.
40
+ @result << data
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadwire
4
+ # Thin terminal-and-agent-friendly IO wrapper. Output streams and the confirm
5
+ # behavior are injectable so commands stay decoupled from Thor and tests can
6
+ # capture output and answer prompts deterministically. With yes: true every
7
+ # confirmation is auto-accepted (the agent/CI path).
8
+ class UI
9
+ def initialize(out: $stdout, err: $stderr, yes: false, confirm_proc: nil)
10
+ @out = out
11
+ @err = err
12
+ @yes = yes
13
+ @confirm_proc = confirm_proc
14
+ end
15
+
16
+ def say(message)
17
+ @out.puts(message)
18
+ end
19
+
20
+ def warn(message)
21
+ @err.puts(message)
22
+ end
23
+
24
+ def error(message)
25
+ @err.puts(message)
26
+ end
27
+
28
+ def confirm?(question, default: false)
29
+ return true if @yes
30
+ return !!@confirm_proc.call(question) if @confirm_proc
31
+
32
+ prompt(question, default)
33
+ end
34
+
35
+ private
36
+
37
+ def prompt(question, default)
38
+ suffix = default ? "[Y/n]" : "[y/N]"
39
+ @out.print("#{question} #{suffix} ")
40
+ answer = $stdin.gets
41
+ return default if answer.nil? || answer.strip.empty?
42
+
43
+ answer.strip.downcase.start_with?("y")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadwire
4
+ VERSION = "0.2.0"
5
+ end
data/lib/shadwire.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shadwire/version"
4
+ require_relative "shadwire/errors"
5
+ require_relative "shadwire/config"
6
+ require_relative "shadwire/project"
7
+ require_relative "shadwire/registry_client"
8
+ require_relative "shadwire/resolver"
9
+ require_relative "shadwire/installer"
10
+ require_relative "shadwire/dependencies"
11
+ require_relative "shadwire/differ"
12
+ require_relative "shadwire/ui"
13
+ require_relative "shadwire/commands/init"
14
+ require_relative "shadwire/commands/add"
15
+ require_relative "shadwire/commands/list"
16
+ require_relative "shadwire/commands/search"
17
+ require_relative "shadwire/commands/info"
18
+ require_relative "shadwire/commands/diff"
19
+ require_relative "shadwire/commands/update"
20
+ require_relative "shadwire/commands/remove"
21
+ require_relative "shadwire/commands/status"
22
+ require_relative "shadwire/cli"