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
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
data/lib/shadwire/cli.rb
ADDED
|
@@ -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
|