easy_caddy 0.1.0 → 0.1.2

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: 15359e9854098ff355b15635597ff9979952d9224835eb31594676492fbd64d9
4
- data.tar.gz: b3f4b62942373f0554e0d0ff92671bccb8fe7efd8ffef16038f4248c251687bb
3
+ metadata.gz: 1c38478a91bcf7a5ef93a10693e73d829b74ab1e88709fb38112c1120d30e499
4
+ data.tar.gz: c4923a78a738fdd89079df4f80f3f35ac3185a421be7b2e3ae3509d985168790
5
5
  SHA512:
6
- metadata.gz: 2cccf4fca5dbe0eb0148998507d67666b294dcc0233c34fd83b2789e6c18c44191981dc2391b9f319067bc183729078b2985994641f9247838134c3361efbdf2
7
- data.tar.gz: d8c66f1dba6f6d32210c2a2cbfa910a1560668a7ea5e5a84cfe9e6371142036fcd0543035b488f9b415f9e1c6ac9c58b6ac1e17cc06006e6f71a9e8c057e45bf
6
+ metadata.gz: 59e2de015249c22dc56aa731e9a059655a9548d27582947a6d1ba38bffd713950fc23db4e21867ffee2cb2ae7d6e36bd13a8fe4eff4356e06b25115df2f326c8
7
+ data.tar.gz: 4a2226c3ad545bdd047dfcb896636eaf52a9fa1bae1a8984d68a7bbf84300e0dc9fcc9c61fc64fe259f994c37b1d3f8fd12e4d413764e6b3775577302f569937
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] — 2026-06-09
9
+
10
+ ### Added
11
+
12
+ - `EasyCaddy::Error` exception class for user-facing failures. `exe/ecaddy`
13
+ now rescues it and prints a clean one-line message to stderr (exit 1),
14
+ replacing Ruby's default backtrace dump on expected errors.
15
+ - Registered fragments now have `output file` directives rewritten to
16
+ include `mode 0660` so log files (and rolled successors) stay
17
+ group-writable — Caddy runs as root to bind `:80`/`:443`, but
18
+ `caddy validate` / `caddy reload` run as the unprivileged user and need
19
+ to open them.
20
+ - `ecaddy audit` now reports each declared log file as writable, missing,
21
+ or root-locked, with an interactive `--fix` that escalates to
22
+ `sudo chmod` when needed.
23
+
24
+ ### Fixed
25
+
26
+ - `ecaddy setup` now starts the Caddy brew service **before** running
27
+ `caddy trust`, fixing a `connection refused` failure on fresh installs
28
+ (the local-CA fetch requires the admin endpoint at `localhost:2019` to
29
+ be running). Setup also polls the admin endpoint for up to 10 s before
30
+ attempting trust.
31
+ - `caddy trust` failures now surface the underlying output plus an
32
+ actionable hint (brew restart, `sudo caddy trust`, or re-run `setup`)
33
+ instead of a generic message.
34
+ - `ecaddy run` / `ecaddy ensure` now pre-create each log file declared
35
+ in the fragment and fail fast with a `sudo chmod` hint when the file
36
+ or its directory is owned by another user (typically left over from a
37
+ previous `sudo` run). The opaque `permission denied` from Caddy's
38
+ config validator is now translated into a one-line, actionable error.
39
+
8
40
  ## [0.1.0] — 2026-06-09
9
41
 
10
42
  ### Added
@@ -31,4 +63,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
63
  `SIGTERM`/`SIGINT`, and unregisters on exit — designed to drop into a
32
64
  Procfile alongside the Rails server.
33
65
 
66
+ [0.1.2]: https://github.com/pniemczyk/easy_caddy/releases/tag/v0.1.2
34
67
  [0.1.0]: https://github.com/pniemczyk/easy_caddy/releases/tag/v0.1.0
data/README.md CHANGED
@@ -272,7 +272,7 @@ ecaddy reload
272
272
 
273
273
  ```bash
274
274
  ecaddy version
275
- # ecaddy 0.1.0
275
+ # ecaddy 0.1.2
276
276
  ```
277
277
 
278
278
  ## Global config layout
data/exe/ecaddy CHANGED
@@ -3,4 +3,11 @@
3
3
 
4
4
  require_relative '../lib/easy_caddy'
5
5
 
6
- EasyCaddy::CLI.start(ARGV)
6
+ begin
7
+ EasyCaddy::CLI.start(ARGV)
8
+ rescue EasyCaddy::Error => e
9
+ # Deliberately $stderr.puts, not warn: this message must always surface,
10
+ # even when warnings are disabled (e.g. RUBYOPT=-W0).
11
+ $stderr.puts e.message # rubocop:disable Style/StderrPuts
12
+ exit 1
13
+ end
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'English'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require_relative 'error'
4
7
  require_relative 'paths'
5
8
 
6
9
  module EasyCaddy
7
10
  module Caddy
8
11
  BINARY = 'caddy'
9
12
 
13
+ # Group-writable mode for Caddy log files. A root-run Caddy (needed to bind :443/:80)
14
+ # creates logs the unprivileged user can't open during `caddy validate`/`reload`;
15
+ # 0660 + macOS staff-group inheritance keeps them openable. See the log-permission fix.
16
+ LOG_FILE_MODE = '0660'
17
+
10
18
  def self.installed?
11
19
  system('which caddy > /dev/null 2>&1')
12
20
  end
@@ -19,8 +27,34 @@ module EasyCaddy
19
27
  return unless caddyfile.exist?
20
28
 
21
29
  out = `#{BINARY} validate --config #{caddyfile} 2>&1`
22
- raise "Caddy config invalid:\n#{out}" unless $CHILD_STATUS.success?
30
+ return if $CHILD_STATUS.success?
31
+
32
+ raise Error, translate_validate_error(out)
33
+ end
34
+
35
+ # Caddy validate emits a wall of JSON log lines on stderr. Pull out the
36
+ # actual error and, for common cases (log file permission), turn it into
37
+ # an actionable message.
38
+ # rubocop:disable Metrics/MethodLength
39
+ def self.translate_validate_error(output)
40
+ error_line = output.lines.find { |l| l.start_with?('Error:') }&.strip
41
+
42
+ if error_line && error_line.match?(/setting up custom log.*permission denied/i)
43
+ path = error_line[%r{open\s+(/\S+):\s*permission denied}, 1]
44
+ hint =
45
+ if path
46
+ "Caddy runs as root and created this log 0600; validation runs as you and can't " \
47
+ "open it.\nFix it once with: sudo chmod #{LOG_FILE_MODE} #{path}\n" \
48
+ 'or run `ecaddy audit --fix` to do it interactively.'
49
+ else
50
+ 'Check ownership of the log file referenced above, or run `ecaddy audit --fix`.'
51
+ end
52
+ return "Caddy config invalid — log file not writable:\n #{error_line}\n\n#{hint}"
53
+ end
54
+
55
+ "Caddy config invalid:\n#{error_line || output}"
23
56
  end
57
+ # rubocop:enable Metrics/MethodLength
24
58
 
25
59
  def self.reload(caddyfile = Paths.caddyfile)
26
60
  unless caddyfile.exist?
@@ -29,13 +63,46 @@ module EasyCaddy
29
63
  end
30
64
 
31
65
  out = `#{BINARY} reload --config #{caddyfile} 2>&1`
32
- raise "Caddy reload failed:\n#{out}" unless $CHILD_STATUS.success?
66
+ raise Error, "Caddy reload failed:\n#{out}" unless $CHILD_STATUS.success?
33
67
  end
34
68
 
35
69
  def self.trust
36
70
  system("#{BINARY} trust")
37
71
  end
38
72
 
73
+ # Runs `caddy trust` and captures stdout+stderr so callers can inspect failures.
74
+ #
75
+ # @return [Array(String, Boolean)] combined output and whether the command succeeded
76
+ def self.trust_with_output
77
+ out = `#{BINARY} trust 2>&1`
78
+ [out, $CHILD_STATUS.success?]
79
+ end
80
+
81
+ ADMIN_ENDPOINT = 'http://localhost:2019/pki/ca/local'
82
+
83
+ # Polls Caddy's admin API until it responds or the timeout elapses.
84
+ #
85
+ # @param timeout [Numeric] seconds to wait before giving up
86
+ # @return [Boolean] true if the admin endpoint responded
87
+ def self.wait_for_admin_endpoint(timeout: 5)
88
+ deadline = Time.now + timeout
89
+ until Time.now > deadline
90
+ return true if admin_endpoint_reachable?
91
+
92
+ sleep 0.25
93
+ end
94
+ false
95
+ end
96
+
97
+ def self.admin_endpoint_reachable?
98
+ uri = URI(ADMIN_ENDPOINT)
99
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
100
+ http.get(uri.request_uri).is_a?(Net::HTTPSuccess)
101
+ end
102
+ rescue StandardError
103
+ false
104
+ end
105
+
39
106
  def self.running?
40
107
  pid = brew_service_pid
41
108
  pid && pid > 0
@@ -399,15 +399,42 @@ module EasyCaddy
399
399
  if log_paths.empty?
400
400
  info 'No log files configured (add a log { output file … } block)'
401
401
  else
402
- log_paths.each do |path|
403
- if File.exist?(path)
404
- ok("log #{path} (#{humanize_bytes(File.size(path))})")
405
- else
406
- warn("log #{path} — not yet created")
407
- end
408
- end
402
+ log_paths.each { |path| print_log_file(path) }
403
+ end
404
+ end
405
+ # rubocop:enable Metrics/MethodLength
406
+
407
+ def print_log_file(path)
408
+ if !File.exist?(path)
409
+ warn("log #{path} — not yet created")
410
+ elsif File.writable?(path)
411
+ ok("log #{path} (#{humanize_bytes(File.size(path))})")
412
+ else
413
+ fail("log #{path} — NOT writable by you (root-owned?)",
414
+ hint: 'Caddy runs as root and created this 0600 log; `caddy validate` runs as ' \
415
+ 'you and cannot open it. Make it group-writable.',
416
+ fix: log_permission_fix(path))
409
417
  end
410
418
  end
419
+
420
+ # rubocop:disable Metrics/MethodLength
421
+ def log_permission_fix(path)
422
+ {
423
+ description: "Make the log group-writable (chmod #{Caddy::LOG_FILE_MODE})",
424
+ command: "chmod #{Caddy::LOG_FILE_MODE} #{path}",
425
+ verify: -> { File.writable?(path) },
426
+ escalation: "You don't own this file — needs sudo.",
427
+ next_fix: Fix.new(
428
+ label: "#{path} still not writable",
429
+ description: 'chmod the root-owned log via sudo',
430
+ command: "sudo chmod #{Caddy::LOG_FILE_MODE} #{path}",
431
+ verify: -> { File.writable?(path) },
432
+ escalation: "Still not writable. Check `ls -l #{path}`; " \
433
+ "you may need `sudo chown $USER:staff #{path}`.",
434
+ next_fix: nil
435
+ )
436
+ }
437
+ end
411
438
  # rubocop:enable Metrics/MethodLength
412
439
 
413
440
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'socket'
5
5
  require 'openssl'
6
+ require_relative '../error'
6
7
  require_relative '../paths'
7
8
  require_relative '../registry'
8
9
  require_relative '../conflicts'
@@ -12,6 +13,7 @@ require_relative '../parser'
12
13
 
13
14
  module EasyCaddy
14
15
  module Commands
16
+ # rubocop:disable Metrics/ModuleLength
15
17
  module RegisterHelpers
16
18
  private
17
19
 
@@ -19,10 +21,10 @@ module EasyCaddy
19
21
  # Returns the site name on success.
20
22
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
21
23
  def register(config_path, name)
22
- raise ArgumentError, 'Pass --site NAME to identify this project.' unless name
24
+ raise Error, 'Pass --site NAME to identify this project.' unless name
23
25
 
24
26
  config_path = File.expand_path(config_path)
25
- raise ArgumentError, "Config not found: #{config_path}" unless File.exist?(config_path)
27
+ raise Error, "Config not found: #{config_path}" unless File.exist?(config_path)
26
28
 
27
29
  content = File.read(config_path)
28
30
  registry = Registry.load
@@ -33,11 +35,11 @@ module EasyCaddy
33
35
  blocks = findings.select { |f| f.severity == 'BLOCK' }
34
36
  unless blocks.empty?
35
37
  blocks.each { |f| warn " BLOCK: #{f.message}\n Hint: #{f.hint}" }
36
- raise 'Aborting due to conflict.'
38
+ raise Error, 'Aborting due to conflict.'
37
39
  end
38
40
 
39
41
  Paths.sites_dir.mkpath
40
- rewritten = absolutize_log_paths(content, File.dirname(config_path))
42
+ rewritten = ensure_log_mode(absolutize_log_paths(content, File.dirname(config_path)))
41
43
  Paths.site_file(name).write(rewritten)
42
44
 
43
45
  ensure_log_dirs(rewritten)
@@ -65,12 +67,66 @@ module EasyCaddy
65
67
  end
66
68
  end
67
69
 
70
+ # Guarantee every `output file` log directive sets a group-writable mode, so a
71
+ # root-run Caddy's logs (and rolled files) stay openable by the staff-group user that
72
+ # runs `caddy validate`/`reload`. Leaves an explicit `mode` untouched.
73
+ def ensure_log_mode(content)
74
+ content.gsub(/(\boutput\s+file\s+\S+)(\s*\{[^}]*\})?/m) do
75
+ directive = Regexp.last_match(1)
76
+ block = Regexp.last_match(2)
77
+ next "#{directive} {\n mode #{Caddy::LOG_FILE_MODE}\n }" if block.nil?
78
+ next "#{directive}#{block}" if block.match?(/\bmode\b/)
79
+
80
+ "#{directive}#{block.sub('{', "{\n mode #{Caddy::LOG_FILE_MODE}")}"
81
+ end
82
+ end
83
+
68
84
  def ensure_log_dirs(content)
69
85
  Parser.parse(content).log_paths.each do |path|
70
- FileUtils.mkdir_p(File.dirname(path))
86
+ ensure_log_writable(path)
71
87
  end
72
88
  end
73
89
 
90
+ # Make sure each log path exists and is writable by the current user.
91
+ # Caddy validates configs by *opening* every log file, so a stray
92
+ # root-owned file (left over from an earlier `sudo` run) makes
93
+ # validation fail with a confusing "permission denied".
94
+ def ensure_log_writable(path)
95
+ dir = File.dirname(path)
96
+ FileUtils.mkdir_p(dir)
97
+ FileUtils.touch(path) unless File.exist?(path)
98
+ return if File.writable?(path) && File.writable?(dir)
99
+
100
+ raise Error, build_log_permission_error(path)
101
+ rescue Errno::EACCES, Errno::EPERM
102
+ raise Error, build_log_permission_error(path)
103
+ end
104
+
105
+ # rubocop:disable Metrics/MethodLength
106
+ def build_log_permission_error(path)
107
+ owner =
108
+ begin
109
+ require 'etc'
110
+ Etc.getpwuid(File.stat(path).uid).name if File.exist?(path)
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ user = ENV['USER'] || Etc.getlogin
115
+
116
+ owner_line = owner ? " Current owner: #{owner}\n" : ''
117
+ <<~MSG.strip
118
+ Cannot write to Caddy log file: #{path}
119
+ #{owner_line} Caddy runs as root and created this log 0600; `caddy validate` runs as you
120
+ (#{user}) and can't open it. Make it group-writable once:
121
+
122
+ sudo chmod #{Caddy::LOG_FILE_MODE} #{path}
123
+
124
+ or run `ecaddy audit --fix` to do it interactively. Re-registering keeps the mode,
125
+ so it won't recur after log rolls. Then re-run the same `ecaddy` command.
126
+ MSG
127
+ end
128
+ # rubocop:enable Metrics/MethodLength
129
+
74
130
  def probe_tls(domains)
75
131
  domains.each do |domain|
76
132
  ok = tls_handshake_ok?(domain)
@@ -112,5 +168,6 @@ module EasyCaddy
112
168
  puts " [ecaddy] #{name} unregistered."
113
169
  end
114
170
  end
171
+ # rubocop:enable Metrics/ModuleLength
115
172
  end
116
173
  end
@@ -23,8 +23,8 @@ module EasyCaddy
23
23
  step('Scaffolding config directories') { scaffold_dirs }
24
24
  step('Writing global Caddyfile') { write_caddyfile }
25
25
  step('Symlinking for brew services') { symlink_brew }
26
- step('Trusting local CA') { trust_ca }
27
26
  step('Starting caddy service') { start_service }
27
+ step('Trusting local CA') { trust_ca }
28
28
  print_success
29
29
  end
30
30
 
@@ -76,8 +76,51 @@ module EasyCaddy
76
76
  end
77
77
 
78
78
  def trust_ca
79
- puts "\n Running `caddy trust` you may be prompted for your password."
80
- raise '`caddy trust` failed — re-run `ecaddy setup` or run `sudo caddy trust` manually.' unless Caddy.trust
79
+ puts "\n Waiting for Caddy admin endpoint at localhost:2019..."
80
+ unless Caddy.wait_for_admin_endpoint(timeout: 10)
81
+ raise <<~MSG.strip
82
+ Caddy admin endpoint at localhost:2019 is not responding.
83
+ `caddy trust` needs a running Caddy instance to fetch the local CA.
84
+ Check that the brew service is up: brew services list
85
+ Then re-run: ecaddy setup
86
+ MSG
87
+ end
88
+
89
+ puts ' Running `caddy trust` — you may be prompted for your password.'
90
+ output, success = Caddy.trust_with_output
91
+ return if success
92
+
93
+ raise build_trust_error(output)
94
+ end
95
+
96
+ def build_trust_error(output)
97
+ hint =
98
+ if output.include?('connection refused')
99
+ <<~HINT
100
+ Caddy admin endpoint at localhost:2019 became unreachable.
101
+ Try: brew services restart caddy && ecaddy setup
102
+ HINT
103
+ elsif output.match?(/permission denied|not permitted|requires.*root/i)
104
+ <<~HINT
105
+ `caddy trust` needs to add a root certificate to your system keychain.
106
+ Try running it manually: sudo caddy trust
107
+ HINT
108
+ else
109
+ <<~HINT
110
+ Re-run `ecaddy setup`, or run `caddy trust` manually to see the full error.
111
+ HINT
112
+ end
113
+
114
+ <<~MSG.strip
115
+ `caddy trust` failed:
116
+ #{indent(output.strip)}
117
+
118
+ #{indent(hint.strip)}
119
+ MSG
120
+ end
121
+
122
+ def indent(text, prefix = ' ')
123
+ text.lines.map { |l| "#{prefix}#{l}" }.join.rstrip
81
124
  end
82
125
 
83
126
  def start_service
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCaddy
4
+ # Raised for expected, user-actionable failures: invalid Caddy config,
5
+ # domain/port conflicts, unwritable log files. The CLI prints the message
6
+ # and exits non-zero WITHOUT a Ruby backtrace — these are not bugs, they're
7
+ # things the user is expected to fix and re-run.
8
+ class Error < StandardError; end
9
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyCaddy
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.2'
5
5
  end
data/lib/easy_caddy.rb CHANGED
@@ -4,6 +4,7 @@ require 'pathname'
4
4
  require 'fileutils'
5
5
 
6
6
  require_relative 'easy_caddy/version'
7
+ require_relative 'easy_caddy/error'
7
8
  require_relative 'easy_caddy/paths'
8
9
  require_relative 'easy_caddy/site'
9
10
  require_relative 'easy_caddy/registry'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_caddy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pawel Niemczyk
@@ -139,6 +139,7 @@ files:
139
139
  - lib/easy_caddy/commands/status.rb
140
140
  - lib/easy_caddy/commands/up.rb
141
141
  - lib/easy_caddy/conflicts.rb
142
+ - lib/easy_caddy/error.rb
142
143
  - lib/easy_caddy/parser.rb
143
144
  - lib/easy_caddy/paths.rb
144
145
  - lib/easy_caddy/registry.rb
@@ -167,7 +168,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
168
  - !ruby/object:Gem::Version
168
169
  version: '0'
169
170
  requirements: []
170
- rubygems_version: 4.0.6
171
+ rubygems_version: 3.6.9
171
172
  specification_version: 4
172
173
  summary: CLI to manage a single global Caddy for multiple local Rails projects
173
174
  test_files: []