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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Shadwire
|
|
6
|
+
module Commands
|
|
7
|
+
# Lists the registry catalog: one entry (name/type/title/description) per
|
|
8
|
+
# published item. Read-only — needs a registry but no Rails app. With
|
|
9
|
+
# json: true it emits the catalog array; otherwise a "name — title" line each.
|
|
10
|
+
class List
|
|
11
|
+
def initialize(root:, registry: nil, json: false, ui: UI.new)
|
|
12
|
+
@root = root.to_s
|
|
13
|
+
@registry_override = registry
|
|
14
|
+
@json = json
|
|
15
|
+
@ui = ui
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
client = RegistryClient.new(@registry_override || Config.load(@root).registry)
|
|
20
|
+
items = client.index.fetch("items", [])
|
|
21
|
+
emit(items)
|
|
22
|
+
items
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def emit(items)
|
|
28
|
+
if @json
|
|
29
|
+
@ui.say(JSON.generate(items))
|
|
30
|
+
else
|
|
31
|
+
items.each { |item| @ui.say("#{item["name"]} — #{item["title"]}") }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Shadwire
|
|
6
|
+
module Commands
|
|
7
|
+
# Uninstalls one or more components. It deletes only files unique to the
|
|
8
|
+
# removed items — never the shared base (ui_component/ui_helper/shadwire.css)
|
|
9
|
+
# and never a file still listed by another installed entry — then prunes any
|
|
10
|
+
# emptied directory under the components root and drops the items from
|
|
11
|
+
# shadwire.json. Importmap pins that no remaining component uses are reported
|
|
12
|
+
# (not auto-removed — dep cleanup is left to the user). Confirms first unless
|
|
13
|
+
# --yes.
|
|
14
|
+
class Remove
|
|
15
|
+
def initialize(root:, names:, yes: false, registry: nil, json: false, ui: UI.new(yes:))
|
|
16
|
+
@root = root.to_s
|
|
17
|
+
@names = names
|
|
18
|
+
@yes = yes
|
|
19
|
+
@registry_override = registry
|
|
20
|
+
@json = json
|
|
21
|
+
@ui = ui
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
raise Shadwire::Error, "remove requires at least one component name" if @names.empty?
|
|
26
|
+
|
|
27
|
+
Project.new(@root).raise_unless_rails!
|
|
28
|
+
config = Config.load(@root)
|
|
29
|
+
client = RegistryClient.new(@registry_override || config.registry)
|
|
30
|
+
@names.each do |name|
|
|
31
|
+
raise Shadwire::Error, "#{name} is not installed" unless config.installed.key?(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
base_targets = client.index.fetch("base").fetch("files").map { |file| file["target"] }
|
|
35
|
+
removed_targets = targets_for(config, @names)
|
|
36
|
+
remaining = config.installed.keys - @names
|
|
37
|
+
other_targets = targets_for(config, remaining)
|
|
38
|
+
|
|
39
|
+
deletable = (removed_targets - base_targets - other_targets).select { |target| on_disk?(target) }
|
|
40
|
+
kept_shared = base_targets & removed_targets
|
|
41
|
+
|
|
42
|
+
unless @yes || @ui.confirm?("Remove #{deletable.size} files for #{@names.join(", ")}?")
|
|
43
|
+
@ui.say("Aborted; nothing removed.")
|
|
44
|
+
return { removed: [], deleted: [], keptShared: kept_shared, unusedPins: [] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
deletable.each { |target| File.delete(File.join(@root, target)) }
|
|
48
|
+
prune_empty_dirs(deletable, config)
|
|
49
|
+
|
|
50
|
+
@names.each { |name| config.forget(name) }
|
|
51
|
+
config.save
|
|
52
|
+
|
|
53
|
+
result = {
|
|
54
|
+
removed: @names,
|
|
55
|
+
deleted: deletable,
|
|
56
|
+
keptShared: kept_shared,
|
|
57
|
+
unusedPins: unused_pins(client, remaining)
|
|
58
|
+
}
|
|
59
|
+
emit(result)
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def targets_for(config, names)
|
|
66
|
+
names.flat_map { |name| config.installed[name]["files"] }.uniq
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def on_disk?(target)
|
|
70
|
+
File.exist?(File.join(@root, target))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Removes now-empty directories from each deleted file's dir upward, bounded
|
|
74
|
+
# strictly inside the components root (never the root itself or above).
|
|
75
|
+
def prune_empty_dirs(deleted, config)
|
|
76
|
+
components_root = File.expand_path(File.join(@root, config.aliases.fetch("components", "app/components")))
|
|
77
|
+
deleted.each do |target|
|
|
78
|
+
dir = File.dirname(File.expand_path(File.join(@root, target)))
|
|
79
|
+
while dir.start_with?("#{components_root}#{File::SEPARATOR}") && File.directory?(dir) && Dir.empty?(dir)
|
|
80
|
+
Dir.rmdir(dir)
|
|
81
|
+
dir = File.dirname(dir)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Pins of the removed items that no still-installed item pins.
|
|
87
|
+
def unused_pins(client, remaining)
|
|
88
|
+
remaining_names = remaining.map { |name| pins_for(client, name).map { |pin| pin["name"] } }.flatten
|
|
89
|
+
@names.flat_map { |name| pins_for(client, name) }
|
|
90
|
+
.reject { |pin| remaining_names.include?(pin["name"]) }
|
|
91
|
+
.uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pins_for(client, name)
|
|
95
|
+
Array(client.item(name)["importmap"])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def emit(result)
|
|
99
|
+
return @ui.say(JSON.generate(result)) if @json
|
|
100
|
+
|
|
101
|
+
human(result)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def human(result)
|
|
105
|
+
return if result[:removed].empty?
|
|
106
|
+
|
|
107
|
+
@ui.say("Removed #{result[:removed].join(", ")}.")
|
|
108
|
+
result[:deleted].each { |target| @ui.say(" delete #{target}") }
|
|
109
|
+
result[:keptShared].each { |target| @ui.say(" keep #{target} (shared)") }
|
|
110
|
+
result[:unusedPins].each do |pin|
|
|
111
|
+
@ui.say(%( note importmap pin "#{pin["name"]}" is now unused; review config/importmap.rb))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Shadwire
|
|
6
|
+
module Commands
|
|
7
|
+
# Filters the registry catalog by a case-insensitive substring match on an
|
|
8
|
+
# item's name, title, description, or whenToUse. Read-only. With json: true
|
|
9
|
+
# it emits the matching catalog entries; otherwise a "name — title" line each.
|
|
10
|
+
#
|
|
11
|
+
# whenToUse is searched because it is where the disambiguating vocabulary
|
|
12
|
+
# lives ("form", "modal", "overlay"); without it a search for "form" misses
|
|
13
|
+
# most of the form controls.
|
|
14
|
+
class Search
|
|
15
|
+
SEARCHABLE = %w[name title description whenToUse].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(root:, query:, registry: nil, json: false, ui: UI.new)
|
|
18
|
+
@root = root.to_s
|
|
19
|
+
@query = query.to_s
|
|
20
|
+
@registry_override = registry
|
|
21
|
+
@json = json
|
|
22
|
+
@ui = ui
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
client = RegistryClient.new(@registry_override || Config.load(@root).registry)
|
|
27
|
+
matches = filter(client.index.fetch("items", []))
|
|
28
|
+
emit(matches)
|
|
29
|
+
matches
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def filter(items)
|
|
35
|
+
needle = @query.downcase
|
|
36
|
+
items.select do |item|
|
|
37
|
+
SEARCHABLE.any? { |key| item[key].to_s.downcase.include?(needle) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def emit(matches)
|
|
42
|
+
if @json
|
|
43
|
+
@ui.say(JSON.generate(matches))
|
|
44
|
+
elsif matches.empty?
|
|
45
|
+
@ui.say(%(No matches for "#{@query}".))
|
|
46
|
+
else
|
|
47
|
+
matches.each { |item| @ui.say("#{item["name"]} — #{item["title"]}") }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Shadwire
|
|
6
|
+
module Commands
|
|
7
|
+
# Reports the consuming app's Shadwire context: stack detection, the
|
|
8
|
+
# components installed with the helper methods they actually define, and
|
|
9
|
+
# per-component drift.
|
|
10
|
+
#
|
|
11
|
+
# This is the payload the agent skill injects, so it must always succeed. A
|
|
12
|
+
# directory that is not a Rails app, a missing shadwire.json, or an
|
|
13
|
+
# unreachable registry are all reported as data — never raised — because an
|
|
14
|
+
# exception here would leave an agent starting from a backtrace instead of
|
|
15
|
+
# its project context.
|
|
16
|
+
class Status
|
|
17
|
+
def initialize(root:, registry: nil, json: false, ui: UI.new)
|
|
18
|
+
@root = root.to_s
|
|
19
|
+
@registry_override = registry
|
|
20
|
+
@json = json
|
|
21
|
+
@ui = ui
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
project = Project.new(@root)
|
|
26
|
+
config = Config.load(@root)
|
|
27
|
+
result = context(project, config)
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
client = RegistryClient.new(@registry_override || config.registry)
|
|
31
|
+
result["registryVersion"] = client.index["version"]
|
|
32
|
+
result["availableCount"] = Array(client.index["items"]).size
|
|
33
|
+
result["installed"] = installed(config, client)
|
|
34
|
+
rescue Shadwire::Error => e
|
|
35
|
+
result["registryError"] = e.message
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
emit(result)
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def context(project, config)
|
|
45
|
+
{
|
|
46
|
+
"rails" => project.rails?,
|
|
47
|
+
"configPresent" => File.exist?(File.join(@root, Config::CONFIG_FILE)),
|
|
48
|
+
"registry" => @registry_override || config.registry,
|
|
49
|
+
"registryVersion" => nil,
|
|
50
|
+
"availableCount" => nil,
|
|
51
|
+
"stack" => {
|
|
52
|
+
"importmap" => project.importmap?,
|
|
53
|
+
"stimulus" => project.stimulus?,
|
|
54
|
+
"tailwindcssRails" => project.tailwindcss_rails?
|
|
55
|
+
},
|
|
56
|
+
"gems" => {
|
|
57
|
+
"view_component" => project.gem?("view_component"),
|
|
58
|
+
"lucide-rails" => project.gem?("lucide-rails")
|
|
59
|
+
},
|
|
60
|
+
"tailwind" => {
|
|
61
|
+
"css" => config.tailwind_css,
|
|
62
|
+
"importPresent" => tailwind_import?(config)
|
|
63
|
+
},
|
|
64
|
+
"helpers" => {
|
|
65
|
+
"includeAllHelpers" => project.include_all_helpers?,
|
|
66
|
+
"legacyHelperPresent" => project.legacy_helper?
|
|
67
|
+
},
|
|
68
|
+
"installed" => []
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tailwind_import?(config)
|
|
73
|
+
path = File.join(@root, config.tailwind_css)
|
|
74
|
+
File.exist?(path) && File.read(path).include?("shadwire.css")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def installed(config, client)
|
|
78
|
+
config.installed.map do |name, entry|
|
|
79
|
+
item = client.item(name)
|
|
80
|
+
components = Array(item.dig("api", "components"))
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
"name" => name,
|
|
84
|
+
"version" => entry["version"],
|
|
85
|
+
"drift" => drift(item),
|
|
86
|
+
"helpers" => components.filter_map { |component| component["helper"] },
|
|
87
|
+
"classes" => components.filter_map { |component| component["class"] }
|
|
88
|
+
}
|
|
89
|
+
rescue Shadwire::Error
|
|
90
|
+
# The item vanished from the registry; report it rather than failing.
|
|
91
|
+
{ "name" => name, "version" => entry["version"], "drift" => "unknown",
|
|
92
|
+
"helpers" => [], "classes" => [] }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Worst status across the item's files: missing beats modified beats
|
|
97
|
+
# unchanged, so a single number tells an agent whether to look closer.
|
|
98
|
+
def drift(item)
|
|
99
|
+
statuses = Array(item["files"]).map do |file|
|
|
100
|
+
path = File.join(@root, file["target"])
|
|
101
|
+
next "missing" unless File.exist?(path)
|
|
102
|
+
|
|
103
|
+
File.read(path) == file["content"] ? "unchanged" : "modified"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return "missing" if statuses.include?("missing")
|
|
107
|
+
|
|
108
|
+
statuses.include?("modified") ? "modified" : "unchanged"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def emit(result)
|
|
112
|
+
return @ui.say(JSON.generate(result)) if @json
|
|
113
|
+
|
|
114
|
+
human(result)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def human(result)
|
|
118
|
+
@ui.say("Rails app: #{yes_no(result["rails"])} shadwire.json: #{yes_no(result["configPresent"])}")
|
|
119
|
+
@ui.say("Registry: #{result["registry"]} (#{result["registryVersion"] || "unavailable"})")
|
|
120
|
+
@ui.say("Stack: importmap=#{result.dig("stack", "importmap")} " \
|
|
121
|
+
"stimulus=#{result.dig("stack", "stimulus")} " \
|
|
122
|
+
"tailwindcss-rails=#{result.dig("stack", "tailwindcssRails")}")
|
|
123
|
+
@ui.say("Installed (#{result["installed"].size} of #{result["availableCount"] || "?"}):")
|
|
124
|
+
result["installed"].each do |item|
|
|
125
|
+
@ui.say(" #{item["drift"].ljust(9)} #{item["name"]} #{item["helpers"].join(", ")}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@ui.warn("Registry unavailable: #{result["registryError"]}") if result["registryError"]
|
|
129
|
+
return unless result.dig("helpers", "legacyHelperPresent")
|
|
130
|
+
|
|
131
|
+
@ui.warn("Note: app/helpers/ui_helper.rb is left over from an older install; " \
|
|
132
|
+
"helpers now live in app/helpers/ui/ and it can be deleted.")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def yes_no(value)
|
|
136
|
+
value ? "yes" : "no"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Shadwire
|
|
6
|
+
module Commands
|
|
7
|
+
# Re-applies the registry version of installed components. With no names it
|
|
8
|
+
# updates every installed item. It surfaces local drift (via Differ) before
|
|
9
|
+
# writing so edits are never clobbered silently, then reuses add's overwrite
|
|
10
|
+
# policy, dependency handling, and installed bookkeeping. --yes/--overwrite
|
|
11
|
+
# skip the per-file prompt; --no-deps limits the update to the named items.
|
|
12
|
+
class Update
|
|
13
|
+
def initialize(root:, names: [], yes: false, overwrite: false, no_deps: false,
|
|
14
|
+
registry: nil, json: false, ui: UI.new(yes:), runner: Dependencies::DEFAULT_RUNNER)
|
|
15
|
+
@root = root.to_s
|
|
16
|
+
@names = names
|
|
17
|
+
@yes = yes
|
|
18
|
+
@overwrite = overwrite
|
|
19
|
+
@no_deps = no_deps
|
|
20
|
+
@registry_override = registry
|
|
21
|
+
@json = json
|
|
22
|
+
@ui = ui
|
|
23
|
+
@runner = runner
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
project = Project.new(@root)
|
|
28
|
+
project.raise_unless_rails!
|
|
29
|
+
|
|
30
|
+
config = Config.load(@root)
|
|
31
|
+
client = RegistryClient.new(@registry_override || config.registry)
|
|
32
|
+
targets = resolve_targets(config)
|
|
33
|
+
|
|
34
|
+
items = resolve_items(targets, client)
|
|
35
|
+
files = items.flat_map { |item| item.fetch("files") }
|
|
36
|
+
|
|
37
|
+
diffs = compute_diffs(files) # read local drift before any overwrite
|
|
38
|
+
report = install_files(files)
|
|
39
|
+
|
|
40
|
+
deps = Dependencies.new(project, runner: @runner)
|
|
41
|
+
dep_confirm = ->(desc) { @ui.confirm?(desc) }
|
|
42
|
+
gems = deps.ensure_gems(union(items, "gems"), yes: @yes, confirm: dep_confirm)
|
|
43
|
+
pins = deps.ensure_importmap_pins(union(items, "importmap"), yes: @yes, confirm: dep_confirm)
|
|
44
|
+
|
|
45
|
+
record_installed(config, client, targets, items)
|
|
46
|
+
config.save
|
|
47
|
+
|
|
48
|
+
result = { updated: targets, report:, gems:, pins:, diffs: }
|
|
49
|
+
emit(result)
|
|
50
|
+
Dependencies.raise_on_failure!(gems, pins)
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def resolve_targets(config)
|
|
58
|
+
targets = @names.empty? ? config.installed.keys : @names
|
|
59
|
+
targets.each do |name|
|
|
60
|
+
raise Shadwire::Error, "#{name} is not installed" unless config.installed.key?(name)
|
|
61
|
+
end
|
|
62
|
+
targets
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_items(targets, client)
|
|
66
|
+
@no_deps ? targets.map { |name| client.item(name) } : Resolver.expand(targets, client)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compute_diffs(files)
|
|
70
|
+
diffs = {}
|
|
71
|
+
files.each do |file|
|
|
72
|
+
target = file["target"]
|
|
73
|
+
path = File.join(@root, target)
|
|
74
|
+
next unless File.exist?(path)
|
|
75
|
+
|
|
76
|
+
local = File.read(path)
|
|
77
|
+
diffs[target] = Differ.unified(local, file["content"], path: target) unless local == file["content"]
|
|
78
|
+
end
|
|
79
|
+
diffs
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def install_files(files)
|
|
83
|
+
if @yes || @overwrite
|
|
84
|
+
Installer.new(@root, overwrite: :always).install(files)
|
|
85
|
+
else
|
|
86
|
+
Installer.new(@root, overwrite: :prompt, confirm: content_aware_confirm(files)).install(files)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Skips files whose on-disk content already matches the registry; prompts
|
|
91
|
+
# only for locally-modified files.
|
|
92
|
+
def content_aware_confirm(files)
|
|
93
|
+
by_target = files.to_h { |file| [file["target"], file["content"]] }
|
|
94
|
+
lambda do |target|
|
|
95
|
+
current = File.read(File.join(@root, target))
|
|
96
|
+
return false if current == by_target[target]
|
|
97
|
+
|
|
98
|
+
@ui.confirm?("Overwrite modified #{target}?")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def union(items, key)
|
|
103
|
+
items.flat_map { |item| Array(item[key]) }.uniq
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def record_installed(config, client, targets, items)
|
|
107
|
+
by_name = items.to_h { |item| [item["name"], item] }
|
|
108
|
+
version = client.index["version"]
|
|
109
|
+
targets.each do |name|
|
|
110
|
+
item = by_name[name] || client.item(name)
|
|
111
|
+
file_targets = item.fetch("files").map { |file| file["target"] }
|
|
112
|
+
config.record_installed(name, version, file_targets)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def emit(result)
|
|
117
|
+
return @ui.say(JSON.generate(json_payload(result))) if @json
|
|
118
|
+
|
|
119
|
+
human(result)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# `overwritten` is the whole point: update clobbers local edits, and the
|
|
123
|
+
# human path prints the diff. Omitting it from JSON let an agent destroy
|
|
124
|
+
# customizations without ever being told they existed.
|
|
125
|
+
def json_payload(result)
|
|
126
|
+
{
|
|
127
|
+
updated: result[:updated],
|
|
128
|
+
written: result[:report][:written],
|
|
129
|
+
skipped: result[:report][:skipped],
|
|
130
|
+
overwritten: result[:diffs].keys & result[:report][:written],
|
|
131
|
+
diffs: result[:diffs],
|
|
132
|
+
gems: result[:gems][:applied],
|
|
133
|
+
gemsFailed: result[:gems][:failed],
|
|
134
|
+
pins: result[:pins][:applied],
|
|
135
|
+
pinsFailed: result[:pins][:failed]
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def human(result)
|
|
140
|
+
@ui.say("Updated #{result[:updated].join(", ")}.")
|
|
141
|
+
result[:diffs].each_value { |rendered| @ui.say(rendered) }
|
|
142
|
+
result[:report][:written].each { |target| @ui.say(" write #{target}") }
|
|
143
|
+
result[:report][:skipped].each { |target| @ui.say(" skip #{target}") }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Shadwire
|
|
7
|
+
# Reads and writes a consuming Rails app's shadwire.json.
|
|
8
|
+
#
|
|
9
|
+
# On-disk JSON shape (mirrors shadcn's components.json conventions):
|
|
10
|
+
#
|
|
11
|
+
# {
|
|
12
|
+
# "registry": "https://edumoraes.github.io/shadwire/r",
|
|
13
|
+
# "tailwind": { "css": "app/assets/tailwind/application.css" },
|
|
14
|
+
# "aliases": {
|
|
15
|
+
# "components": "app/components",
|
|
16
|
+
# "ui": "app/components/ui",
|
|
17
|
+
# "helpers": "app/helpers",
|
|
18
|
+
# "controllers": "app/javascript/controllers",
|
|
19
|
+
# "vendorCss": "vendor/shadwire"
|
|
20
|
+
# },
|
|
21
|
+
# "installed": {
|
|
22
|
+
# "button": { "version": "1.0.0", "files": ["app/components/ui/button_component.rb"] }
|
|
23
|
+
# }
|
|
24
|
+
# }
|
|
25
|
+
class Config
|
|
26
|
+
CONFIG_FILE = "shadwire.json"
|
|
27
|
+
|
|
28
|
+
DEFAULTS = {
|
|
29
|
+
"registry" => "https://edumoraes.github.io/shadwire/r",
|
|
30
|
+
"tailwind" => { "css" => "app/assets/tailwind/application.css" },
|
|
31
|
+
"aliases" => {
|
|
32
|
+
"components" => "app/components",
|
|
33
|
+
"ui" => "app/components/ui",
|
|
34
|
+
"helpers" => "app/helpers",
|
|
35
|
+
"controllers" => "app/javascript/controllers",
|
|
36
|
+
"vendorCss" => "vendor/shadwire"
|
|
37
|
+
},
|
|
38
|
+
"installed" => {}
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
attr_reader :root, :tailwind_css, :aliases, :installed
|
|
42
|
+
attr_accessor :registry
|
|
43
|
+
|
|
44
|
+
# Loads shadwire.json from the given app root, or returns a Config with
|
|
45
|
+
# defaults when the file does not exist.
|
|
46
|
+
def self.load(root)
|
|
47
|
+
path = File.join(root, CONFIG_FILE)
|
|
48
|
+
data = File.exist?(path) ? parse(path) : JSON.parse(JSON.generate(DEFAULTS))
|
|
49
|
+
new(root, data)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A hand-edited shadwire.json is common enough that a raw JSON::ParserError
|
|
53
|
+
# backtrace is the wrong answer; name the file and the problem.
|
|
54
|
+
def self.parse(path)
|
|
55
|
+
JSON.parse(File.read(path))
|
|
56
|
+
rescue JSON::ParserError => e
|
|
57
|
+
raise ConfigError, "#{path} is not valid JSON: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Writes pretty JSON to <root>/shadwire.json.
|
|
61
|
+
def save
|
|
62
|
+
FileUtils.mkdir_p(@root)
|
|
63
|
+
File.write(File.join(@root, CONFIG_FILE), JSON.pretty_generate(to_h) + "\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Records an installed component: installed[name] = { "version" => ..., "files" => [...] }.
|
|
67
|
+
def record_installed(name, version, files)
|
|
68
|
+
@installed[name] = { "version" => version, "files" => files }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Removes an entry from installed.
|
|
72
|
+
def forget(name)
|
|
73
|
+
@installed.delete(name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def initialize(root, data)
|
|
79
|
+
@root = root
|
|
80
|
+
@registry = data.fetch("registry", DEFAULTS["registry"])
|
|
81
|
+
@tailwind_css = data.dig("tailwind", "css") || DEFAULTS.dig("tailwind", "css")
|
|
82
|
+
@aliases = data.fetch("aliases", JSON.parse(JSON.generate(DEFAULTS["aliases"])))
|
|
83
|
+
@installed = data.fetch("installed", {})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
{
|
|
88
|
+
"registry" => @registry,
|
|
89
|
+
"tailwind" => { "css" => @tailwind_css },
|
|
90
|
+
"aliases" => @aliases,
|
|
91
|
+
"installed" => @installed
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|