easy_caddy 0.1.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.
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'register_helpers'
4
+
5
+ module EasyCaddy
6
+ module Commands
7
+ # One-shot: copies the project Caddyfile into global sites/, reloads Caddy, then exits.
8
+ # Site stays registered until `ecaddy down NAME` or `ecaddy remove NAME`.
9
+ class Ensure
10
+ include RegisterHelpers
11
+
12
+ def initialize(config_path:, site:)
13
+ @config_path = config_path
14
+ @site = site
15
+ end
16
+
17
+ def call
18
+ register(@config_path, @site)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../registry'
4
+ require_relative '../paths'
5
+ require_relative '../parser'
6
+
7
+ module EasyCaddy
8
+ module Commands
9
+ class List
10
+ def initialize(format: 'table')
11
+ @format = format
12
+ @registry = Registry.load
13
+ end
14
+
15
+ def call
16
+ sites = @registry.all
17
+ if sites.empty?
18
+ puts ' No sites registered. Use `ecaddy run --config ./Caddyfile --site NAME` to add one.'
19
+ return
20
+ end
21
+
22
+ if @format == 'json'
23
+ require 'json'
24
+ puts JSON.generate(sites.map { |s| row_data(s) })
25
+ return
26
+ end
27
+
28
+ require 'tty-table'
29
+ rows = sites.map { |s| row_data(s).values }
30
+
31
+ table = TTY::Table.new(
32
+ header: ['Name', 'Status', 'Domains', 'Ports', 'Source'],
33
+ rows: rows
34
+ )
35
+ puts table.render(:unicode, padding: [0, 1])
36
+ end
37
+
38
+ private
39
+
40
+ def row_data(site)
41
+ frag = Paths.site_file(site.name)
42
+ parsed = frag.exist? ? Parser.parse(frag.read) : nil
43
+ {
44
+ name: site.name,
45
+ status: site.enabled ? 'up' : 'down',
46
+ domains: parsed&.domains&.join(', ') || '-',
47
+ ports: parsed&.ports&.join(', ') || '-',
48
+ source: site.source_path || '-'
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../registry'
4
+ require_relative '../paths'
5
+ require_relative '../parser'
6
+
7
+ module EasyCaddy
8
+ module Commands
9
+ # Tails Caddy access/error log files for a registered site.
10
+ class Logs
11
+ def initialize(site:, lines:, follow:)
12
+ @site = site
13
+ @lines = lines
14
+ @follow = follow
15
+ end
16
+
17
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
18
+ def call
19
+ registry = Registry.load
20
+ entry = registry.find(@site)
21
+ abort " [ecaddy] No site '#{@site}' in registry. Run `ecaddy list` to see registered sites." unless entry
22
+
23
+ fragment = resolve_fragment(entry)
24
+ abort " [ecaddy] Fragment not found for '#{@site}' in sites/ or disabled/." unless fragment
25
+
26
+ paths = Parser.parse(File.read(fragment)).log_paths
27
+ if paths.empty?
28
+ puts " [ecaddy] No 'output file' log directives found in #{fragment}."
29
+ puts ' Add a log block to your Caddyfile, e.g.:'
30
+ puts ' log { output file log/caddy.log }'
31
+ return
32
+ end
33
+
34
+ paths.each do |p|
35
+ next if File.exist?(p)
36
+
37
+ puts " [ecaddy] Note: #{p} not yet created (Caddy writes it on first request)."
38
+ end
39
+
40
+ existing = paths.select { |p| File.exist?(p) }
41
+ if existing.empty?
42
+ puts ' [ecaddy] No log files exist yet. Make a request to the site first.'
43
+ return
44
+ end
45
+
46
+ tail_args = @follow ? ['-F'] : ['-n', @lines.to_s]
47
+ exec('tail', *tail_args, *existing)
48
+ end
49
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
50
+
51
+ private
52
+
53
+ def resolve_fragment(entry)
54
+ enabled = Paths.site_file(entry.name)
55
+ disabled = Paths.disabled_file(entry.name)
56
+ return enabled if enabled.exist?
57
+ return disabled if disabled.exist?
58
+
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'socket'
5
+ require 'openssl'
6
+ require_relative '../paths'
7
+ require_relative '../registry'
8
+ require_relative '../conflicts'
9
+ require_relative '../caddy'
10
+ require_relative '../site'
11
+ require_relative '../parser'
12
+
13
+ module EasyCaddy
14
+ module Commands
15
+ module RegisterHelpers
16
+ private
17
+
18
+ # Copies config_path into ~/.config/caddy/sites/<name>.caddy and reloads.
19
+ # Returns the site name on success.
20
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
21
+ def register(config_path, name)
22
+ raise ArgumentError, 'Pass --site NAME to identify this project.' unless name
23
+
24
+ config_path = File.expand_path(config_path)
25
+ raise ArgumentError, "Config not found: #{config_path}" unless File.exist?(config_path)
26
+
27
+ content = File.read(config_path)
28
+ registry = Registry.load
29
+ existing = registry.find(name)
30
+
31
+ findings = Conflicts.check(name: name, content: content, registry: registry,
32
+ skip_name: existing&.name)
33
+ blocks = findings.select { |f| f.severity == 'BLOCK' }
34
+ unless blocks.empty?
35
+ blocks.each { |f| warn " BLOCK: #{f.message}\n Hint: #{f.hint}" }
36
+ raise 'Aborting due to conflict.'
37
+ end
38
+
39
+ Paths.sites_dir.mkpath
40
+ rewritten = absolutize_log_paths(content, File.dirname(config_path))
41
+ Paths.site_file(name).write(rewritten)
42
+
43
+ ensure_log_dirs(rewritten)
44
+
45
+ site = Site.new(name: name, enabled: true, source_path: config_path)
46
+ existing ? registry.update(site) : registry.add(site)
47
+
48
+ Caddy.validate!(Paths.caddyfile)
49
+ Caddy.reload(Paths.caddyfile)
50
+
51
+ puts " [ecaddy] #{name} registered (#{config_path})"
52
+ probe_tls(Parser.parse(rewritten).domains)
53
+ name
54
+ end
55
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
56
+
57
+ # Rewrite `output file <relative>` log paths to absolute paths so Caddy can
58
+ # write logs regardless of its working directory when running as a service.
59
+ def absolutize_log_paths(content, base_dir)
60
+ content.gsub(/(\boutput\s+file\s+)(\S+)/) do
61
+ prefix = Regexp.last_match(1)
62
+ path = Regexp.last_match(2)
63
+ absolute = File.expand_path(path, base_dir)
64
+ "#{prefix}#{absolute}"
65
+ end
66
+ end
67
+
68
+ def ensure_log_dirs(content)
69
+ Parser.parse(content).log_paths.each do |path|
70
+ FileUtils.mkdir_p(File.dirname(path))
71
+ end
72
+ end
73
+
74
+ def probe_tls(domains)
75
+ domains.each do |domain|
76
+ ok = tls_handshake_ok?(domain)
77
+ next if ok
78
+
79
+ warn " [ecaddy] WARN #{domain}: TLS handshake failed (Caddy may not be serving :443 yet)."
80
+ warn ' Run `ecaddy audit` to diagnose.'
81
+ end
82
+ end
83
+
84
+ # rubocop:disable Metrics/MethodLength
85
+ def tls_handshake_ok?(domain)
86
+ require 'timeout'
87
+ Timeout.timeout(1) do
88
+ tcp = TCPSocket.new('localhost', 443)
89
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp)
90
+ ssl.hostname = domain
91
+ ssl.sync_close = true
92
+ ssl.connect
93
+ ssl.close
94
+ end
95
+ true
96
+ rescue StandardError
97
+ false
98
+ end
99
+ # rubocop:enable Metrics/MethodLength
100
+
101
+ def unregister(name)
102
+ Paths.site_file(name).delete if Paths.site_file(name).exist?
103
+
104
+ registry = Registry.load
105
+ registry.remove(name)
106
+
107
+ begin
108
+ Caddy.reload(Paths.caddyfile)
109
+ rescue StandardError
110
+ nil
111
+ end
112
+ puts " [ecaddy] #{name} unregistered."
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../caddy'
4
+ require_relative '../paths'
5
+
6
+ module EasyCaddy
7
+ module Commands
8
+ class Reload
9
+ def call
10
+ Caddy.validate!(Paths.caddyfile)
11
+ Caddy.reload(Paths.caddyfile)
12
+ puts ' Caddy reloaded.'
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../paths'
4
+ require_relative '../registry'
5
+ require_relative '../caddy'
6
+
7
+ module EasyCaddy
8
+ module Commands
9
+ class Remove
10
+ def initialize(name:, force:, prompt:)
11
+ @name = name.downcase
12
+ @force = force
13
+ @prompt = prompt
14
+ @registry = Registry.load
15
+ end
16
+
17
+ def call
18
+ site = @registry.find(@name)
19
+ unless site
20
+ warn " Site '#{@name}' is not registered."
21
+ exit 1
22
+ end
23
+
24
+ unless @force
25
+ unless @prompt.yes?("Remove #{@name} and delete its Caddy fragment?")
26
+ puts ' Aborted.'
27
+ return
28
+ end
29
+ end
30
+
31
+ [Paths.site_file(@name), Paths.disabled_file(@name)].each do |f|
32
+ f.delete if f.exist?
33
+ end
34
+
35
+ @registry.remove(@name)
36
+ Caddy.reload(Paths.caddyfile)
37
+ puts " Removed '#{@name}'."
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'register_helpers'
4
+
5
+ module EasyCaddy
6
+ module Commands
7
+ # Foreground command for Procfile.dev.
8
+ # Registers the project Caddyfile on start, blocks until SIGTERM/SIGINT,
9
+ # then unregisters and exits cleanly.
10
+ class Run
11
+ include RegisterHelpers
12
+
13
+ def initialize(config_path:, site:)
14
+ @config_path = config_path
15
+ @site = site
16
+ end
17
+
18
+ def call
19
+ @registered_name = register(@config_path, @site)
20
+
21
+ cleanup = proc do
22
+ puts "\n [ecaddy] Shutting down — removing #{@registered_name}..."
23
+ unregister(@registered_name)
24
+ exit 0
25
+ end
26
+
27
+ Signal.trap('TERM', &cleanup)
28
+ Signal.trap('INT', &cleanup)
29
+
30
+ puts " [ecaddy] Watching. Send SIGTERM or Ctrl-C to unregister."
31
+ loop { sleep 5 }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../paths'
4
+ require_relative '../caddy'
5
+
6
+ module EasyCaddy
7
+ module Commands
8
+ class Setup
9
+ GLOBAL_CADDYFILE_CONTENT = <<~CADDY
10
+ {
11
+ admin localhost:2019
12
+ }
13
+
14
+ import sites/*.caddy
15
+ CADDY
16
+
17
+ def initialize(prompt:)
18
+ @prompt = prompt
19
+ end
20
+
21
+ def call
22
+ step('Checking Caddy binary') { ensure_caddy_installed }
23
+ step('Scaffolding config directories') { scaffold_dirs }
24
+ step('Writing global Caddyfile') { write_caddyfile }
25
+ step('Symlinking for brew services') { symlink_brew }
26
+ step('Trusting local CA') { trust_ca }
27
+ step('Starting caddy service') { start_service }
28
+ print_success
29
+ end
30
+
31
+ private
32
+
33
+ def step(label)
34
+ print " #{label}... "
35
+ yield
36
+ puts 'done'
37
+ rescue StandardError => e
38
+ puts "FAILED\n #{e.message}"
39
+ raise
40
+ end
41
+
42
+ def ensure_caddy_installed
43
+ return if Caddy.installed?
44
+
45
+ unless @prompt.yes?('caddy is not installed. Install via Homebrew now?')
46
+ raise 'caddy is required. Install it with: brew install caddy'
47
+ end
48
+
49
+ Caddy.install_via_brew
50
+ raise 'caddy installation failed' unless Caddy.installed?
51
+ end
52
+
53
+ def scaffold_dirs
54
+ [Paths.root, Paths.sites_dir, Paths.disabled_dir].each(&:mkpath)
55
+ end
56
+
57
+ def write_caddyfile
58
+ return if Paths.caddyfile.exist?
59
+
60
+ Paths.caddyfile.write(GLOBAL_CADDYFILE_CONTENT)
61
+ end
62
+
63
+ def symlink_brew
64
+ target = Paths.caddyfile
65
+ symlink = Paths.brew_caddyfile
66
+ return if symlink.symlink? && symlink.readlink == target
67
+
68
+ if symlink.exist? && !symlink.symlink?
69
+ bak = "#{symlink}.bak.#{Time.now.strftime('%Y%m%d%H%M%S')}"
70
+ symlink.rename(bak)
71
+ puts "\n Backed up existing #{symlink} → #{bak}"
72
+ end
73
+
74
+ symlink.parent.mkpath
75
+ symlink.make_symlink(target)
76
+ end
77
+
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
81
+ end
82
+
83
+ def start_service
84
+ if Caddy.running?
85
+ Caddy.restart_service
86
+ else
87
+ Caddy.start_service
88
+ end
89
+ end
90
+
91
+ def print_success
92
+ puts
93
+ puts ' ecaddy setup complete!'
94
+ puts
95
+ puts ' Next steps:'
96
+ puts ' ecaddy add myapp --port 3001 --vite-port 3050'
97
+ puts ' # then visit https://myapp.localhost'
98
+ puts
99
+ puts ' Or in your Procfile.dev:'
100
+ puts ' caddy: ecaddy run --config ./Caddyfile'
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../registry'
4
+ require_relative '../caddy'
5
+ require_relative '../conflicts'
6
+ require_relative '../paths'
7
+
8
+ module EasyCaddy
9
+ module Commands
10
+ class Status
11
+ def call
12
+ running = Caddy.running?
13
+ puts " Caddy service: #{running ? 'running' : 'STOPPED'}"
14
+ puts " Config: #{Paths.caddyfile}"
15
+ puts
16
+
17
+ registry = Registry.load
18
+ sites = registry.all
19
+
20
+ if sites.empty?
21
+ puts ' No sites registered.'
22
+ return
23
+ end
24
+
25
+ dead_msgs = Conflicts.doctor(registry: registry)
26
+ .select { |f| f.severity == 'INFO' }
27
+ .map(&:message)
28
+
29
+ sites.each do |s|
30
+ site_dead = dead_msgs.any? { |m| m.start_with?(s.name) }
31
+ label = !s.enabled ? 'down' : (site_dead ? 'up (app not running)' : 'up')
32
+ puts " #{s.name.ljust(20)} #{label}"
33
+ puts " fragment: #{Paths.site_file(s.name)}" if s.enabled
34
+ puts " source: #{s.source_path}" if s.source_path
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../paths'
4
+ require_relative '../registry'
5
+ require_relative '../caddy'
6
+ require_relative '../site'
7
+
8
+ module EasyCaddy
9
+ module Commands
10
+ class Up
11
+ def initialize(name:)
12
+ @name = name.downcase
13
+ @registry = Registry.load
14
+ end
15
+
16
+ def call
17
+ site = @registry.find(@name)
18
+ unless site
19
+ warn " Site '#{@name}' is not registered."
20
+ exit 1
21
+ end
22
+
23
+ if site.enabled
24
+ puts " '#{@name}' is already up."
25
+ return
26
+ end
27
+
28
+ disabled = Paths.disabled_file(@name)
29
+ unless disabled.exist?
30
+ warn " Fragment not found in disabled/: #{disabled}"
31
+ exit 1
32
+ end
33
+
34
+ disabled.rename(Paths.site_file(@name))
35
+ @registry.update(Site.new(name: site.name, enabled: true, source_path: site.source_path))
36
+ Caddy.validate!(Paths.caddyfile)
37
+ Caddy.reload(Paths.caddyfile)
38
+ puts " '#{@name}' is up."
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'registry'
4
+ require_relative 'paths'
5
+ require_relative 'parser'
6
+
7
+ module EasyCaddy
8
+ class Conflicts
9
+ Finding = Data.define(:severity, :message, :hint)
10
+
11
+ # Check a fragment file about to be written for domain/port collisions with existing enabled sites.
12
+ # skip_name: the site being updated (exclude it from collision checks against itself).
13
+ def self.check(name:, content:, registry:, skip_name: nil)
14
+ new(name: name, content: content, registry: registry, skip_name: skip_name).check
15
+ end
16
+
17
+ def self.doctor(registry:)
18
+ new(name: nil, content: nil, registry: registry, skip_name: nil).doctor
19
+ end
20
+
21
+ def initialize(name:, content:, registry:, skip_name:)
22
+ @name = name
23
+ @content = content
24
+ @registry = registry
25
+ @skip_name = skip_name
26
+ end
27
+
28
+ def check
29
+ return [] unless @content
30
+
31
+ incoming = Parser.parse(@content)
32
+ findings = []
33
+ findings += domain_conflicts(incoming.domains)
34
+ findings += port_conflicts(incoming.ports)
35
+ findings
36
+ end
37
+
38
+ def doctor
39
+ findings = []
40
+ findings += cross_site_conflicts
41
+ findings += dead_upstream_findings
42
+ findings
43
+ end
44
+
45
+ private
46
+
47
+ def domain_conflicts(incoming_domains)
48
+ existing_domains = enabled_site_data
49
+ .reject { |name, _| name == (@skip_name || @name) }
50
+ .values.flat_map { |d| d[:domains] }
51
+
52
+ (incoming_domains & existing_domains).map do |d|
53
+ owner = enabled_site_data.find { |_, data| data[:domains].include?(d) }&.first
54
+ Finding.new(
55
+ severity: 'BLOCK',
56
+ message: "Domain #{d} is already registered by '#{owner}'.",
57
+ hint: "Run `ecaddy list` to see which project owns this domain."
58
+ )
59
+ end
60
+ end
61
+
62
+ def port_conflicts(incoming_ports)
63
+ findings = []
64
+ existing_ports = enabled_site_data
65
+ .reject { |name, _| name == (@skip_name || @name) }
66
+ .transform_values { |d| d[:ports] }
67
+
68
+ incoming_ports.each do |port|
69
+ existing_ports.each do |owner, ports|
70
+ next unless ports.include?(port)
71
+
72
+ findings << Finding.new(
73
+ severity: 'BLOCK',
74
+ message: "Port #{port} is already used by '#{owner}'.",
75
+ hint: "Choose a different port or run `ecaddy list` to see all ports in use."
76
+ )
77
+ end
78
+ end
79
+ findings
80
+ end
81
+
82
+ def cross_site_conflicts
83
+ findings = []
84
+ seen_ports = {}
85
+ seen_domains = {}
86
+
87
+ enabled_site_data.each do |name, data|
88
+ data[:ports].each do |p|
89
+ if seen_ports[p]
90
+ findings << Finding.new(
91
+ severity: 'BLOCK',
92
+ message: "Port #{p} is shared by '#{seen_ports[p]}' and '#{name}'.",
93
+ hint: "Edit the conflicting Caddyfile and run `ecaddy run` again."
94
+ )
95
+ else
96
+ seen_ports[p] = name
97
+ end
98
+ end
99
+
100
+ data[:domains].each do |d|
101
+ if seen_domains[d]
102
+ findings << Finding.new(
103
+ severity: 'BLOCK',
104
+ message: "Domain #{d} is shared by '#{seen_domains[d]}' and '#{name}'.",
105
+ hint: "Edit the conflicting Caddyfile and run `ecaddy run` again."
106
+ )
107
+ else
108
+ seen_domains[d] = name
109
+ end
110
+ end
111
+ end
112
+ findings
113
+ end
114
+
115
+ def dead_upstream_findings
116
+ @registry.all.flat_map do |site|
117
+ fragment = Paths.site_file(site.name)
118
+ next [] unless fragment.exist?
119
+
120
+ parsed = Parser.parse(fragment.read)
121
+ parsed.ports.filter_map do |port|
122
+ next if tcp_open?(port)
123
+
124
+ Finding.new(
125
+ severity: 'INFO',
126
+ message: "#{site.name}: upstream localhost:#{port} is not listening.",
127
+ hint: "Start your app on port #{port}."
128
+ )
129
+ end
130
+ end
131
+ end
132
+
133
+ # Parse all enabled fragment files once, memoised.
134
+ def enabled_site_data
135
+ @enabled_site_data ||= @registry.all.each_with_object({}) do |site, h|
136
+ frag = Paths.site_file(site.name)
137
+ next unless frag.exist?
138
+
139
+ parsed = Parser.parse(frag.read)
140
+ h[site.name] = { domains: parsed.domains, ports: parsed.ports }
141
+ end
142
+ end
143
+
144
+ def tcp_open?(port)
145
+ require 'socket'
146
+ TCPSocket.new('localhost', port).close
147
+ true
148
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
149
+ false
150
+ end
151
+ end
152
+ end