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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cdc0f4830063d6e21c79acbe20a20d3b81862ff346a3af2fff6888af18ba8c2
4
- data.tar.gz: a455411bae400e6e0a99f4ad693988add8b95945abf15081321631f5922e3124
3
+ metadata.gz: 6294368abb24616f95b0d9a751bb554ba5135904927f03e5d880c3b969448c2a
4
+ data.tar.gz: ebcf2c60b07a68b96d2c97d07759b05b44432bf634809e1e174958adf3ea51f9
5
5
  SHA512:
6
- metadata.gz: 4ae1610e1fc2382eba1787a94ba27be6efaed66ed8535bbd72a64a09b82aa79df01271ad9186330e9fbefebe3487f84f447f662472bab4292de552d43e950b76
7
- data.tar.gz: a10682b3ba15a5e955b0952967b5af2695d040b5f71d08f4dad3d9f9c70bb8f43b146a783081fe1e843646011a66ef8edf35821eab39ccb3a28ca270fb2fa01d
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
- bundle add shadwire --group development # then run: bundle exec shadwire
15
- gem install shadwire # or globally, then run: shadwire
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.github.io/shadwire/r",
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.github.io/shadwire/r`.
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: "Overwrite an existing shadwire.json"
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
- snippets.each { |snippet| @ui.say(snippet.lines.map { |line| " #{line}" }.join.chomp) }
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
- gems = deps.ensure_gems(Array(base["gems"]), yes: @yes, confirm:)
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
- Dependencies.raise_on_failure!(gems)
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[:gems], result[:tailwind])
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, gems, tailwind)
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
- 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)") }
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 = Config.load(@root)
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
 
@@ -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.github.io/shadwire/r",
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.github.io/shadwire/r",
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
- failed = results.flat_map { |result| Array(result[:failed]) }.uniq
33
- return if failed.empty?
36
+ failing = results.reject { |result| Array(result[:failed]).empty? }
37
+ return if failing.empty?
34
38
 
35
- raise Error, "Failed to install: #{failed.join(", ")}. " \
36
- "Run `bundle add #{failed.join(" ")}` in the app and re-run this command."
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
- def ensure_gems(names, yes:, confirm: nil)
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
- unless apply?(yes, confirm, "Add gems: #{missing.join(", ")} (bundle add)")
51
- return result(skipped: present, pending: missing)
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(["bundle", "add", *missing], chdir: @project.root)
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)
@@ -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
- response = Net::HTTP.get_response(uri)
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, url, code: response.code)
103
+ raise RegistryError, missing_message(name, uri, code: response.code)
56
104
  end
57
105
 
58
- parse(response.body, url)
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shadwire
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/shadwire.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "shadwire/version"
4
4
  require_relative "shadwire/errors"
5
+ require_relative "shadwire/binstub"
5
6
  require_relative "shadwire/config"
6
7
  require_relative "shadwire/project"
7
8
  require_relative "shadwire/registry_client"
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.2.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