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,576 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'socket'
5
+ require 'openssl'
6
+ require 'timeout'
7
+ require 'tty-prompt'
8
+ require_relative '../registry'
9
+ require_relative '../paths'
10
+ require_relative '../caddy'
11
+ require_relative '../parser'
12
+ require_relative '../conflicts'
13
+
14
+ module EasyCaddy
15
+ module Commands
16
+ # Prints a full system + TLS + site snapshot with per-domain TLS handshake probes.
17
+ # With fix: true, prompts to apply a remedy for each actionable finding, with
18
+ # automatic escalation to a chained next_fix when the primary fix doesn't resolve it.
19
+ # rubocop:disable Metrics/ClassLength
20
+ class Audit
21
+ # ANSI colours — fall back gracefully if $stdout is not a TTY.
22
+ RED = "\e[31m"
23
+ GREEN = "\e[32m"
24
+ YELLW = "\e[33m"
25
+ RESET = "\e[0m"
26
+
27
+ Fix = Data.define(:label, :description, :command, :verify, :escalation, :next_fix)
28
+
29
+ def initialize(site: nil, fix: false)
30
+ @site_filter = site
31
+ @fix_mode = fix
32
+ @fixes = []
33
+ end
34
+
35
+ def call
36
+ section('SYSTEM')
37
+ print_system
38
+
39
+ section('TLS READINESS')
40
+ print_tls_readiness
41
+
42
+ section('SITES')
43
+ print_sites
44
+
45
+ section('CONFLICTS')
46
+ print_conflicts
47
+
48
+ run_fixes if @fix_mode
49
+ end
50
+
51
+ private
52
+
53
+ # ── formatting helpers ──────────────────────────────────────────────
54
+
55
+ def section(title)
56
+ puts
57
+ puts "── #{title} #{'─' * [0, 60 - title.length].max}"
58
+ end
59
+
60
+ def ok(msg) = puts(" #{GREEN}✓#{RESET} #{msg}")
61
+ def info(msg) = puts(" #{msg}")
62
+
63
+ # rubocop:disable Metrics/MethodLength
64
+ def fail(msg, hint: nil, fix: nil)
65
+ puts " #{RED}✗#{RESET} #{msg}"
66
+ puts " hint: #{hint}" if hint
67
+ return unless fix
68
+
69
+ @fixes << Fix.new(
70
+ label: msg,
71
+ description: fix[:description],
72
+ command: fix[:command],
73
+ verify: fix[:verify],
74
+ escalation: fix[:escalation],
75
+ next_fix: fix[:next_fix]
76
+ )
77
+ end
78
+ # rubocop:enable Metrics/MethodLength
79
+
80
+ # rubocop:disable Metrics/MethodLength
81
+ def warn(msg, hint: nil, fix: nil)
82
+ puts " #{YELLW}!#{RESET} #{msg}"
83
+ puts " hint: #{hint}" if hint
84
+ return unless fix
85
+
86
+ @fixes << Fix.new(
87
+ label: msg,
88
+ description: fix[:description],
89
+ command: fix[:command],
90
+ verify: fix[:verify],
91
+ escalation: fix[:escalation],
92
+ next_fix: fix[:next_fix]
93
+ )
94
+ end
95
+ # rubocop:enable Metrics/MethodLength
96
+
97
+ # ── system section ──────────────────────────────────────────────────
98
+
99
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
100
+ def print_system
101
+ if Caddy.installed?
102
+ version = `caddy version 2>/dev/null`.strip
103
+ ok("Caddy installed: #{version}")
104
+ else
105
+ fail('Caddy not installed',
106
+ hint: 'Install via Homebrew.',
107
+ fix: {
108
+ description: 'Install Caddy',
109
+ command: 'brew install caddy',
110
+ verify: -> { Caddy.installed? },
111
+ escalation: 'Install still failed. Check `brew doctor`, or install manually from caddyserver.com.',
112
+ next_fix: nil
113
+ })
114
+ end
115
+
116
+ print_service_status
117
+
118
+ caddyfile = Paths.caddyfile
119
+ if caddyfile.exist?
120
+ ok("Global Caddyfile: #{caddyfile}")
121
+ else
122
+ fail("Global Caddyfile missing: #{caddyfile}",
123
+ hint: 'Run `ecaddy setup` to scaffold the global config.',
124
+ fix: {
125
+ description: 'Scaffold global config',
126
+ command: 'ecaddy setup',
127
+ verify: -> { Paths.caddyfile.exist? },
128
+ escalation: 'Setup didn\'t write the Caddyfile. Re-run `ecaddy setup` and watch for errors.',
129
+ next_fix: nil
130
+ })
131
+ end
132
+ brew_link = Paths.brew_caddyfile
133
+ if brew_link.symlink? && brew_link.readlink == caddyfile
134
+ ok("Brew symlink: #{brew_link} → #{caddyfile}")
135
+ elsif brew_link.exist?
136
+ warn("Brew symlink #{brew_link} points elsewhere",
137
+ hint: 'Run `ecaddy setup` to fix the symlink.',
138
+ fix: {
139
+ description: 'Fix brew symlink',
140
+ command: 'ecaddy setup',
141
+ verify: -> { Paths.brew_caddyfile.symlink? && Paths.brew_caddyfile.readlink == Paths.caddyfile },
142
+ escalation: 'Symlink still wrong. Remove it manually ' \
143
+ '(`rm /opt/homebrew/etc/Caddyfile`) and re-run `ecaddy setup`.',
144
+ next_fix: nil
145
+ })
146
+ else
147
+ fail("Brew symlink missing: #{brew_link}",
148
+ hint: 'Run `ecaddy setup` to create the symlink.',
149
+ fix: {
150
+ description: 'Create brew symlink',
151
+ command: 'ecaddy setup',
152
+ verify: -> { Paths.brew_caddyfile.symlink? && Paths.brew_caddyfile.readlink == Paths.caddyfile },
153
+ escalation: 'Symlink still missing after setup. Re-run `ecaddy setup` and watch for errors.',
154
+ next_fix: nil
155
+ })
156
+ end
157
+ end
158
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
159
+
160
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
161
+ def print_service_status
162
+ brew_pid = Caddy.brew_service_pid
163
+ proc_pid = Caddy.process_pid
164
+
165
+ if brew_pid
166
+ ok("brew service running (PID #{brew_pid})")
167
+ elsif proc_pid
168
+ warn("brew service not started, but Caddy process #{proc_pid} is running",
169
+ hint: 'Caddy was started outside brew (e.g. `caddy run` or sudo). ' \
170
+ 'Ports :443/:80 will work, but it won\'t restart automatically. ' \
171
+ 'To switch to the brew-managed service: stop it and run `brew services start caddy`.',
172
+ fix: {
173
+ description: 'Stop external Caddy + start brew service',
174
+ command: "pkill -f 'caddy run'; brew services start caddy",
175
+ verify: -> { Caddy.brew_service_pid },
176
+ escalation: 'Brew service still not running — external Caddy may have ignored SIGTERM.',
177
+ next_fix: Fix.new(
178
+ label: 'External Caddy did not stop',
179
+ description: 'Force-kill external Caddy and restart via sudo',
180
+ command: "sudo pkill -9 -f 'caddy run'; sudo brew services restart caddy",
181
+ verify: -> { port_open?(443) || Caddy.brew_service_pid },
182
+ escalation: 'Still not running. Check `brew services info caddy`.',
183
+ next_fix: nil
184
+ )
185
+ })
186
+ else
187
+ fail('Caddy is not running',
188
+ hint: 'Start the brew service: `brew services start caddy`.',
189
+ fix: {
190
+ description: 'Start Caddy via brew',
191
+ command: 'brew services start caddy',
192
+ verify: -> { Caddy.brew_service_pid },
193
+ escalation: 'Service still not up — brew user-mode may lack permission to bind low ports.',
194
+ next_fix: Fix.new(
195
+ label: 'Caddy still not running — trying with elevated privileges',
196
+ description: 'Start Caddy via sudo (creates a root LaunchDaemon, binds low ports)',
197
+ command: 'brew services stop caddy; sudo brew services start caddy',
198
+ verify: -> { port_open?(443) || Caddy.brew_service_pid },
199
+ escalation: 'Still not running. Check `brew services info caddy`.',
200
+ next_fix: nil
201
+ )
202
+ })
203
+ end
204
+ end
205
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
206
+
207
+ # ── TLS readiness section ───────────────────────────────────────────
208
+
209
+ # rubocop:disable Metrics/MethodLength
210
+ def print_tls_readiness
211
+ if caddy_ca_trusted?
212
+ ok('Caddy Local Authority found in system keychain')
213
+ else
214
+ fail('Caddy Local Authority not found in keychain',
215
+ hint: 'Run `caddy trust` to install the local root CA. ' \
216
+ 'Without it, browsers show ERR_SSL_PROTOCOL_ERROR or NET::ERR_CERT_AUTHORITY_INVALID.',
217
+ fix: {
218
+ description: 'Trust Caddy local CA',
219
+ command: 'caddy trust',
220
+ verify: -> { caddy_ca_trusted? },
221
+ escalation: 'CA not installed — installation into System keychain requires admin.',
222
+ next_fix: Fix.new(
223
+ label: 'Caddy CA still not trusted — trying with sudo',
224
+ description: 'Install CA into System keychain via sudo',
225
+ command: 'sudo caddy trust',
226
+ verify: -> { caddy_ca_trusted? },
227
+ escalation: 'CA still not in keychain. Check Keychain Access for \'Caddy Local Authority\'.',
228
+ next_fix: nil
229
+ )
230
+ })
231
+ end
232
+
233
+ check_port(443, 'HTTPS (:443)')
234
+ check_port(80, 'HTTP (:80)')
235
+ end
236
+ # rubocop:enable Metrics/MethodLength
237
+
238
+ def caddy_ca_trusted?
239
+ # `find-certificate -p` dumps PEM; `verify-cert -p ssl` checks trust settings —
240
+ # unlike bare `find-certificate` which only checks for presence.
241
+ system(
242
+ 'security find-certificate -c "Caddy Local Authority" -p ' \
243
+ '/Library/Keychains/System.keychain 2>/dev/null | ' \
244
+ 'security verify-cert -c /dev/stdin -p ssl > /dev/null 2>&1'
245
+ )
246
+ end
247
+
248
+ def browser_trusts?(domain)
249
+ # curl on macOS uses Secure Transport (same trust store as Chrome/Safari).
250
+ # --resolve avoids DNS edge cases with .localhost
251
+ system(
252
+ "curl --silent --show-error --max-time 2 --resolve #{domain}:443:127.0.0.1 " \
253
+ "-o /dev/null https://#{domain}/ 2>/dev/null"
254
+ )
255
+ end
256
+
257
+ def port_open?(port)
258
+ Timeout.timeout(0.5) { TCPSocket.new('127.0.0.1', port).close }
259
+ true
260
+ rescue StandardError
261
+ false
262
+ end
263
+
264
+ # rubocop:disable Metrics/MethodLength
265
+ def check_port(port, label)
266
+ if port_open?(port)
267
+ ok("#{label} is bound")
268
+ else
269
+ fail("#{label} is NOT bound",
270
+ hint: 'Caddy may need `sudo` to bind low ports, or is not running.',
271
+ fix: {
272
+ description: "Restart Caddy (to bind #{label})",
273
+ command: 'brew services restart caddy',
274
+ verify: -> { port_open?(port) },
275
+ escalation: "Port :#{port} still not bound — brew user-mode cannot bind ports below 1024.",
276
+ next_fix: Fix.new(
277
+ label: "#{label} still not bound — needs elevated privileges",
278
+ description: 'Stop user-mode Caddy and start as root (binds low ports). ' \
279
+ 'Prompts for admin password.',
280
+ command: 'brew services stop caddy; sudo brew services restart caddy',
281
+ verify: -> { port_open?(port) },
282
+ escalation: "Still not bound. Another process may own :#{port} — " \
283
+ "check `sudo lsof -nP -i :#{port}`.",
284
+ next_fix: nil
285
+ )
286
+ })
287
+ end
288
+ end
289
+ # rubocop:enable Metrics/MethodLength
290
+
291
+ # ── sites section ───────────────────────────────────────────────────
292
+
293
+ def print_sites
294
+ registry = Registry.load
295
+ sites = registry.all
296
+ sites = sites.select { |s| s.name == @site_filter } if @site_filter
297
+
298
+ if sites.empty?
299
+ info(@site_filter ? "No site '#{@site_filter}' in registry." : 'No sites registered.')
300
+ return
301
+ end
302
+
303
+ sites.each { |s| print_site(s) }
304
+ end
305
+
306
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
307
+ def print_site(site)
308
+ puts
309
+ puts " Site: #{site.name} [#{site.enabled ? 'enabled' : 'disabled'}]"
310
+ info "source: #{site.source_path || '(none)'}"
311
+
312
+ fragment = site.enabled ? Paths.site_file(site.name) : Paths.disabled_file(site.name)
313
+ unless fragment.exist?
314
+ re_register_cmd = site.source_path ? "ecaddy ensure -c #{site.source_path} -s #{site.name}" : nil
315
+ fail("fragment missing: #{fragment}",
316
+ hint: 'Fragment file was deleted. Re-register from source.',
317
+ fix: if re_register_cmd
318
+ {
319
+ description: 'Re-register from source',
320
+ command: re_register_cmd,
321
+ verify: -> { fragment.exist? },
322
+ escalation: 'Re-register didn\'t write the fragment. ' \
323
+ 'Check the source path exists and is readable.',
324
+ next_fix: nil
325
+ }
326
+ end)
327
+ return
328
+ end
329
+
330
+ info "fragment: #{fragment}"
331
+ parsed = Parser.parse(File.read(fragment))
332
+
333
+ print_domains(parsed.domains)
334
+ print_upstreams(parsed.ports)
335
+ print_log_files(parsed.log_paths)
336
+ end
337
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
338
+
339
+ # rubocop:disable Metrics/MethodLength
340
+ def print_domains(domains)
341
+ if domains.empty?
342
+ warn 'No .localhost domains found in fragment'
343
+ return
344
+ end
345
+
346
+ domains.each do |domain|
347
+ handshake_ok, detail = tls_probe(domain)
348
+ unless handshake_ok
349
+ hint, fix = tls_hint_and_fix(detail, domain)
350
+ fail("#{domain} — TLS ✗ #{detail}", hint: hint, fix: fix)
351
+ next
352
+ end
353
+
354
+ if browser_trusts?(domain)
355
+ ok("#{domain} — TLS ✓ browser-trusted ✓ #{detail}")
356
+ else
357
+ fail("#{domain} — TLS ✓ browser-trust ✗ (Chrome will show ERR_CERT_AUTHORITY_INVALID)",
358
+ hint: 'Caddy CA is not installed into the System keychain with SSL trust. ' \
359
+ 'Run `sudo caddy trust`.',
360
+ fix: browser_trust_fix(domain))
361
+ end
362
+ end
363
+ end
364
+
365
+ def browser_trust_fix(domain)
366
+ {
367
+ description: 'Install Caddy CA into System keychain',
368
+ command: 'caddy trust',
369
+ verify: -> { browser_trusts?(domain) },
370
+ escalation: 'CA still not browser-trusted — System-keychain install requires admin.',
371
+ next_fix: Fix.new(
372
+ label: "#{domain} browser-trust still failing — needs sudo",
373
+ description: 'Install CA into System keychain via sudo (browsers will honor it)',
374
+ command: 'sudo caddy trust',
375
+ verify: -> { browser_trusts?(domain) },
376
+ escalation: 'Still not trusted. Open Keychain Access → System → search "Caddy Local Authority" → ' \
377
+ 'set Trust → "When using this certificate: Always Trust".',
378
+ next_fix: nil
379
+ )
380
+ }
381
+ end
382
+
383
+ def print_upstreams(ports)
384
+ if ports.empty?
385
+ info 'No reverse_proxy upstreams found'
386
+ else
387
+ ports.each do |port|
388
+ if tcp_open?(port)
389
+ ok("upstream localhost:#{port} — listening")
390
+ else
391
+ warn("upstream localhost:#{port} — NOT listening",
392
+ hint: "Start your app on port #{port}.")
393
+ end
394
+ end
395
+ end
396
+ end
397
+
398
+ def print_log_files(log_paths)
399
+ if log_paths.empty?
400
+ info 'No log files configured (add a log { output file … } block)'
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
409
+ end
410
+ end
411
+ # rubocop:enable Metrics/MethodLength
412
+
413
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
414
+ def tls_hint_and_fix(detail, domain = nil)
415
+ case detail
416
+ when /connection refused/
417
+ hint = 'Caddy is not listening on :443. Start the service.'
418
+ fix = {
419
+ description: 'Start Caddy',
420
+ command: 'brew services start caddy',
421
+ verify: -> { port_open?(443) },
422
+ escalation: 'Caddy still not on :443 — see the port-binding hint above.',
423
+ next_fix: nil
424
+ }
425
+ when /internal error|alert 80/
426
+ hint = 'Caddy aborted the TLS handshake — usually stale on-demand issuance state. ' \
427
+ 'Try a reload; if that fails, restart Caddy.'
428
+ fix = {
429
+ description: 'Reload Caddy config',
430
+ command: "caddy reload --config #{Paths.brew_caddyfile}",
431
+ verify: domain ? -> { tls_probe(domain).first } : nil,
432
+ escalation: 'Reload didn\'t clear it. Try `brew services restart caddy`. ' \
433
+ 'If still failing, the on-demand cert store may be corrupt — ' \
434
+ 'see `~/Library/Application Support/Caddy/pki/`.',
435
+ next_fix: nil
436
+ }
437
+ when /unknown ca|certificate|authority/i
438
+ hint = "Caddy's local CA is not trusted by this machine."
439
+ fix = {
440
+ description: 'Trust Caddy local CA',
441
+ command: 'caddy trust',
442
+ verify: domain ? -> { browser_trusts?(domain) } : -> { caddy_ca_trusted? },
443
+ escalation: 'CA not installed — installation into System keychain requires admin.',
444
+ next_fix: Fix.new(
445
+ label: 'Caddy CA still not trusted — trying with sudo',
446
+ description: 'Install CA into System keychain via sudo',
447
+ command: 'sudo caddy trust',
448
+ verify: domain ? -> { browser_trusts?(domain) } : -> { caddy_ca_trusted? },
449
+ escalation: 'CA still not in keychain. Check Keychain Access for \'Caddy Local Authority\'.',
450
+ next_fix: nil
451
+ )
452
+ }
453
+ else
454
+ hint = nil
455
+ fix = nil
456
+ end
457
+ [hint, fix]
458
+ end
459
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
460
+
461
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
462
+ def tls_probe(domain)
463
+ Timeout.timeout(1) do
464
+ tcp = TCPSocket.new('localhost', 443)
465
+ ctx = OpenSSL::SSL::SSLContext.new
466
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
467
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
468
+ ssl.hostname = domain
469
+ ssl.sync_close = true
470
+ ssl.connect
471
+ cn = ssl.peer_cert&.subject&.to_a&.find { |name, _| name == 'CN' }&.at(1) || '?'
472
+ ssl.close
473
+ [true, "cert CN=#{cn}"]
474
+ end
475
+ rescue Errno::ECONNREFUSED
476
+ [false, 'connection refused on :443 (Caddy not running or not bound)']
477
+ rescue StandardError => e
478
+ [false, e.message.split("\n").first]
479
+ end
480
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
481
+
482
+ def tcp_open?(port)
483
+ TCPSocket.new('localhost', port).close
484
+ true
485
+ rescue StandardError
486
+ false
487
+ end
488
+
489
+ def humanize_bytes(bytes)
490
+ return "#{bytes} B" if bytes < 1024
491
+ return "#{(bytes / 1024.0).round(1)} KB" if bytes < 1_048_576
492
+
493
+ "#{(bytes / 1_048_576.0).round(1)} MB"
494
+ end
495
+
496
+ # ── conflicts section ───────────────────────────────────────────────
497
+
498
+ # rubocop:disable Metrics/MethodLength
499
+ def print_conflicts
500
+ registry = Registry.load
501
+ findings = Conflicts.doctor(registry: registry)
502
+
503
+ if findings.empty?
504
+ ok('No conflicts or dead upstreams detected')
505
+ return
506
+ end
507
+
508
+ findings.each do |f|
509
+ case f.severity
510
+ when 'BLOCK' then fail("#{f.message} Hint: #{f.hint}")
511
+ when 'WARN' then warn("#{f.message} Hint: #{f.hint}")
512
+ else info("#{f.message} Hint: #{f.hint}")
513
+ end
514
+ end
515
+ end
516
+ # rubocop:enable Metrics/MethodLength
517
+
518
+ # ── fix loop ────────────────────────────────────────────────────────
519
+
520
+ def run_fixes
521
+ return if @fixes.empty?
522
+
523
+ prompt = TTY::Prompt.new
524
+ @applied_commands = []
525
+
526
+ section("FIXES (#{@fixes.length})")
527
+ @fixes.each { |fix| run_fix(fix, prompt) }
528
+ end
529
+
530
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
531
+ def run_fix(fix, prompt)
532
+ if fix.verify&.call
533
+ puts
534
+ puts " #{GREEN}✓#{RESET} #{fix.label} — already resolved"
535
+ return
536
+ end
537
+
538
+ puts
539
+ puts " Issue: #{fix.label}"
540
+ puts " Fix: #{fix.description}"
541
+ puts " Command: #{fix.command}"
542
+ return unless prompt.yes?(' Apply?')
543
+
544
+ if @applied_commands.include?(fix.command)
545
+ puts " #{YELLW}!#{RESET} already ran this command in this session — re-checking..."
546
+ else
547
+ ran = system(fix.command)
548
+ unless ran
549
+ puts " #{RED}✗#{RESET} command failed to run"
550
+ puts " next: #{fix.escalation}" if fix.escalation
551
+ run_fix(fix.next_fix, prompt) if fix.next_fix
552
+ return
553
+ end
554
+
555
+ @applied_commands << fix.command
556
+ end
557
+
558
+ if fix.verify.nil?
559
+ puts " #{GREEN}✓#{RESET} applied"
560
+ return
561
+ end
562
+
563
+ sleep 0.5
564
+ if fix.verify.call
565
+ puts " #{GREEN}✓#{RESET} applied and verified"
566
+ else
567
+ puts " #{YELLW}!#{RESET} applied, but the issue is still present"
568
+ puts " next: #{fix.escalation}" if fix.escalation
569
+ run_fix(fix.next_fix, prompt) if fix.next_fix
570
+ end
571
+ end
572
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
573
+ end
574
+ # rubocop:enable Metrics/ClassLength
575
+ end
576
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../registry'
4
+ require_relative '../conflicts'
5
+
6
+ module EasyCaddy
7
+ module Commands
8
+ class Doctor
9
+ def call
10
+ registry = Registry.load
11
+ findings = Conflicts.doctor(registry: registry)
12
+
13
+ if findings.empty?
14
+ puts ' All clear — no conflicts or dead upstreams detected.'
15
+ return
16
+ end
17
+
18
+ has_block = false
19
+ findings.each do |f|
20
+ label = case f.severity
21
+ when 'BLOCK' then "\e[31mBLOCK\e[0m"
22
+ when 'WARN' then "\e[33mWARN \e[0m"
23
+ else "\e[34mINFO \e[0m"
24
+ end
25
+ puts " #{label} #{f.message}"
26
+ puts " → #{f.hint}"
27
+ has_block = true if f.severity == 'BLOCK'
28
+ end
29
+
30
+ exit 1 if has_block
31
+ end
32
+ end
33
+ end
34
+ 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 Down
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 down."
25
+ return
26
+ end
27
+
28
+ active = Paths.site_file(@name)
29
+ unless active.exist?
30
+ warn " Fragment not found in sites/: #{active}"
31
+ exit 1
32
+ end
33
+
34
+ Paths.disabled_dir.mkpath
35
+ active.rename(Paths.disabled_file(@name))
36
+ @registry.update(Site.new(name: site.name, enabled: false, source_path: site.source_path))
37
+ Caddy.reload(Paths.caddyfile)
38
+ puts " '#{@name}' is down. Run `ecaddy up #{@name}` to bring it back."
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
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 Edit
10
+ def initialize(name:)
11
+ @name = name.downcase
12
+ @registry = Registry.load
13
+ end
14
+
15
+ def call
16
+ site = @registry.find(@name)
17
+ unless site
18
+ warn " Site '#{@name}' is not registered."
19
+ exit 1
20
+ end
21
+
22
+ file = site.enabled ? Paths.site_file(@name) : Paths.disabled_file(@name)
23
+ unless file.exist?
24
+ warn " Fragment file not found: #{file}"
25
+ exit 1
26
+ end
27
+
28
+ editor = ENV.fetch('EDITOR', 'vi')
29
+ system("#{editor} #{file}")
30
+ Caddy.validate!(Paths.caddyfile)
31
+ Caddy.reload(Paths.caddyfile) if site.enabled
32
+ puts " Saved and reloaded."
33
+ end
34
+ end
35
+ end
36
+ end