shadwire 0.2.0 → 0.3.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 +4 -4
- data/README.md +17 -6
- data/lib/shadwire/binstub.rb +55 -0
- data/lib/shadwire/cli.rb +1 -1
- data/lib/shadwire/commands/info.rb +6 -1
- data/lib/shadwire/commands/init.rb +63 -8
- data/lib/shadwire/commands/status.rb +20 -1
- data/lib/shadwire/config.rb +2 -2
- data/lib/shadwire/dependencies.rb +30 -11
- data/lib/shadwire/project.rb +6 -0
- data/lib/shadwire/registry_client.rb +52 -4
- data/lib/shadwire/version.rb +1 -1
- data/lib/shadwire.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6294368abb24616f95b0d9a751bb554ba5135904927f03e5d880c3b969448c2a
|
|
4
|
+
data.tar.gz: ebcf2c60b07a68b96d2c97d07759b05b44432bf634809e1e174958adf3ea51f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3db758ffe31655123b1460ec872f3ff001b68fa7c0f31e690eff757b1dd8a747af55fc50916ea9a855cfa0585a97c1077bab51930f07b68cb7a9887e85e5892
|
|
7
|
+
data.tar.gz: 91475dc299cd4f7a434684386696bb200c8dd8eb95a57f1d69b0696cfc3d3da045311d46b855e04b0d48fb52dc2f54633d86ea57e743345dd4676112d395c441
|
data/README.md
CHANGED
|
@@ -11,19 +11,29 @@ registry the CLI installs from over HTTP.
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
gem install shadwire # global — then: shadwire init
|
|
15
|
+
bundle add shadwire --group development # in the app — then: bundle exec shadwire init
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
`init` adds `shadwire` to the app's `development` group (if it is not there
|
|
19
|
+
already) and writes the `bin/shadwire` binstub. That binstub is the canonical
|
|
20
|
+
entry point: it resolves through the app's bundle, so everyone who installs the
|
|
21
|
+
development group runs the same CLI version. `init` is the one command you run
|
|
22
|
+
un-prefixed, because it is what creates the binstub.
|
|
23
|
+
|
|
24
|
+
A bundle installed with `--without development` (common for deploy, and for some
|
|
25
|
+
CI jobs) has no `shadwire`, so `bin/shadwire` will not run there. Install the
|
|
26
|
+
development group in any job that runs the CLI — the drift check below, for one.
|
|
27
|
+
|
|
18
28
|
Requires Ruby >= 3.2 and a Rails app (>= 7.1) using ViewComponent and Tailwind CSS.
|
|
19
29
|
|
|
20
30
|
## Commands
|
|
21
31
|
|
|
22
|
-
Run `shadwire help COMMAND` for the built-in usage of any command.
|
|
32
|
+
Run `bin/shadwire help COMMAND` for the built-in usage of any command.
|
|
23
33
|
|
|
24
34
|
| Command | What it does | Flags (besides `--cwd`) |
|
|
25
35
|
| --- | --- | --- |
|
|
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` |
|
|
36
|
+
| `init` | Bootstrap Shadwire: write `shadwire.json`, install the shared base files (`ui_component.rb`, `shadwire.css`) and base gems, add `shadwire` to the `development` group, write the `bin/shadwire` binstub, add the Tailwind `@import`. | `--yes`, `--registry URL`, `--force`, `--json` |
|
|
27
37
|
| `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
38
|
| `list` | List every component in the registry catalog. | `--registry URL`, `--json` |
|
|
29
39
|
| `search QUERY` | Search the catalog by name, title, or description. | `--registry URL`, `--json` |
|
|
@@ -43,6 +53,7 @@ the Shadwire agent skill injects it:
|
|
|
43
53
|
{
|
|
44
54
|
"rails": true, "configPresent": true, "registryVersion": "0.2.0",
|
|
45
55
|
"stack": { "importmap": true, "stimulus": true, "tailwindcssRails": true },
|
|
56
|
+
"cli": { "gem": true, "binstub": true },
|
|
46
57
|
"helpers": { "includeAllHelpers": true, "legacyHelperPresent": false },
|
|
47
58
|
"installed": [
|
|
48
59
|
{ "name": "card", "drift": "unchanged",
|
|
@@ -78,7 +89,7 @@ files install (aliases), the Tailwind entrypoint, and what is installed:
|
|
|
78
89
|
|
|
79
90
|
```json
|
|
80
91
|
{
|
|
81
|
-
"registry": "https://edumoraes.
|
|
92
|
+
"registry": "https://shadwire.edumoraes.dev.br/r",
|
|
82
93
|
"tailwind": { "css": "app/assets/tailwind/application.css" },
|
|
83
94
|
"aliases": {
|
|
84
95
|
"components": "app/components",
|
|
@@ -104,7 +115,7 @@ For any command, the registry base URL is resolved in this order:
|
|
|
104
115
|
|
|
105
116
|
1. the `--registry` flag, if given;
|
|
106
117
|
2. the `registry` field in the app's `shadwire.json`;
|
|
107
|
-
3. the built-in default, `https://edumoraes.
|
|
118
|
+
3. the built-in default, `https://shadwire.edumoraes.dev.br/r`.
|
|
108
119
|
|
|
109
120
|
Both `https://` and local `file://` bases are supported. Point `--registry` at a
|
|
110
121
|
`file://` path to install from a registry built locally with `bin/build_registry`
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Shadwire
|
|
7
|
+
# The canonical entry point a consuming app uses to run the CLI: bin/shadwire.
|
|
8
|
+
#
|
|
9
|
+
# The template is the shape Rails 8 generates for bin/rubocop and bin/brakeman,
|
|
10
|
+
# so the file reads as native to the app it lands in. Being a bundler binstub,
|
|
11
|
+
# it only works once `gem "shadwire"` is declared in the app's Gemfile —
|
|
12
|
+
# Commands::Init gates the write on that, because a binstub written without it
|
|
13
|
+
# raises Gem::LoadError on first run.
|
|
14
|
+
#
|
|
15
|
+
# Deliberately not routed through Installer: that writes published registry
|
|
16
|
+
# entries tracked in shadwire.json, and it never sets an execute bit.
|
|
17
|
+
module Binstub
|
|
18
|
+
PATH = "bin/shadwire"
|
|
19
|
+
MODE = 0o755
|
|
20
|
+
|
|
21
|
+
TEMPLATE = <<~RUBY
|
|
22
|
+
#!/usr/bin/env ruby
|
|
23
|
+
# frozen_string_literal: true
|
|
24
|
+
require "rubygems"
|
|
25
|
+
require "bundler/setup"
|
|
26
|
+
load Gem.bin_path("shadwire", "shadwire")
|
|
27
|
+
RUBY
|
|
28
|
+
|
|
29
|
+
# The gem that backs the binstub. `init` adds it, `status` reports it.
|
|
30
|
+
GEM = "shadwire"
|
|
31
|
+
|
|
32
|
+
def self.exist?(root)
|
|
33
|
+
File.exist?(File.join(root.to_s, PATH))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Writes bin/shadwire unless it already exists.
|
|
37
|
+
#
|
|
38
|
+
# An existing binstub is left alone: `bundle binstubs shadwire` produces a
|
|
39
|
+
# different, working file, and it is not tracked in shadwire.json, so
|
|
40
|
+
# `shadwire diff` could never report that we replaced it.
|
|
41
|
+
#
|
|
42
|
+
# @param root [String, Pathname] the consuming app root
|
|
43
|
+
# @param force [Boolean] rewrite an existing binstub
|
|
44
|
+
# @return [Hash] { written: Boolean, skipped: Boolean }
|
|
45
|
+
def self.write(root, force: false)
|
|
46
|
+
path = Pathname(root).join(PATH)
|
|
47
|
+
return { written: false, skipped: true } if path.exist? && !force
|
|
48
|
+
|
|
49
|
+
FileUtils.mkdir_p(path.dirname)
|
|
50
|
+
path.write(TEMPLATE)
|
|
51
|
+
path.chmod(MODE)
|
|
52
|
+
{ written: true, skipped: false }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/shadwire/cli.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Shadwire
|
|
|
21
21
|
desc: "Apply dependency changes without prompting"
|
|
22
22
|
method_option :registry, type: :string, desc: "Registry base URL to install from"
|
|
23
23
|
method_option :force, type: :boolean, default: false,
|
|
24
|
-
desc: "
|
|
24
|
+
desc: "Reset shadwire.json and rewrite bin/shadwire, overwriting both"
|
|
25
25
|
method_option :json, type: :boolean, default: false, desc: "Emit machine-readable JSON"
|
|
26
26
|
def init
|
|
27
27
|
run_command do |root, ui|
|
|
@@ -82,7 +82,12 @@ module Shadwire
|
|
|
82
82
|
return if snippets.empty?
|
|
83
83
|
|
|
84
84
|
@ui.say("Usage:")
|
|
85
|
-
|
|
85
|
+
# Blank line between snippets: items that document several recipes
|
|
86
|
+
# (date-picker) would otherwise print as one wall of ERB.
|
|
87
|
+
snippets.each_with_index do |snippet, index|
|
|
88
|
+
@ui.say("") unless index.zero?
|
|
89
|
+
@ui.say(snippet.lines.map { |line| " #{line}" }.join.chomp)
|
|
90
|
+
end
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
def section(label, values)
|
|
@@ -8,6 +8,12 @@ module Shadwire
|
|
|
8
8
|
# files, and applies the base gems + the Tailwind @import (confirm-then-apply,
|
|
9
9
|
# auto on yes:). Idempotent on re-run; --force resets shadwire.json.
|
|
10
10
|
class Init
|
|
11
|
+
# The CLI gem is a development-time tool for the app, deliberately kept out
|
|
12
|
+
# of registry.json: the registry's gems are what the *components* need at
|
|
13
|
+
# runtime, and installed files carry no Shadwire dependency.
|
|
14
|
+
CLI_GEM = Binstub::GEM
|
|
15
|
+
CLI_GEM_GROUP = "development"
|
|
16
|
+
|
|
11
17
|
def initialize(root:, registry: nil, yes: false, force: false, json: false,
|
|
12
18
|
ui: UI.new(yes:), runner: Dependencies::DEFAULT_RUNNER)
|
|
13
19
|
@root = root.to_s
|
|
@@ -31,16 +37,21 @@ module Shadwire
|
|
|
31
37
|
|
|
32
38
|
deps = Dependencies.new(project, runner: @runner)
|
|
33
39
|
confirm = ->(desc) { @ui.confirm?(desc) }
|
|
34
|
-
|
|
40
|
+
base_gems = deps.ensure_gems(Array(base["gems"]), yes: @yes, confirm:)
|
|
41
|
+
cli_gem = deps.ensure_gems([CLI_GEM], yes: @yes, confirm:, group: CLI_GEM_GROUP)
|
|
42
|
+
gems = merge_gem_results(base_gems, cli_gem)
|
|
35
43
|
tailwind = deps.ensure_tailwind_import(
|
|
36
44
|
vendor_css_target(config, base), css_entry: config.tailwind_css, yes: @yes, confirm:
|
|
37
45
|
)
|
|
46
|
+
binstub = write_binstub(cli_gem)
|
|
38
47
|
|
|
39
48
|
config.save
|
|
40
49
|
warn_missing_stack(project)
|
|
41
|
-
result = { config:, report:, gems:, tailwind: }
|
|
50
|
+
result = { config:, report:, gems:, gem_results: [base_gems, cli_gem], tailwind:, binstub: }
|
|
42
51
|
emit(result)
|
|
43
|
-
|
|
52
|
+
# Passed separately, not merged: each result carries its own group, so
|
|
53
|
+
# the recovery command it suggests matches the one that failed.
|
|
54
|
+
Dependencies.raise_on_failure!(base_gems, cli_gem)
|
|
44
55
|
|
|
45
56
|
result
|
|
46
57
|
end
|
|
@@ -55,6 +66,27 @@ module Shadwire
|
|
|
55
66
|
config
|
|
56
67
|
end
|
|
57
68
|
|
|
69
|
+
def merge_gem_results(*results)
|
|
70
|
+
%i[applied skipped pending manual failed]
|
|
71
|
+
.to_h { |key| [key, results.flat_map { |result| result[key] }] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Only write the binstub once `shadwire` is actually declared in the app's
|
|
75
|
+
# Gemfile — a bundler binstub without it raises Gem::LoadError on first run.
|
|
76
|
+
#
|
|
77
|
+
# The gate reads the ensure_gems result rather than re-reading the Gemfile:
|
|
78
|
+
# it is a direct record of what the command did, and it does not depend on
|
|
79
|
+
# `bundle add` writing a line the Project#gem? regex recognizes.
|
|
80
|
+
def write_binstub(cli_gem)
|
|
81
|
+
present = (cli_gem[:applied] + cli_gem[:skipped]).include?(CLI_GEM)
|
|
82
|
+
# An existing binstub without the gem is a different problem from a
|
|
83
|
+
# missing one: telling someone to create a file that is already there
|
|
84
|
+
# buries the real issue, which is that it cannot run.
|
|
85
|
+
return { status: Binstub.exist?(@root) ? :unusable : :unavailable } unless present
|
|
86
|
+
|
|
87
|
+
Binstub.write(@root, force: @force)[:written] ? { status: :written } : { status: :skipped }
|
|
88
|
+
end
|
|
89
|
+
|
|
58
90
|
def vendor_css_target(config, base)
|
|
59
91
|
File.join(config.aliases.fetch("vendorCss", "vendor/shadwire"), base.dig("tailwind", "css"))
|
|
60
92
|
end
|
|
@@ -74,7 +106,7 @@ module Shadwire
|
|
|
74
106
|
def emit(result)
|
|
75
107
|
return @ui.say(JSON.generate(json_payload(result))) if @json
|
|
76
108
|
|
|
77
|
-
print_summary(result[:report], result[:
|
|
109
|
+
print_summary(result[:report], result[:gem_results], result[:tailwind], result[:binstub])
|
|
78
110
|
end
|
|
79
111
|
|
|
80
112
|
def json_payload(result)
|
|
@@ -83,18 +115,41 @@ module Shadwire
|
|
|
83
115
|
"gems" => { "applied" => result[:gems][:applied], "pending" => result[:gems][:pending],
|
|
84
116
|
"failed" => result[:gems][:failed] },
|
|
85
117
|
"tailwind" => { "applied" => result[:tailwind][:applied], "manual" => result[:tailwind][:manual] },
|
|
118
|
+
"binstub" => { "path" => Binstub::PATH,
|
|
119
|
+
"written" => result[:binstub][:status] == :written,
|
|
120
|
+
"skipped" => result[:binstub][:status] == :skipped },
|
|
86
121
|
"registry" => result[:config].registry
|
|
87
122
|
}
|
|
88
123
|
end
|
|
89
124
|
|
|
90
|
-
def print_summary(report,
|
|
125
|
+
def print_summary(report, gem_results, tailwind, binstub)
|
|
91
126
|
@ui.say("Initialized shadwire (#{report[:written].size} base files).")
|
|
92
127
|
report[:written].each { |t| @ui.say(" create #{t}") }
|
|
93
|
-
|
|
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)") }
|
|
128
|
+
gem_results.each { |result| print_gems(result) }
|
|
96
129
|
(tailwind[:applied]).each { |i| @ui.say(" tailwind #{i}") }
|
|
97
130
|
tailwind[:manual].each { |m| @ui.say(" manual #{m}") }
|
|
131
|
+
print_binstub(binstub)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Each result prints its own group, so the command it tells you to run is
|
|
135
|
+
# the one that would actually have worked.
|
|
136
|
+
def print_gems(result)
|
|
137
|
+
suffix = result[:group] ? " --group #{result[:group]}" : ""
|
|
138
|
+
result[:applied].each { |g| @ui.say(" gem #{g}") }
|
|
139
|
+
result[:pending].each { |g| @ui.say(" pending #{g} (not installed — run: bundle add #{g}#{suffix})") }
|
|
140
|
+
result[:failed].each { |g| @ui.say(" FAILED #{g} (bundle add failed)") }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def print_binstub(binstub)
|
|
144
|
+
case binstub[:status]
|
|
145
|
+
when :written then @ui.say(" create #{Binstub::PATH}")
|
|
146
|
+
when :skipped then @ui.say(" skip #{Binstub::PATH} (already exists)")
|
|
147
|
+
when :unusable
|
|
148
|
+
@ui.say(" warn #{Binstub::PATH} exists but `gem \"#{CLI_GEM}\"` is not in the " \
|
|
149
|
+
"Gemfile — it will not run until it is")
|
|
150
|
+
when :unavailable
|
|
151
|
+
@ui.say(" manual #{Binstub::PATH} (add `gem \"#{CLI_GEM}\"` to the Gemfile, then re-run init)")
|
|
152
|
+
end
|
|
98
153
|
end
|
|
99
154
|
end
|
|
100
155
|
end
|
|
@@ -23,8 +23,9 @@ module Shadwire
|
|
|
23
23
|
|
|
24
24
|
def call
|
|
25
25
|
project = Project.new(@root)
|
|
26
|
-
config =
|
|
26
|
+
config, config_error = load_config
|
|
27
27
|
result = context(project, config)
|
|
28
|
+
result["configError"] = config_error if config_error
|
|
28
29
|
|
|
29
30
|
begin
|
|
30
31
|
client = RegistryClient.new(@registry_override || config.registry)
|
|
@@ -41,6 +42,16 @@ module Shadwire
|
|
|
41
42
|
|
|
42
43
|
private
|
|
43
44
|
|
|
45
|
+
# A hand-edited shadwire.json must not raise here. The agent skill injects
|
|
46
|
+
# this command and discards stderr while it chains invocation forms, so a
|
|
47
|
+
# non-zero exit would reach the agent as "no CLI here" with the real
|
|
48
|
+
# diagnostic thrown away. Report it and carry on with defaults instead.
|
|
49
|
+
def load_config
|
|
50
|
+
[Config.load(@root), nil]
|
|
51
|
+
rescue Shadwire::Error => e
|
|
52
|
+
[Config.new(@root, JSON.parse(JSON.generate(Config::DEFAULTS))), e.message]
|
|
53
|
+
end
|
|
54
|
+
|
|
44
55
|
def context(project, config)
|
|
45
56
|
{
|
|
46
57
|
"rails" => project.rails?,
|
|
@@ -57,6 +68,12 @@ module Shadwire
|
|
|
57
68
|
"view_component" => project.gem?("view_component"),
|
|
58
69
|
"lucide-rails" => project.gem?("lucide-rails")
|
|
59
70
|
},
|
|
71
|
+
# The canonical entry point, so the skill can name one invocation
|
|
72
|
+
# rather than inspecting the Gemfile to guess at one.
|
|
73
|
+
"cli" => {
|
|
74
|
+
"gem" => project.gem?("shadwire"),
|
|
75
|
+
"binstub" => project.binstub?
|
|
76
|
+
},
|
|
60
77
|
"tailwind" => {
|
|
61
78
|
"css" => config.tailwind_css,
|
|
62
79
|
"importPresent" => tailwind_import?(config)
|
|
@@ -120,11 +137,13 @@ module Shadwire
|
|
|
120
137
|
@ui.say("Stack: importmap=#{result.dig("stack", "importmap")} " \
|
|
121
138
|
"stimulus=#{result.dig("stack", "stimulus")} " \
|
|
122
139
|
"tailwindcss-rails=#{result.dig("stack", "tailwindcssRails")}")
|
|
140
|
+
@ui.say("CLI: binstub=#{result.dig("cli", "binstub")} gem=#{result.dig("cli", "gem")}")
|
|
123
141
|
@ui.say("Installed (#{result["installed"].size} of #{result["availableCount"] || "?"}):")
|
|
124
142
|
result["installed"].each do |item|
|
|
125
143
|
@ui.say(" #{item["drift"].ljust(9)} #{item["name"]} #{item["helpers"].join(", ")}")
|
|
126
144
|
end
|
|
127
145
|
|
|
146
|
+
@ui.warn("shadwire.json unreadable, using defaults: #{result["configError"]}") if result["configError"]
|
|
128
147
|
@ui.warn("Registry unavailable: #{result["registryError"]}") if result["registryError"]
|
|
129
148
|
return unless result.dig("helpers", "legacyHelperPresent")
|
|
130
149
|
|
data/lib/shadwire/config.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Shadwire
|
|
|
9
9
|
# On-disk JSON shape (mirrors shadcn's components.json conventions):
|
|
10
10
|
#
|
|
11
11
|
# {
|
|
12
|
-
# "registry": "https://edumoraes.
|
|
12
|
+
# "registry": "https://shadwire.edumoraes.dev.br/r",
|
|
13
13
|
# "tailwind": { "css": "app/assets/tailwind/application.css" },
|
|
14
14
|
# "aliases": {
|
|
15
15
|
# "components": "app/components",
|
|
@@ -26,7 +26,7 @@ module Shadwire
|
|
|
26
26
|
CONFIG_FILE = "shadwire.json"
|
|
27
27
|
|
|
28
28
|
DEFAULTS = {
|
|
29
|
-
"registry" => "https://edumoraes.
|
|
29
|
+
"registry" => "https://shadwire.edumoraes.dev.br/r",
|
|
30
30
|
"tailwind" => { "css" => "app/assets/tailwind/application.css" },
|
|
31
31
|
"aliases" => {
|
|
32
32
|
"components" => "app/components",
|
|
@@ -28,12 +28,25 @@ module Shadwire
|
|
|
28
28
|
# Raises when any of the given result hashes reports something it failed to
|
|
29
29
|
# apply, so a command that could not install a dependency exits non-zero
|
|
30
30
|
# instead of claiming success.
|
|
31
|
+
# Each result carries its own `group:`, so the recovery command it suggests
|
|
32
|
+
# matches the one that failed. Collapsing them would tell the user to
|
|
33
|
+
# `bundle add shadwire`, landing the development-only CLI in the app's
|
|
34
|
+
# runtime dependencies.
|
|
31
35
|
def self.raise_on_failure!(*results)
|
|
32
|
-
|
|
33
|
-
return if
|
|
36
|
+
failing = results.reject { |result| Array(result[:failed]).empty? }
|
|
37
|
+
return if failing.empty?
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
names = failing.flat_map { |result| Array(result[:failed]) }.uniq
|
|
40
|
+
commands = failing.map { |result| bundle_add_command(result) }
|
|
41
|
+
|
|
42
|
+
raise Error, "Failed to install: #{names.join(", ")}. " \
|
|
43
|
+
"Run `#{commands.join("` and `")}` in the app and re-run this command."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `bundle add <names...>` for a result, with its group when it has one.
|
|
47
|
+
def self.bundle_add_command(result)
|
|
48
|
+
suffix = result[:group] ? " --group #{result[:group]}" : ""
|
|
49
|
+
"bundle add #{Array(result[:failed]).join(" ")}#{suffix}"
|
|
37
50
|
end
|
|
38
51
|
|
|
39
52
|
def initialize(project, runner: DEFAULT_RUNNER)
|
|
@@ -42,20 +55,26 @@ module Shadwire
|
|
|
42
55
|
end
|
|
43
56
|
|
|
44
57
|
# Ensures each gem name is present, running `bundle add <missing...>`.
|
|
45
|
-
|
|
58
|
+
# `group:` adds them to a bundler group — used for the shadwire CLI itself,
|
|
59
|
+
# which belongs in development, not in the app's runtime dependencies.
|
|
60
|
+
def ensure_gems(names, yes:, confirm: nil, group: nil)
|
|
46
61
|
present = names.select { |name| @project.gem?(name) }
|
|
47
62
|
missing = names - present
|
|
48
63
|
return result(skipped: present) if missing.empty?
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
suffix = group ? " --group #{group}" : ""
|
|
66
|
+
unless apply?(yes, confirm, "Add gems: #{missing.join(", ")} (bundle add#{suffix})")
|
|
67
|
+
return result(skipped: present, pending: missing, group:)
|
|
52
68
|
end
|
|
53
69
|
|
|
70
|
+
command = ["bundle", "add", *missing]
|
|
71
|
+
command.push("--group", group.to_s) if group
|
|
72
|
+
|
|
54
73
|
# `system` returns false on a non-zero exit and nil when the command could
|
|
55
74
|
# not be run at all; neither means the gems landed.
|
|
56
|
-
return result(skipped: present, failed: missing) unless @runner.call(
|
|
75
|
+
return result(skipped: present, failed: missing, group:) unless @runner.call(command, chdir: @project.root)
|
|
57
76
|
|
|
58
|
-
result(applied: missing, skipped: present)
|
|
77
|
+
result(applied: missing, skipped: present, group:)
|
|
59
78
|
end
|
|
60
79
|
|
|
61
80
|
# Ensures each pin ({ "name" =>, "to" => }) exists in config/importmap.rb.
|
|
@@ -106,8 +125,8 @@ module Shadwire
|
|
|
106
125
|
|
|
107
126
|
private
|
|
108
127
|
|
|
109
|
-
def result(applied: [], skipped: [], pending: [], manual: [], failed: [])
|
|
110
|
-
{ applied:, skipped:, pending:, manual:, failed: }
|
|
128
|
+
def result(applied: [], skipped: [], pending: [], manual: [], failed: [], group: nil)
|
|
129
|
+
{ applied:, skipped:, pending:, manual:, failed:, group: }
|
|
111
130
|
end
|
|
112
131
|
|
|
113
132
|
def apply?(yes, confirm, description)
|
data/lib/shadwire/project.rb
CHANGED
|
@@ -33,6 +33,12 @@ module Shadwire
|
|
|
33
33
|
gem?("stimulus-rails") || File.exist?(path("app/javascript/controllers/index.js"))
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# The canonical CLI entry point. `status` reports it so the agent skill can
|
|
37
|
+
# name one invocation instead of guessing from the Gemfile.
|
|
38
|
+
def binstub?
|
|
39
|
+
Binstub.exist?(@root)
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
def importmap_path
|
|
37
43
|
path("config/importmap.rb")
|
|
38
44
|
end
|
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module Shadwire
|
|
6
7
|
# Fetches the published registry over file:// or http(s)://.
|
|
7
8
|
# Both #index and #item are memoized within the instance.
|
|
8
9
|
class RegistryClient
|
|
10
|
+
# Registries move: GitHub Pages answers 301 for every path once a custom
|
|
11
|
+
# domain is configured. Follow a few hops, then assume a misconfiguration
|
|
12
|
+
# rather than chasing a chain forever.
|
|
13
|
+
MAX_REDIRECTS = 5
|
|
14
|
+
|
|
15
|
+
# Resolves a redirect's Location against the URL it came from, rejecting
|
|
16
|
+
# redirects we must not follow. A registry serves source code that gets
|
|
17
|
+
# written into the user's app, so a redirect may upgrade to HTTPS but never
|
|
18
|
+
# drop it (GitHub Pages answers `Location: http://…` for a custom domain
|
|
19
|
+
# until "Enforce HTTPS" is on).
|
|
20
|
+
def self.redirect_target(from_uri, location)
|
|
21
|
+
if location.to_s.strip.empty?
|
|
22
|
+
raise RegistryError, "Registry redirect from #{from_uri} has no Location header"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
target = from_uri + location.strip
|
|
27
|
+
rescue URI::Error => e
|
|
28
|
+
raise RegistryError,
|
|
29
|
+
"Registry redirect from #{from_uri} has an unusable Location (#{location}): #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if from_uri.scheme == "https" && target.scheme != "https"
|
|
33
|
+
raise RegistryError,
|
|
34
|
+
"Registry redirect from #{from_uri} drops HTTPS (to #{target}); refusing to follow it"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
target
|
|
38
|
+
end
|
|
39
|
+
|
|
9
40
|
def initialize(base_url)
|
|
10
41
|
# Normalise: drop any trailing slash so URL building is consistent.
|
|
11
42
|
@base_url = base_url.chomp("/")
|
|
@@ -46,16 +77,33 @@ module Shadwire
|
|
|
46
77
|
|
|
47
78
|
def fetch_http(url, name: nil)
|
|
48
79
|
require "net/http"
|
|
49
|
-
require "uri"
|
|
50
80
|
|
|
51
81
|
uri = URI.parse(url)
|
|
52
|
-
|
|
82
|
+
seen = []
|
|
83
|
+
response = nil
|
|
84
|
+
|
|
85
|
+
(MAX_REDIRECTS + 1).times do
|
|
86
|
+
if seen.include?(uri.to_s)
|
|
87
|
+
raise RegistryError, "Registry redirect from #{url} loops back to #{uri}"
|
|
88
|
+
end
|
|
89
|
+
seen << uri.to_s
|
|
90
|
+
|
|
91
|
+
response = Net::HTTP.get_response(uri)
|
|
92
|
+
break unless response.is_a?(Net::HTTPRedirection)
|
|
93
|
+
|
|
94
|
+
uri = self.class.redirect_target(uri, response["location"])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if response.is_a?(Net::HTTPRedirection)
|
|
98
|
+
raise RegistryError,
|
|
99
|
+
"Registry at #{url} redirected more than #{MAX_REDIRECTS} times (last: #{uri})"
|
|
100
|
+
end
|
|
53
101
|
|
|
54
102
|
unless response.is_a?(Net::HTTPSuccess)
|
|
55
|
-
raise RegistryError, missing_message(name,
|
|
103
|
+
raise RegistryError, missing_message(name, uri, code: response.code)
|
|
56
104
|
end
|
|
57
105
|
|
|
58
|
-
parse(response.body,
|
|
106
|
+
parse(response.body, uri)
|
|
59
107
|
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
60
108
|
# An offline machine, a DNS failure or a TLS problem is an ordinary
|
|
61
109
|
# condition here, not something to surface as a Ruby backtrace.
|
data/lib/shadwire/version.rb
CHANGED
data/lib/shadwire.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shadwire
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eduardo de Moraes
|
|
@@ -36,6 +36,7 @@ files:
|
|
|
36
36
|
- README.md
|
|
37
37
|
- exe/shadwire
|
|
38
38
|
- lib/shadwire.rb
|
|
39
|
+
- lib/shadwire/binstub.rb
|
|
39
40
|
- lib/shadwire/cli.rb
|
|
40
41
|
- lib/shadwire/commands/add.rb
|
|
41
42
|
- lib/shadwire/commands/diff.rb
|