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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1cdc0f4830063d6e21c79acbe20a20d3b81862ff346a3af2fff6888af18ba8c2
4
+ data.tar.gz: a455411bae400e6e0a99f4ad693988add8b95945abf15081321631f5922e3124
5
+ SHA512:
6
+ metadata.gz: 4ae1610e1fc2382eba1787a94ba27be6efaed66ed8535bbd72a64a09b82aa79df01271ad9186330e9fbefebe3487f84f447f662472bab4292de552d43e950b76
7
+ data.tar.gz: a10682b3ba15a5e955b0952967b5af2695d040b5f71d08f4dad3d9f9c70bb8f43b146a783081fe1e843646011a66ef8edf35821eab39ccb3a28ca270fb2fa01d
data/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # shadwire
2
+
3
+ shadcn/ui components for Ruby on Rails, delivered the shadcn **Open Code** way:
4
+ the CLI copies component *source* into your app and records it in `shadwire.json`.
5
+ Installed files are yours to edit — there is no runtime dependency on Shadwire.
6
+
7
+ Part of the [Shadwire](https://github.com/edumoraes/shadwire) monorepo. The
8
+ component source lives in that repo's `registry/` and is published to a static
9
+ registry the CLI installs from over HTTP.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ bundle add shadwire --group development # then run: bundle exec shadwire
15
+ gem install shadwire # or globally, then run: shadwire
16
+ ```
17
+
18
+ Requires Ruby >= 3.2 and a Rails app (>= 7.1) using ViewComponent and Tailwind CSS.
19
+
20
+ ## Commands
21
+
22
+ Run `shadwire help COMMAND` for the built-in usage of any command.
23
+
24
+ | Command | What it does | Flags (besides `--cwd`) |
25
+ | --- | --- | --- |
26
+ | `init` | Bootstrap Shadwire: write `shadwire.json`, install the shared base files (`ui_component.rb`, `shadwire.css`) and base gems, add the Tailwind `@import`. | `--yes`, `--registry URL`, `--force`, `--json` |
27
+ | `add NAME...` | Install one or more components and their registry dependencies; apply their gems and importmap pins; record them in `shadwire.json`. | `--yes`, `--overwrite`, `--no-deps`, `--registry URL`, `--json` |
28
+ | `list` | List every component in the registry catalog. | `--registry URL`, `--json` |
29
+ | `search QUERY` | Search the catalog by name, title, or description. | `--registry URL`, `--json` |
30
+ | `info NAME` | Show a component's metadata: files, gems, importmap pins, registry dependencies. | `--registry URL`, `--json` |
31
+ | `diff [NAME...]` | Show how installed files have drifted from the registry (`unchanged` / `modified` / `missing`). | `--registry URL`, `--json`, `--exit-code` |
32
+ | `update [NAME...]` | Re-apply the registry version of installed components (all, or the named ones). | `--yes`, `--overwrite`, `--no-deps`, `--registry URL`, `--json` |
33
+ | `remove NAME...` | Uninstall components, deleting only their own files (never the shared base, never files still used by another component). | `--yes`, `--registry URL`, `--json` |
34
+ | `status` | Report app context: stack detection, installed components with the helpers they define, and drift. | `--registry URL`, `--json` |
35
+ | `version` | Print the installed CLI version. | — |
36
+
37
+ ### `status` and coding agents
38
+
39
+ `status --json` is the one call that describes the whole install, which is why
40
+ the Shadwire agent skill injects it:
41
+
42
+ ```json
43
+ {
44
+ "rails": true, "configPresent": true, "registryVersion": "0.2.0",
45
+ "stack": { "importmap": true, "stimulus": true, "tailwindcssRails": true },
46
+ "helpers": { "includeAllHelpers": true, "legacyHelperPresent": false },
47
+ "installed": [
48
+ { "name": "card", "drift": "unchanged",
49
+ "helpers": ["ui_card", "ui_card_header", "ui_card_title"],
50
+ "classes": ["Ui::CardComponent", "Ui::Card::HeaderComponent"] }
51
+ ],
52
+ "availableCount": 58
53
+ }
54
+ ```
55
+
56
+ `installed[].helpers` lists the `ui_*` methods that actually exist in the app, so
57
+ there is no guessing about which helpers are callable.
58
+
59
+ `status` never raises: a directory that is not a Rails app, a missing
60
+ `shadwire.json`, and an unreachable registry are all reported as fields
61
+ (`"rails": false`, `"registryError": "..."`) with exit code 0.
62
+
63
+ ### Flags
64
+
65
+ - `--cwd DIR` — run against another app directory (default: the current directory). Available on every command.
66
+ - `--yes`, `-y` — apply file and dependency changes without prompting (the agent / CI path).
67
+ - `--overwrite` — overwrite locally-modified files without prompting (`add` / `update`).
68
+ - `--no-deps` — skip transitive registry dependencies (`add` / `update`; deps are on by default).
69
+ - `--registry URL` — install or read from this registry instead of the configured one.
70
+ - `--json` — emit machine-readable JSON instead of human output.
71
+ - `--force` — overwrite an existing `shadwire.json` (`init`).
72
+ - `--exit-code` — make `diff` exit non-zero when any drift is found (for CI).
73
+
74
+ ## `shadwire.json`
75
+
76
+ `init` writes `shadwire.json` at the app root. It records the registry, where
77
+ files install (aliases), the Tailwind entrypoint, and what is installed:
78
+
79
+ ```json
80
+ {
81
+ "registry": "https://edumoraes.github.io/shadwire/r",
82
+ "tailwind": { "css": "app/assets/tailwind/application.css" },
83
+ "aliases": {
84
+ "components": "app/components",
85
+ "ui": "app/components/ui",
86
+ "helpers": "app/helpers",
87
+ "controllers": "app/javascript/controllers",
88
+ "vendorCss": "vendor/shadwire"
89
+ },
90
+ "installed": {
91
+ "button": { "version": "1.0.0", "files": ["app/components/ui/button_component.rb"] }
92
+ }
93
+ }
94
+ ```
95
+
96
+ - `registry` — base URL the CLI installs from (persisted from `init --registry`).
97
+ - `tailwind.css` — the app's Tailwind entrypoint that receives the `@import`.
98
+ - `aliases` — install targets for each kind of file.
99
+ - `installed` — per-component `version` plus the files it owns (used by `diff`, `update`, and `remove`).
100
+
101
+ ## Registry resolution
102
+
103
+ For any command, the registry base URL is resolved in this order:
104
+
105
+ 1. the `--registry` flag, if given;
106
+ 2. the `registry` field in the app's `shadwire.json`;
107
+ 3. the built-in default, `https://edumoraes.github.io/shadwire/r`.
108
+
109
+ Both `https://` and local `file://` bases are supported. Point `--registry` at a
110
+ `file://` path to install from a registry built locally with `bin/build_registry`
111
+ (for example `file://$PWD/build/r`). The CLI reads `index.json` (the catalog and
112
+ shared base) and `<name>.json` (a component with its files inlined) from that base.
113
+
114
+ ## License
115
+
116
+ MIT.
data/exe/shadwire ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "shadwire"
5
+
6
+ Shadwire::CLI.start(ARGV)
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Shadwire
6
+ class CLI < Thor
7
+ package_name "shadwire"
8
+
9
+ def self.exit_on_failure? = true
10
+
11
+ class_option :cwd, type: :string,
12
+ desc: "Run against this app directory (default: current directory)"
13
+
14
+ desc "version", "Print the shadwire version"
15
+ def version
16
+ puts Shadwire::VERSION
17
+ end
18
+
19
+ desc "init", "Bootstrap shadwire in a Rails app (config, base files, base deps)"
20
+ method_option :yes, type: :boolean, default: false, aliases: "-y",
21
+ desc: "Apply dependency changes without prompting"
22
+ method_option :registry, type: :string, desc: "Registry base URL to install from"
23
+ method_option :force, type: :boolean, default: false,
24
+ desc: "Overwrite an existing shadwire.json"
25
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
26
+ def init
27
+ run_command do |root, ui|
28
+ Commands::Init.new(
29
+ root:, registry: options[:registry], yes: options[:yes],
30
+ force: options[:force], json: options[:json], ui:
31
+ ).call
32
+ end
33
+ end
34
+
35
+ desc "add NAME...", "Install components and their registry dependencies"
36
+ method_option :yes, type: :boolean, default: false, aliases: "-y",
37
+ desc: "Apply file/dependency changes without prompting"
38
+ method_option :overwrite, type: :boolean, default: false,
39
+ desc: "Overwrite locally-modified files without prompting"
40
+ method_option :deps, type: :boolean, default: true,
41
+ desc: "Install transitive registry dependencies (--no-deps to skip)"
42
+ method_option :registry, type: :string, desc: "Registry base URL to install from"
43
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
44
+ def add(*names)
45
+ run_command do |root, ui|
46
+ raise Shadwire::Error, "add requires at least one component name" if names.empty?
47
+
48
+ Commands::Add.new(
49
+ root:, names:, yes: options[:yes], overwrite: options[:overwrite],
50
+ no_deps: !options[:deps], registry: options[:registry], json: options[:json], ui:
51
+ ).call
52
+ end
53
+ end
54
+
55
+ desc "list", "List every component in the registry catalog"
56
+ method_option :registry, type: :string, desc: "Registry base URL to read from"
57
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
58
+ def list
59
+ run_command do |root, ui|
60
+ Commands::List.new(root:, registry: options[:registry], json: options[:json], ui:).call
61
+ end
62
+ end
63
+
64
+ desc "search QUERY", "Search the catalog by name, title, or description"
65
+ method_option :registry, type: :string, desc: "Registry base URL to read from"
66
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
67
+ def search(query = nil)
68
+ run_command do |root, ui|
69
+ raise Shadwire::Error, "search requires a query" if query.nil? || query.strip.empty?
70
+
71
+ Commands::Search.new(root:, query:, registry: options[:registry], json: options[:json], ui:).call
72
+ end
73
+ end
74
+
75
+ desc "info NAME", "Show a component's metadata (files, gems, pins, dependencies)"
76
+ method_option :registry, type: :string, desc: "Registry base URL to read from"
77
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
78
+ def info(name)
79
+ run_command do |root, ui|
80
+ Commands::Info.new(root:, name:, registry: options[:registry], json: options[:json], ui:).call
81
+ end
82
+ end
83
+
84
+ desc "diff [NAME...]", "Show how installed files have drifted from the registry"
85
+ method_option :registry, type: :string, desc: "Registry base URL to compare against"
86
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
87
+ method_option :exit_code, type: :boolean, default: false,
88
+ desc: "Exit non-zero when drift is found (for CI)"
89
+ def diff(*names)
90
+ run_command do |root, ui|
91
+ result = Commands::Diff.new(
92
+ root:, names:, registry: options[:registry], json: options[:json], ui:
93
+ ).call
94
+ exit 1 if options[:exit_code] && result[:drifted]
95
+ end
96
+ end
97
+
98
+ desc "update [NAME...]", "Re-apply the registry version of installed components"
99
+ method_option :yes, type: :boolean, default: false, aliases: "-y",
100
+ desc: "Overwrite locally-modified files without prompting"
101
+ method_option :overwrite, type: :boolean, default: false,
102
+ desc: "Overwrite locally-modified files without prompting"
103
+ method_option :deps, type: :boolean, default: true,
104
+ desc: "Also update transitive registry dependencies (--no-deps to skip)"
105
+ method_option :registry, type: :string, desc: "Registry base URL to update from"
106
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
107
+ def update(*names)
108
+ run_command do |root, ui|
109
+ Commands::Update.new(
110
+ root:, names:, yes: options[:yes], overwrite: options[:overwrite],
111
+ no_deps: !options[:deps], registry: options[:registry], json: options[:json], ui:
112
+ ).call
113
+ end
114
+ end
115
+
116
+ desc "remove NAME...", "Uninstall components, deleting only their own files"
117
+ method_option :yes, type: :boolean, default: false, aliases: "-y",
118
+ desc: "Delete without prompting"
119
+ method_option :registry, type: :string, desc: "Registry base URL to reconcile against"
120
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
121
+ def remove(*names)
122
+ run_command do |root, ui|
123
+ raise Shadwire::Error, "remove requires at least one component name" if names.empty?
124
+
125
+ Commands::Remove.new(
126
+ root:, names:, yes: options[:yes], registry: options[:registry], json: options[:json], ui:
127
+ ).call
128
+ end
129
+ end
130
+
131
+ desc "status", "Report app context: stack, installed components, helpers, drift"
132
+ method_option :registry, type: :string, desc: "Registry base URL to reconcile against"
133
+ method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
134
+ def status
135
+ run_command do |root, ui|
136
+ Commands::Status.new(root:, registry: options[:registry], json: options[:json], ui:).call
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def resolve_root
143
+ File.expand_path(options[:cwd] || Dir.pwd)
144
+ end
145
+
146
+ def run_command
147
+ ui = UI.new(yes: options[:yes])
148
+ yield(resolve_root, ui)
149
+ rescue Shadwire::Error => e
150
+ ui.error(e.message)
151
+ exit 1
152
+ rescue Errno::EACCES, Errno::ENOENT, Errno::EROFS => e
153
+ # Filesystem problems in the consuming app: a read-only checkout, a
154
+ # deleted directory. Actionable as a sentence, not as a backtrace.
155
+ ui.error("Filesystem error: #{e.message}")
156
+ exit 1
157
+ rescue Interrupt
158
+ ui.error("Interrupted.")
159
+ exit 130
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadwire
4
+ module Commands
5
+ # Installs one or more components and their transitive registry
6
+ # dependencies: writes files (overwrite policy from flags, content-aware
7
+ # prompting), applies the union of gems + importmap pins, and records each
8
+ # named component in shadwire.json.
9
+ class Add
10
+ def initialize(root:, names:, yes: false, overwrite: false, no_deps: false,
11
+ registry: nil, json: false, ui: UI.new(yes:), runner: Dependencies::DEFAULT_RUNNER)
12
+ @root = root.to_s
13
+ @names = names
14
+ @yes = yes
15
+ @overwrite = overwrite
16
+ @no_deps = no_deps
17
+ @registry_override = registry
18
+ @json = json
19
+ @ui = ui
20
+ @runner = runner
21
+ end
22
+
23
+ def call
24
+ project = Project.new(@root)
25
+ project.raise_unless_rails!
26
+
27
+ config = Config.load(@root)
28
+ client = RegistryClient.new(@registry_override || config.registry)
29
+
30
+ items = resolve_items(client)
31
+ files = items.flat_map { |item| item.fetch("files") }
32
+ report = install_files(files)
33
+
34
+ deps = Dependencies.new(project, runner: @runner)
35
+ dep_confirm = ->(desc) { @ui.confirm?(desc) }
36
+ gems = deps.ensure_gems(union(items, "gems"), yes: @yes, confirm: dep_confirm)
37
+ pins = deps.ensure_importmap_pins(union(items, "importmap"), yes: @yes, confirm: dep_confirm)
38
+
39
+ record_installed(config, client, items)
40
+ config.save
41
+
42
+ result = { report:, gems:, pins: }
43
+ emit(result)
44
+ Dependencies.raise_on_failure!(gems, pins)
45
+
46
+ result
47
+ end
48
+
49
+ private
50
+
51
+ def resolve_items(client)
52
+ @no_deps ? @names.map { |name| client.item(name) } : Resolver.expand(@names, client)
53
+ end
54
+
55
+ def union(items, key)
56
+ items.flat_map { |item| Array(item[key]) }.uniq
57
+ end
58
+
59
+ def install_files(files)
60
+ if @yes || @overwrite
61
+ Installer.new(@root, overwrite: :always).install(files)
62
+ else
63
+ Installer.new(@root, overwrite: :prompt, confirm: content_aware_confirm(files)).install(files)
64
+ end
65
+ end
66
+
67
+ # Skips files whose on-disk content already matches the registry; prompts
68
+ # only for locally-modified files.
69
+ def content_aware_confirm(files)
70
+ by_target = files.to_h { |file| [file["target"], file["content"]] }
71
+ lambda do |target|
72
+ current = File.read(File.join(@root, target))
73
+ return false if current == by_target[target]
74
+
75
+ @ui.confirm?("Overwrite modified #{target}?")
76
+ end
77
+ end
78
+
79
+ def record_installed(config, client, items)
80
+ by_name = items.to_h { |item| [item["name"], item] }
81
+ version = client.index["version"]
82
+ @names.each do |name|
83
+ item = by_name[name] || client.item(name)
84
+ targets = item.fetch("files").map { |file| file["target"] }
85
+ config.record_installed(name, version, targets)
86
+ end
87
+ end
88
+
89
+ def emit(result)
90
+ return @ui.say(JSON.generate(json_payload(result))) if @json
91
+
92
+ print_summary(result[:report], result[:gems], result[:pins])
93
+ end
94
+
95
+ def json_payload(result)
96
+ {
97
+ "added" => @names,
98
+ "written" => result[:report][:written],
99
+ "skipped" => result[:report][:skipped],
100
+ "gems" => slice_deps(result[:gems]),
101
+ "pins" => slice_deps(result[:pins])
102
+ }
103
+ end
104
+
105
+ def slice_deps(deps)
106
+ { "applied" => deps[:applied], "pending" => deps[:pending],
107
+ "failed" => deps[:failed], "manual" => deps[:manual] }
108
+ end
109
+
110
+ def print_summary(report, gems, pins)
111
+ @ui.say("Added #{@names.join(", ")}.")
112
+ report[:written].each { |t| @ui.say(" write #{t}") }
113
+ report[:skipped].each { |t| @ui.say(" skip #{t}") }
114
+ gems[:applied].each { |g| @ui.say(" gem #{g}") }
115
+ gems[:pending].each { |g| @ui.say(" pending #{g} (not installed — run: bundle add #{g})") }
116
+ gems[:failed].each { |g| @ui.say(" FAILED #{g} (bundle add failed)") }
117
+ pins[:applied].each { |p| @ui.say(" pin #{p}") }
118
+ pins[:pending].each { |p| @ui.say(" pending pin #{p}") }
119
+ (gems[:manual] + pins[:manual]).each { |m| @ui.say(" manual #{m}") }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Reports how installed files have drifted from the registry: for each file
8
+ # of each requested (or, with no names, every installed) item, compares the
9
+ # local bytes to the freshly fetched registry content. Status per file is
10
+ # "unchanged", "modified", or "missing". Read-only — never writes.
11
+ class Diff
12
+ def initialize(root:, names: [], registry: nil, json: false, ui: UI.new)
13
+ @root = root.to_s
14
+ @names = names
15
+ @registry_override = registry
16
+ @json = json
17
+ @ui = ui
18
+ end
19
+
20
+ def call
21
+ config = Config.load(@root)
22
+ client = RegistryClient.new(@registry_override || config.registry)
23
+
24
+ entries = []
25
+ diffs = {}
26
+ resolve_names(config).each do |name|
27
+ client.item(name).fetch("files").each do |file|
28
+ target = file["target"]
29
+ status, rendered = compare(target, file["content"])
30
+ entries << { name:, file: target, status: }
31
+ diffs["#{name}:#{target}"] = rendered if status == "modified"
32
+ end
33
+ end
34
+
35
+ drifted = entries.any? { |entry| entry[:status] != "unchanged" }
36
+ emit(entries, diffs)
37
+ { entries:, diffs:, drifted: }
38
+ end
39
+
40
+ private
41
+
42
+ def resolve_names(config)
43
+ names = @names.empty? ? config.installed.keys : @names
44
+ names.each do |name|
45
+ raise Shadwire::Error, "#{name} is not installed" unless config.installed.key?(name)
46
+ end
47
+ names
48
+ end
49
+
50
+ def compare(target, registry_content)
51
+ path = File.join(@root, target)
52
+ return ["missing", nil] unless File.exist?(path)
53
+
54
+ local = File.read(path)
55
+ return ["unchanged", nil] if local == registry_content
56
+
57
+ ["modified", Differ.unified(local, registry_content, path: target)]
58
+ end
59
+
60
+ def emit(entries, diffs)
61
+ return @ui.say(JSON.generate(entries)) if @json
62
+
63
+ entries.each { |entry| @ui.say("#{entry[:status].ljust(9)} #{entry[:name]} #{entry[:file]}") }
64
+ diffs.each_value { |rendered| @ui.say(rendered) if rendered && !rendered.empty? }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Shows one item's metadata: type, title, description, file targets, gems,
8
+ # importmap pins, and registry dependencies. Read-only. The file bodies
9
+ # (`content`) are stripped so both the JSON and human output stay a manifest,
10
+ # not a payload. An unknown name propagates RegistryError from the client.
11
+ class Info
12
+ def initialize(root:, name:, registry: nil, json: false, ui: UI.new)
13
+ @root = root.to_s
14
+ @name = name
15
+ @registry_override = registry
16
+ @json = json
17
+ @ui = ui
18
+ end
19
+
20
+ def call
21
+ client = RegistryClient.new(@registry_override || Config.load(@root).registry)
22
+ payload = strip_content(client.item(@name))
23
+ emit(payload)
24
+ payload
25
+ end
26
+
27
+ private
28
+
29
+ def strip_content(item)
30
+ payload = item.dup
31
+ payload["files"] = Array(item["files"]).map { |file| file.reject { |key, _| key == "content" } }
32
+ payload
33
+ end
34
+
35
+ def emit(payload)
36
+ return @ui.say(JSON.generate(payload)) if @json
37
+
38
+ human(payload)
39
+ end
40
+
41
+ def human(payload)
42
+ @ui.say("#{payload["name"]} (#{payload["type"]}) — #{payload["title"]}")
43
+ @ui.say(payload["description"]) if payload["description"]
44
+ @ui.say("When to use: #{payload["whenToUse"]}") if payload["whenToUse"]
45
+
46
+ components(payload)
47
+
48
+ @ui.say("Files:")
49
+ payload["files"].each { |file| @ui.say(" #{file["target"]}") }
50
+
51
+ section("Gems", payload["gems"])
52
+ section("Registry dependencies", payload["registryDependencies"])
53
+ @ui.say("Requires Stimulus: yes (needs importmap with eager loading)") if payload["requiresStimulus"]
54
+
55
+ pins = Array(payload["importmap"])
56
+ unless pins.empty?
57
+ @ui.say("Importmap:")
58
+ pins.each { |pin| @ui.say(" #{pin["name"]} → #{pin["to"]}") }
59
+ end
60
+
61
+ usage(payload)
62
+ end
63
+
64
+ # The generated API: what to call and what it accepts. Roots first, so the
65
+ # entry point is obvious before its composition parts.
66
+ def components(payload)
67
+ entries = Array(payload.dig("api", "components")).reject { |entry| entry["helper"].nil? }
68
+ return if entries.empty?
69
+
70
+ @ui.say("Components:")
71
+ entries.sort_by { |entry| entry["root"] ? 0 : 1 }.each do |entry|
72
+ @ui.say(" #{entry["class"]} — #{entry["helper"]}")
73
+ @ui.say(" variants: #{entry["variants"].join(" | ")}") if entry["variants"]&.any?
74
+ @ui.say(" sizes: #{entry["sizes"].join(" | ")}") if entry["sizes"]&.any?
75
+ props = Array(entry["props"]).map { |prop| prop["default"] ? "#{prop["name"]}: #{prop["default"]}" : "#{prop["name"]}:" }
76
+ @ui.say(" props: #{props.join(", ")}") unless props.empty?
77
+ end
78
+ end
79
+
80
+ def usage(payload)
81
+ snippets = Array(payload["usage"])
82
+ return if snippets.empty?
83
+
84
+ @ui.say("Usage:")
85
+ snippets.each { |snippet| @ui.say(snippet.lines.map { |line| " #{line}" }.join.chomp) }
86
+ end
87
+
88
+ def section(label, values)
89
+ values = Array(values)
90
+ @ui.say("#{label}: #{values.join(", ")}") unless values.empty?
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Shadwire
6
+ module Commands
7
+ # Bootstraps a consuming Rails app: writes shadwire.json, installs the base
8
+ # files, and applies the base gems + the Tailwind @import (confirm-then-apply,
9
+ # auto on yes:). Idempotent on re-run; --force resets shadwire.json.
10
+ class Init
11
+ def initialize(root:, registry: nil, yes: false, force: false, json: false,
12
+ ui: UI.new(yes:), runner: Dependencies::DEFAULT_RUNNER)
13
+ @root = root.to_s
14
+ @registry_override = registry
15
+ @yes = yes
16
+ @force = force
17
+ @json = json
18
+ @ui = ui
19
+ @runner = runner
20
+ end
21
+
22
+ def call
23
+ project = Project.new(@root)
24
+ project.raise_unless_rails!
25
+
26
+ config = build_config
27
+ client = RegistryClient.new(config.registry)
28
+ base = client.index.fetch("base")
29
+
30
+ report = Installer.new(@root, overwrite: :always).install(base.fetch("files"))
31
+
32
+ deps = Dependencies.new(project, runner: @runner)
33
+ confirm = ->(desc) { @ui.confirm?(desc) }
34
+ gems = deps.ensure_gems(Array(base["gems"]), yes: @yes, confirm:)
35
+ tailwind = deps.ensure_tailwind_import(
36
+ vendor_css_target(config, base), css_entry: config.tailwind_css, yes: @yes, confirm:
37
+ )
38
+
39
+ config.save
40
+ warn_missing_stack(project)
41
+ result = { config:, report:, gems:, tailwind: }
42
+ emit(result)
43
+ Dependencies.raise_on_failure!(gems)
44
+
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ def build_config
51
+ path = File.join(@root, Config::CONFIG_FILE)
52
+ File.delete(path) if @force && File.exist?(path)
53
+ config = Config.load(@root)
54
+ config.registry = @registry_override if @registry_override
55
+ config
56
+ end
57
+
58
+ def vendor_css_target(config, base)
59
+ File.join(config.aliases.fetch("vendorCss", "vendor/shadwire"), base.dig("tailwind", "css"))
60
+ end
61
+
62
+ def warn_missing_stack(project)
63
+ unless project.importmap?
64
+ @ui.warn("Warning: importmap-rails not detected — Stimulus controllers won't auto-register.")
65
+ end
66
+ unless project.stimulus?
67
+ @ui.warn("Warning: stimulus-rails not detected — interactive components need Stimulus.")
68
+ end
69
+ unless project.tailwindcss_rails?
70
+ @ui.warn("Warning: tailwindcss-rails not detected — add the Shadwire @import to your CSS manually.")
71
+ end
72
+ end
73
+
74
+ def emit(result)
75
+ return @ui.say(JSON.generate(json_payload(result))) if @json
76
+
77
+ print_summary(result[:report], result[:gems], result[:tailwind])
78
+ end
79
+
80
+ def json_payload(result)
81
+ {
82
+ "created" => result[:report][:written],
83
+ "gems" => { "applied" => result[:gems][:applied], "pending" => result[:gems][:pending],
84
+ "failed" => result[:gems][:failed] },
85
+ "tailwind" => { "applied" => result[:tailwind][:applied], "manual" => result[:tailwind][:manual] },
86
+ "registry" => result[:config].registry
87
+ }
88
+ end
89
+
90
+ def print_summary(report, gems, tailwind)
91
+ @ui.say("Initialized shadwire (#{report[:written].size} base files).")
92
+ report[:written].each { |t| @ui.say(" create #{t}") }
93
+ gems[:applied].each { |g| @ui.say(" gem #{g}") }
94
+ gems[:pending].each { |g| @ui.say(" pending #{g} (not installed — run: bundle add #{g})") }
95
+ gems[:failed].each { |g| @ui.say(" FAILED #{g} (bundle add failed)") }
96
+ (tailwind[:applied]).each { |i| @ui.say(" tailwind #{i}") }
97
+ tailwind[:manual].each { |m| @ui.say(" manual #{m}") }
98
+ end
99
+ end
100
+ end
101
+ end