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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +333 -0
- data/exe/ecaddy +6 -0
- data/lib/easy_caddy/caddy.rb +69 -0
- data/lib/easy_caddy/cli.rb +107 -0
- data/lib/easy_caddy/commands/audit.rb +576 -0
- data/lib/easy_caddy/commands/doctor.rb +34 -0
- data/lib/easy_caddy/commands/down.rb +42 -0
- data/lib/easy_caddy/commands/edit.rb +36 -0
- data/lib/easy_caddy/commands/ensure.rb +22 -0
- data/lib/easy_caddy/commands/list.rb +53 -0
- data/lib/easy_caddy/commands/logs.rb +63 -0
- data/lib/easy_caddy/commands/register_helpers.rb +116 -0
- data/lib/easy_caddy/commands/reload.rb +16 -0
- data/lib/easy_caddy/commands/remove.rb +41 -0
- data/lib/easy_caddy/commands/run.rb +35 -0
- data/lib/easy_caddy/commands/setup.rb +104 -0
- data/lib/easy_caddy/commands/status.rb +39 -0
- data/lib/easy_caddy/commands/up.rb +42 -0
- data/lib/easy_caddy/conflicts.rb +152 -0
- data/lib/easy_caddy/parser.rb +39 -0
- data/lib/easy_caddy/paths.rb +18 -0
- data/lib/easy_caddy/registry.rb +49 -0
- data/lib/easy_caddy/site.rb +13 -0
- data/lib/easy_caddy/version.rb +5 -0
- data/lib/easy_caddy.rb +13 -0
- metadata +173 -0
|
@@ -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
|