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.
- checksums.yaml +7 -0
- data/README.md +116 -0
- data/exe/shadwire +6 -0
- data/lib/shadwire/cli.rb +162 -0
- data/lib/shadwire/commands/add.rb +123 -0
- data/lib/shadwire/commands/diff.rb +68 -0
- data/lib/shadwire/commands/info.rb +94 -0
- data/lib/shadwire/commands/init.rb +101 -0
- data/lib/shadwire/commands/list.rb +36 -0
- data/lib/shadwire/commands/remove.rb +116 -0
- data/lib/shadwire/commands/search.rb +52 -0
- data/lib/shadwire/commands/status.rb +140 -0
- data/lib/shadwire/commands/update.rb +147 -0
- data/lib/shadwire/config.rb +95 -0
- data/lib/shadwire/dependencies.rb +148 -0
- data/lib/shadwire/differ.rb +97 -0
- data/lib/shadwire/errors.rb +8 -0
- data/lib/shadwire/installer.rb +91 -0
- data/lib/shadwire/project.rb +91 -0
- data/lib/shadwire/registry_client.rb +79 -0
- data/lib/shadwire/resolver.rb +43 -0
- data/lib/shadwire/ui.rb +46 -0
- data/lib/shadwire/version.rb +5 -0
- data/lib/shadwire.rb +22 -0
- metadata +85 -0
|
@@ -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,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
|
data/lib/shadwire/ui.rb
ADDED
|
@@ -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
|
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"
|