yamine 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.
@@ -0,0 +1,768 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamine
4
+ class CLI
5
+ # System commands: proxy, service, hosts, trust, clean, doctor, kamal.
6
+ module SystemCommand
7
+ module_function
8
+
9
+ def doctor(ctx, args)
10
+ json = args.delete("--json")
11
+ failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout, json: !!json)
12
+ exit(failed.zero? ? 0 : 1)
13
+ end
14
+
15
+ def trust(_ctx, _args)
16
+ result = Trust.trust
17
+ if result[:trusted]
18
+ puts "CA trusted."
19
+ else
20
+ $stderr.puts "Error: #{result[:error]}"
21
+ exit 1
22
+ end
23
+ end
24
+
25
+ def clean(ctx, _args)
26
+ ProxyControl.stop(ctx.store)
27
+ result = Trust.untrust
28
+ puts "CA removed from trust store." if result[:removed]
29
+ warn "CA untrust failed: #{result[:error]}" if result[:error]
30
+ Hosts.clean
31
+ require "fileutils"
32
+ FileUtils.rm_rf(ctx.store.dir)
33
+ puts "Cleaned yamine state."
34
+ end
35
+
36
+ def hosts(ctx, args)
37
+ sub = args.first
38
+ case sub
39
+ when "sync"
40
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
41
+ if Hosts.sync(hostnames)
42
+ puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
43
+ elsif !ProxyControl.root? && !hostnames.empty?
44
+ # /etc/hosts is root-owned; once the root service is
45
+ # installed the guidance is "run yamine hosts sync" — so
46
+ # make that command work by re-running it elevated.
47
+ puts "Writing /etc/hosts needs root — re-running elevated..."
48
+ state = Certs.state_dir
49
+ cmd = ["env", "YAMINE_STATE_DIR=#{state}", RbConfig.ruby,
50
+ ProxyControl.bin_path, "hosts", "sync"]
51
+ exit(elevate(cmd) ? 0 : 1)
52
+ else
53
+ $stderr.puts "Could not write /etc/hosts (try sudo)."
54
+ exit 1
55
+ end
56
+ when "clean"
57
+ Hosts.clean
58
+ puts "Removed yamine entries from /etc/hosts."
59
+ else
60
+ raise Error, "Usage: yamine hosts [sync|clean]"
61
+ end
62
+ end
63
+
64
+ def proxy(ctx, args)
65
+ sub = args.first
66
+ case sub
67
+ when "start"
68
+ port, tls, foreground = parse_proxy_start(args[1..])
69
+ tlds = active_tlds(args[1..])
70
+ if foreground
71
+ write_tls_marker(ctx, tls)
72
+ write_tlds_file(ctx, tlds)
73
+ sup = Supervisor.new(store: ctx.store, runner: Runner.new(store: ctx.store),
74
+ on_event: ->(m) { warn m })
75
+ sup.start
76
+ Proxy.new(store: ctx.store, port: port, tls: tls,
77
+ state_dir: ctx.store.dir, supervisor: sup, tlds: tlds).start_foreground
78
+ else
79
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, tlds: tlds)
80
+ puts "Proxy started on port #{port}#{tls ? " (HTTPS)" : " (HTTP)"}."
81
+ end
82
+ when "stop"
83
+ case ProxyControl.stop(ctx.store)
84
+ when :stopped then puts "Proxy stopped."
85
+ when :stale then puts "Removed stale proxy state."
86
+ when :not_running then puts "Proxy is not running."
87
+ when :unknown_process then puts "Port in use by an unknown process."
88
+ end
89
+ else
90
+ raise Error, "Usage: yamine proxy [start|stop]"
91
+ end
92
+ end
93
+
94
+ def write_tls_marker(ctx, tls)
95
+ ctx.store.ensure_dir
96
+ path = File.join(ctx.store.dir, "proxy.tls")
97
+ tls ? File.write(path, "1") : File.write(path, "0")
98
+ end
99
+
100
+ # TLDs the proxy serves: --tld flags, else YAMINE_TLD, else
101
+ # localhost. Persisted so auto-restarted daemons agree, and so
102
+ # the 404 page knows which hosts are "ours" (rebinding boundary).
103
+ def active_tlds(args)
104
+ flags = []
105
+ i = 0
106
+ while i < args.length
107
+ if args[i] == "--tld"
108
+ flags << args.fetch(i + 1).to_s
109
+ i += 2
110
+ else
111
+ i += 1
112
+ end
113
+ end
114
+ list = flags.any? ? flags : (ENV["YAMINE_TLD"]&.split(",")&.map(&:strip) || [])
115
+ list = list.reject(&:empty?).map(&:downcase).uniq
116
+ list.empty? ? [Hostname::DEFAULT_TLD] : list
117
+ end
118
+
119
+ def write_tlds_file(ctx, tlds)
120
+ ctx.store.ensure_dir
121
+ File.write(File.join(ctx.store.dir, "proxy.tlds"), "#{tlds.join("\n")}\n")
122
+ rescue SystemCallError
123
+ nil
124
+ end
125
+
126
+ def parse_proxy_start(args)
127
+ port = nil
128
+ tls = true
129
+ foreground = false
130
+ i = 0
131
+ while i < args.length
132
+ case args[i]
133
+ when "-p", "--port" then port = args.fetch(i + 1).to_i; i += 2
134
+ when "--no-tls" then tls = false; i += 1
135
+ when "--https" then tls = true; i += 1
136
+ when "--foreground" then foreground = true; i += 1
137
+ when "--tld" then i += 2 # consumed by active_tlds
138
+ else i += 1
139
+ end
140
+ end
141
+ [port || ProxyControl.default_port(tls), tls, foreground]
142
+ end
143
+
144
+ def service(ctx, args)
145
+ sub = args.first
146
+ case sub
147
+ when "install" then exit(service_install(ctx, args) ? 0 : 1)
148
+ when "uninstall" then exit(service_uninstall(ctx) ? 0 : 1)
149
+ when "status" then service_status(ctx)
150
+ else raise Error, "Usage: yamine service [install|uninstall|status]"
151
+ end
152
+ end
153
+
154
+ # Print the scoped passwordless-sudo rules that let `service install`
155
+ # (and only it) run without a prompt. The service re-execs the whole
156
+ # gem under sudo, so the safe NOPASSWD grants exactly the gem path +
157
+ # subcommand for the current user — never a bare interpreter. This is
158
+ # how agents and repeat machines get clean :443 without a TTY.
159
+ #
160
+ # macOS: sudo install -o root -g wheel -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
161
+ # Linux: sudo install -o root -g root -m 440 <(yamine sudoers) /etc/sudoers.d/yamine
162
+ def sudoers(_ctx, _args)
163
+ require "etc"
164
+ ruby = RbConfig.ruby
165
+ bin = ProxyControl.bin_path
166
+ user = ENV.fetch("USER", Etc.getlogin)
167
+ puts <<~SUDOERS
168
+ # yamine: let #{user} install/run the privileged proxy on port 443
169
+ # without a password prompt. Scoped to yamine's own service
170
+ # re-exec — the gem path above, not a bare interpreter.
171
+ #{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service install --internal
172
+ #{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service uninstall --internal
173
+ SUDOERS
174
+ end
175
+
176
+ # Root-owned LaunchDaemon binding 80/443 at boot (puma-dev model).
177
+ # Non-root runs re-exec under sudo once (--internal marks the root
178
+ # half); the proxy runs with the invoking user's state dir so
179
+ # routes registered by unprivileged CLIs are shared. The root half
180
+ # can also write /etc/hosts.
181
+ #
182
+ # Non-interactive runs (agents, CI) use `sudo -n`: never prompts,
183
+ # succeeds only when the scoped NOPASSWD grant from `yamine
184
+ # sudoers` is installed, and fails fast with guidance otherwise.
185
+ # Interactive runs use plain sudo (one password, then the service
186
+ # is installed for good).
187
+ #
188
+ # Returns true when the service is installed. No exit here: the
189
+ # bare `service install` CLI exits in `service`, while `setup`
190
+ # keeps going (hosts sync, doctor) after a successful install.
191
+ def service_install(ctx, args)
192
+ if ProxyControl.root?
193
+ install_service!(ctx)
194
+ elsif args.include?("--internal")
195
+ raise Error, "`service install --internal` is the root half of the sudo re-exec — run `yamine service install`"
196
+ else
197
+ puts "Installing system service (sudo required)..."
198
+ state = Certs.state_dir
199
+ cmd = ["env", "YAMINE_STATE_DIR=#{state}",
200
+ RbConfig.ruby, ProxyControl.bin_path,
201
+ "service", "install", "--internal"]
202
+ elevate(cmd)
203
+ end
204
+ end
205
+
206
+ def install_service!(ctx)
207
+ case RUBY_PLATFORM
208
+ when /darwin/ then install_launchd(ctx)
209
+ when /linux/ then install_systemd
210
+ else raise Error, "Service install not supported on #{RUBY_PLATFORM}"
211
+ end
212
+ # Root can write /etc/hosts, so sync the routes registered so
213
+ # far while elevated — Safari works the moment setup finishes
214
+ # (Chrome resolves *.localhost natively).
215
+ sync_hosts_from_routes(ctx)
216
+ true
217
+ end
218
+
219
+ def sync_hosts_from_routes(ctx)
220
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
221
+ if hostnames.empty?
222
+ puts " No routes registered yet — hosts sync will happen on the next boot."
223
+ elsif Yamine::Hosts.sync(hostnames)
224
+ puts " Synced #{hostnames.length} hostname(s) to /etc/hosts."
225
+ else
226
+ warn " could not write /etc/hosts (run `sudo yamine hosts sync` later)"
227
+ end
228
+ end
229
+
230
+ # Run a privileged command via sudo. Interactive: plain sudo (one
231
+ # prompt). Non-interactive: `sudo -n` — no prompt ever; requires the
232
+ # NOPASSWD grant from `yamine sudoers`. On failure prints the
233
+ # provisioning hint so agents/CI know exactly what to install.
234
+ def elevate(cmd)
235
+ interactive = $stdin.tty? && ENV["CI"].nil?
236
+ sudo_args = interactive ? ["sudo"] : ["sudo", "-n"]
237
+ ok = Command.run(*sudo_args, *cmd)
238
+ return true if ok
239
+
240
+ # An interactive sudo failure is auth or the elevated command
241
+ # itself (whose error is already on screen) — re-run and read it.
242
+ # The scoped-grant hint only helps passwordless non-interactive
243
+ # runs, where a missing NOPASSWD rule is the usual cause.
244
+ if interactive
245
+ $stderr.puts "sudo failed — re-run `yamine setup` to try again."
246
+ else
247
+ $stderr.puts "sudo failed — install the scoped grant once:"
248
+ $stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
249
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
250
+ end
251
+ false
252
+ end
253
+
254
+ def user_home_for_service
255
+ sudo_user = ENV["SUDO_USER"]
256
+ if sudo_user && !sudo_user.empty?
257
+ require "etc"
258
+ Etc.getpwnam(sudo_user).dir
259
+ else
260
+ Certs.home
261
+ end
262
+ rescue ArgumentError
263
+ Certs.home
264
+ end
265
+
266
+ def install_launchd(ctx)
267
+ require "etc"
268
+ home = user_home_for_service
269
+ state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
270
+ dir = "/Library/LaunchDaemons"
271
+ FileUtils.mkdir_p(dir)
272
+ plist = <<~PLIST
273
+ <?xml version="1.0" encoding="UTF-8"?>
274
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
275
+ <plist version="1.0">
276
+ <dict>
277
+ <key>Label</key><string>dev.ask.local</string>
278
+ <key>ProgramArguments</key>
279
+ <array>
280
+ <string>#{RbConfig.ruby}</string>
281
+ <string>#{ProxyControl.bin_path}</string>
282
+ <string>proxy</string><string>start</string><string>--foreground</string>
283
+ </array>
284
+ <key>EnvironmentVariables</key>
285
+ <dict>
286
+ <key>YAMINE_STATE_DIR</key><string>#{state_dir}</string>
287
+ <key>HOME</key><string>#{home}</string>
288
+ </dict>
289
+ <key>KeepAlive</key><true/>
290
+ <key>RunAtLoad</key><true/>
291
+ </dict>
292
+ </plist>
293
+ PLIST
294
+ path = File.join(dir, "dev.ask.local.plist")
295
+ File.write(path, plist)
296
+ File.chmod(0o644, path)
297
+ # launchd requires /Library/LaunchDaemons plists to be
298
+ # root-owned; we are root here (sudo re-exec). Enforce it
299
+ # explicitly: File.write keeps an existing file's owner, so a
300
+ # stale user-owned plist from an older version would otherwise
301
+ # survive the overwrite and bootstrap fails with error 5.
302
+ # Trust the CA into the System keychain while elevated: silent
303
+ # (no GUI popup) and trusted for every user on the machine.
304
+ File.chown(0, 0, path) if Process.uid.zero?
305
+ ensure_system_ca_trust
306
+ puts " Registering the launchd service on port 443..."
307
+ launchctl_bootstrap(path)
308
+ puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
309
+ end
310
+
311
+ # Root-only CA trust: the System keychain (all users, no prompt).
312
+ # Safe to call repeatedly — once the marker is set it is a no-op.
313
+ # Prints before doing work: the System keychain add can take a few
314
+ # seconds, and this runs in the root half of setup where silence
315
+ # reads as a hang.
316
+ def ensure_system_ca_trust
317
+ return if Certs.trusted?(Certs.state_dir)
318
+
319
+ puts " Trusting the CA into the System keychain..."
320
+ result = Trust.trust
321
+ warn " CA trust warning: #{result[:error]}" unless result[:trusted]
322
+ end
323
+
324
+ # Modern launchctl system-domain verbs. The legacy `launchctl load`
325
+ # is rejected by current macOS with error 5 (Input/output error) —
326
+ # and worse, it can print that error while still exiting 0, so the
327
+ # old code "succeeded" without the service actually running.
328
+ # bootstrap/bootout are the supported verbs (same as puma-dev and
329
+ # portless); extracted so the command sequence is unit-testable.
330
+ def launchctl_bootstrap(path)
331
+ # Best-effort: booting out a service that was never loaded prints
332
+ # "Boot-out failed: 5" — nothing to clear then, so keep it quiet.
333
+ # Any real leftover is removed silently; bootstrap errors below
334
+ # stay loud.
335
+ Command.run("launchctl", "bootout", "system", path, out: File::NULL, err: File::NULL)
336
+ unless Command.run("launchctl", "bootstrap", "system", path)
337
+ raise Error, "launchctl bootstrap failed — check the plist at #{path}"
338
+ end
339
+ Command.run("launchctl", "enable", "system/dev.ask.local")
340
+ Command.run("launchctl", "kickstart", "-k", "system/dev.ask.local")
341
+ end
342
+
343
+ def launchctl_bootout(path)
344
+ Command.run("launchctl", "bootout", "system", path)
345
+ end
346
+
347
+ # Pure unit-file builder (testable without root). Binds 80/443 at
348
+ # boot; the proxy runs with the invoking user's state dir.
349
+ def systemd_unit
350
+ home = user_home_for_service
351
+ state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
352
+ <<~UNIT
353
+ # /etc/systemd/system/yamine.service (binds 80/443 at boot)
354
+ [Unit]
355
+ After=network.target
356
+
357
+ [Service]
358
+ ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground
359
+ Environment=YAMINE_STATE_DIR=#{state_dir}
360
+ Environment=HOME=#{home}
361
+
362
+ [Install]
363
+ WantedBy=multi-user.target
364
+ UNIT
365
+ end
366
+
367
+ # Install + start the systemd unit (mirrors portless). We are root
368
+ # here (sudo re-exec). The unit is written root-owned, then enabled
369
+ # and started.
370
+ def install_systemd
371
+ unit_path = "/etc/systemd/system/yamine.service"
372
+ File.write(unit_path, systemd_unit)
373
+ File.chmod(0o644, unit_path)
374
+ File.chown(0, 0, unit_path) if Process.uid.zero?
375
+ puts " Registering the systemd service on port 443..."
376
+ Command.run("systemctl", "daemon-reload") or raise Error, "systemctl daemon-reload failed"
377
+ Command.run("systemctl", "enable", "--now", "yamine") or raise Error, "systemctl enable failed"
378
+ puts "Installed systemd service yamine on port 443."
379
+ end
380
+
381
+ def service_uninstall(ctx)
382
+ if !ProxyControl.root?
383
+ puts "Removing system service (sudo required)..."
384
+ state = Certs.state_dir
385
+ cmd = ["env", "YAMINE_STATE_DIR=#{state}",
386
+ RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"]
387
+ return elevate(cmd)
388
+ end
389
+ case RUBY_PLATFORM
390
+ when /darwin/
391
+ path = "/Library/LaunchDaemons/dev.ask.local.plist"
392
+ launchctl_bootout(path)
393
+ FileUtils.rm_f(path)
394
+ puts "Removed root LaunchDaemon."
395
+ when /linux/
396
+ Command.run("systemctl", "disable", "--now", "yamine")
397
+ Command.run("systemctl", "daemon-reload")
398
+ FileUtils.rm_f("/etc/systemd/system/yamine.service")
399
+ puts "Removed systemd service yamine."
400
+ else
401
+ raise Error, "Service uninstall not supported on #{RUBY_PLATFORM}"
402
+ end
403
+ true
404
+ end
405
+
406
+ def service_status(ctx)
407
+ port = ProxyControl.proxy_port(ctx.store)
408
+ if port.nil? || !ProxyControl.listening?(port)
409
+ puts "Proxy not running."
410
+ elsif ProxyControl.ours?(port, tls: ProxyControl.proxy_tls(ctx.store))
411
+ puts "Proxy running on port #{port}."
412
+ else
413
+ puts "Port #{port} in use by another process."
414
+ end
415
+ end
416
+
417
+
418
+ def init(ctx, _args)
419
+ config_path = File.join(Dir.pwd, Config::RELATIVE_PATH)
420
+ if File.file?(config_path)
421
+ $stderr.puts "config/local.yml already exists at #{config_path}"
422
+ return
423
+ end
424
+ FileUtils.mkdir_p(File.join(Dir.pwd, Config::RELATIVE_DIR))
425
+
426
+ # Migrate Procfile.dev / Procfile if present.
427
+ procfile = Yamine::Procfile.find_file(Dir.pwd)
428
+ service = Yamine::Sanitize.hostname_label(File.basename(Dir.pwd))
429
+
430
+ process_lines = []
431
+ if procfile
432
+ lines = Yamine::Procfile.parse_file(procfile)
433
+ lines.each do |l|
434
+ type = Yamine::Procfile.classify(l.name) == :background ? "false" : "true"
435
+ process_lines << " #{l.name}:"
436
+ process_lines << " cmd: #{l.command}"
437
+ process_lines << " proxy: #{type}"
438
+ process_lines << " # NOTE: compound line - run explicitly" if l.compound
439
+ end
440
+ else
441
+ # Default: a single web process. Detect Rails (Gemfile with
442
+ # rails) vs plain Rack (config.ru) and pick the right boot.
443
+ has_rails = File.file?(File.join(Dir.pwd, "Gemfile")) &&
444
+ File.read(File.join(Dir.pwd, "Gemfile")).match?(/gem ["']rails["']/)
445
+ cmd =
446
+ if has_rails
447
+ "bundle exec puma -b tcp://127.0.0.1:$PORT config.ru"
448
+ elsif File.file?(File.join(Dir.pwd, "config.ru"))
449
+ "puma -b tcp://127.0.0.1:$PORT config.ru"
450
+ else
451
+ "bin/rails server -p $PORT"
452
+ end
453
+ process_lines << " web:"
454
+ process_lines << " cmd: #{cmd}"
455
+ process_lines << " proxy: true"
456
+ end
457
+
458
+ content = <<~YAML
459
+ # config/local.yml — yamine configuration (Kamal-style).
460
+ # This is the only source of truth. Run `yamine` to boot everything.
461
+ # Overlay variants with config/local.<variant>.yml.
462
+
463
+ service: #{service}
464
+
465
+ proxy:
466
+ tld: localhost
467
+
468
+ processes:
469
+ #{process_lines.join("\n")}
470
+
471
+ env:
472
+ clear:
473
+ RAILS_ENV: development
474
+ YAML
475
+ File.write(config_path, content)
476
+ puts "Created #{config_path}"
477
+ puts " service: #{service}"
478
+ puts " processes from: #{procfile || 'defaults'}"
479
+ puts "Run `yamine start` to boot."
480
+ end
481
+
482
+
483
+ def setup(ctx, args)
484
+ if args.include?("--help") || args.include?("-h")
485
+ puts <<~HELP
486
+ Usage: yamine setup [--no-service]
487
+
488
+ One-shot workstation setup for clean https://<app>.localhost URLs:
489
+
490
+ Default: install the root proxy service on 443 — runs under
491
+ sudo ONCE, trusting the CA system-wide in the same step
492
+ (no separate GUI authorization popup).
493
+ --no-service: trust the CA at user level, then run a sudo
494
+ daemon instead (no boot persistence; ephemeral machines).
495
+
496
+ Both finish by syncing /etc/hosts and verifying with doctor.
497
+ HELP
498
+ return
499
+ end
500
+
501
+ puts "Note: setup takes a few seconds while the 443 service installs and starts."
502
+
503
+ no_service = args.include?("--no-service")
504
+ steps = no_service ? 4 : 3
505
+
506
+ if no_service
507
+ step("1/#{steps} Trusting local CA") do
508
+ result = Yamine::Trust.trust
509
+ unless result[:trusted]
510
+ abort_setup("CA trust failed: #{result[:error]}",
511
+ "Run `yamine trust` manually to see the underlying error,",
512
+ "then re-run `yamine setup`.")
513
+ end
514
+ end
515
+ step("2/#{steps} Starting proxy sudo daemon on port 443") do
516
+ unless ensure_sudo_daemon(ctx)
517
+ abort_setup("Could not start the proxy daemon on port 443.",
518
+ "Check the log, then re-run `yamine setup`.")
519
+ end
520
+ end
521
+ else
522
+ step("1/#{steps} Installing proxy service on port 443 (trusts CA)") do
523
+ # Runs under sudo once; inside, the CA is trusted into the
524
+ # System keychain silently (no GUI popup) and the launchd
525
+ # service is bootstrapped. One password entry, that's all.
526
+ unless ensure_root_service(ctx)
527
+ abort_setup("Could not install the proxy service.",
528
+ "Fallback: `yamine setup --no-service` for a sudo daemon",
529
+ "without boot persistence.")
530
+ end
531
+ end
532
+ end
533
+
534
+ step("#{no_service ? 3 : 2}/#{steps} Syncing /etc/hosts") do
535
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
536
+ next if hostnames.empty?
537
+
538
+ # The root service install already synced under elevation; a
539
+ # plain re-run must not fail rewriting /etc/hosts unprivileged
540
+ # when the block is already in place.
541
+ unless Yamine::Hosts.synced?(hostnames) || Yamine::Hosts.sync(hostnames)
542
+ abort_setup("Could not write /etc/hosts.",
543
+ "Run `sudo yamine hosts sync`, then re-run `yamine setup`.")
544
+ end
545
+ end
546
+
547
+ step("#{no_service ? 4 : 3}/#{steps} Verifying with doctor") do
548
+ failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout)
549
+ if failed.zero?
550
+ puts "\nSetup complete: https://<app>.localhost URLs are ready."
551
+ puts "Try it: cd ~/code/myapp && yamine"
552
+ else
553
+ abort_setup("Doctor reports #{failed} failing check(s) (see above).",
554
+ "Fix the reported issues, then re-run `yamine setup`.")
555
+ end
556
+ end
557
+ end
558
+
559
+ def step(label)
560
+ puts "\n==> #{label}..."
561
+ yield
562
+ puts " ok"
563
+ end
564
+
565
+ def abort_setup(problem, *fixes)
566
+ $stderr.puts "\nSetup failed: #{problem}"
567
+ fixes.each { |f| $stderr.puts " #{f}" }
568
+ exit 1
569
+ end
570
+
571
+ # The two ways to get a privileged proxy on 443: a human runs setup
572
+ # once (interactive sudo), or an agent/CI image is pre-provisioned
573
+ # with the scoped NOPASSWD rules from `yamine sudoers`.
574
+ def privileged_port_hint
575
+ [
576
+ "Human: run this once — yamine setup",
577
+ "Agent/CI: pre-provision passwordless sudo once —",
578
+ " yamine sudoers > /tmp/yamine.sudoers",
579
+ " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
580
+ ]
581
+ end
582
+
583
+ # Install the root service (boot-persistent). Returns true when a
584
+ # proxy is up on 443 afterwards, false otherwise. Never falls back
585
+ # to a high port silently: a :port suffix in URLs would corrupt the
586
+ # stable-URL promise, so failure here is a hard error with guidance.
587
+ def ensure_root_service(ctx)
588
+ return false unless service_install(ctx, [])
589
+ wait_for_ours(ctx, 443, tls: true)
590
+ rescue Error, SystemCallError => e
591
+ warn " service install failed: #{e.message}"
592
+ false
593
+ end
594
+
595
+ # Sudo daemon for 443 without boot persistence (--no-service).
596
+ def ensure_sudo_daemon(ctx)
597
+ port, tls = 443, true
598
+ unless ctx.interactive?
599
+ warn " no TTY available for the sudo prompt."
600
+ warn " Agent/CI: pre-provision passwordless sudo once —"
601
+ warn " yamine sudoers > /tmp/yamine.sudoers"
602
+ warn " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
603
+ return false
604
+ end
605
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
606
+ wait_for_ours(ctx, port, tls: tls)
607
+ rescue Yamine::ProxyNotRunningError, SystemCallError => e
608
+ warn " daemon start failed: #{e.message.lines.first&.strip}"
609
+ false
610
+ end
611
+
612
+ # Poll until our proxy answers on the port. The freshly installed
613
+ # service takes a few seconds to boot, so show motion instead of a
614
+ # frozen prompt: a spinner on a terminal, dots elsewhere. Silent
615
+ # when the proxy is already up.
616
+ def wait_for_ours(ctx, port, tls:, timeout: 20)
617
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
618
+ terminal = $stdout.respond_to?(:tty?) && $stdout.tty?
619
+ waiting = false
620
+ frame = 0
621
+ ok = false
622
+ loop do
623
+ if ProxyControl.ours?(port, tls: tls)
624
+ ok = true
625
+ break
626
+ end
627
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
628
+
629
+ unless waiting
630
+ print terminal ? " starting the proxy on port #{port} " : " (starting the proxy on port #{port}"
631
+ waiting = true
632
+ end
633
+ if terminal
634
+ print %w[| / - \\][frame % 4], "\b"
635
+ else
636
+ print "."
637
+ end
638
+ $stdout.flush
639
+ frame += 1
640
+ sleep 0.5
641
+ end
642
+ if waiting
643
+ if terminal
644
+ puts(ok ? "\r proxy is up on port #{port}." : "\r still not up on port #{port}.")
645
+ else
646
+ puts ")"
647
+ end
648
+ end
649
+ ok
650
+ end
651
+
652
+ # yamine start — one-setup-and-go: idempotent workstation setup
653
+ # (trust, 443, hosts) when doctor fails, then boots the app in the
654
+ # current directory. The single command you run day-to-day; existing
655
+ # bare `yamine` keeps working via BootCommand.run_inferred, and
656
+ # `setup` stays for explicit re-setup.
657
+ def start(ctx, args)
658
+ if args.include?("--help") || args.include?("-h")
659
+ puts <<~HELP
660
+ Usage: yamine start [name] [cmd...] [options]
661
+
662
+ One-setup-and-go: if the workstation isn't ready (CA, proxy,
663
+ hosts), runs the minimal needed setup first, then boots the
664
+ app in the current directory.
665
+
666
+ yamine start # infer name, boot -> https://<app>.localhost
667
+ yamine start myapp # explicit name
668
+ yamine start -- --help # pass --help to the app, not here
669
+
670
+ Options are passed through to the boot path:
671
+ --name <name> --service <svc> --variant <v> --tld <tld> --branch
672
+
673
+ Setup failures become hard errors pointing at `yamine setup`;
674
+ non-interactive CI without a running proxy exits immediately.
675
+ HELP
676
+ return
677
+ end
678
+
679
+ # Fast path: every doctor check passes => skip setup entirely.
680
+ # This makes `start` as fast as `yamine` on a ready machine.
681
+ if needs_workstation_setup?(ctx)
682
+ ensure_workstation!(ctx)
683
+ end
684
+ BootCommand.run_inferred(ctx, args)
685
+ end
686
+
687
+ def needs_workstation_setup?(ctx)
688
+ Yamine::Doctor.run(store: ctx.store).any? { |c| !c.ok }
689
+ end
690
+
691
+
692
+ # Quiet workstation setup for `start`: trust the CA, ensure a proxy
693
+ # on 443 (root service, sudo daemon fallback), and sync hosts.
694
+ # Each step is idempotent; only missing pieces run. Non-interactive
695
+ # CI without a proxy fails fast rather than prompting for sudo.
696
+ def ensure_workstation!(ctx)
697
+ # 1. CA
698
+ unless Yamine::Certs.trusted?(ctx.store.dir)
699
+ result = Yamine::Trust.trust
700
+ unless result[:trusted]
701
+ abort_setup("CA trust failed: #{result[:error]}",
702
+ "Run `yamine setup` in a terminal (handles trust + service),",
703
+ "then re-run `yamine start`.")
704
+ end
705
+ end
706
+
707
+ # 2. Proxy on 443
708
+ port = 443
709
+ tls = true
710
+ unless Yamine::ProxyControl.listening?(port) && ProxyControl.ours?(port, tls: tls)
711
+ if port < 1024 && !Yamine::ProxyControl.root? && !ctx.interactive?
712
+ abort_setup("Proxy is not running and port 443 needs root to bind.", *privileged_port_hint)
713
+ end
714
+ ok =
715
+ if ProxyControl.root?
716
+ Yamine::CLI::SystemCommand.ensure_root_service(ctx)
717
+ elsif ctx.interactive?
718
+ begin
719
+ Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
720
+ wait_for_ours(ctx, port, tls: tls)
721
+ rescue Yamine::ProxyNotRunningError, SystemCallError => e
722
+ warn " daemon start failed: #{e.message.lines.first&.strip}"
723
+ false
724
+ end
725
+ else
726
+ false
727
+ end
728
+ unless ok
729
+ abort_setup("Proxy is not running and could not be started on port 443.", *privileged_port_hint)
730
+ end
731
+ end
732
+
733
+ # 3. Hosts (best-effort: only needed for Safari; warn, don't fail)
734
+ unless Hosts.sync(ctx.store.load_routes.map { |r| r["hostname"] })
735
+ warn "Warning: could not write /etc/hosts (try sudo yamine hosts sync)."
736
+ end
737
+ end
738
+ # yamine kamal <variant> [--app myapp] [--domain preview.example.com]
739
+ def kamal(_ctx, args)
740
+ opts = {}
741
+ rest = []
742
+ i = 0
743
+ a = args.dup
744
+ while i < a.length
745
+ case a[i]
746
+ when "--app" then opts[:app] = a.fetch(i + 1); i += 2
747
+ when "--domain" then opts[:domain] = a.fetch(i + 1); i += 2
748
+ when "--tld" then opts[:tld] = a.fetch(i + 1); i += 2
749
+ else rest << a[i]; i += 1
750
+ end
751
+ end
752
+ variant = rest.first
753
+ raise Error, "Usage: yamine kamal <variant> [--app myapp] [--domain preview.example.com] [--tld <tld>]" unless variant
754
+ tld = opts[:tld] || ENV["YAMINE_TLD"]&.split(",")&.first || "localhost"
755
+
756
+ app = opts[:app] || Resolver.resolve(Dir.pwd).app
757
+ domain = opts[:domain] || ENV["YAMINE_KAMAL_DOMAIN"] || "preview.example.com"
758
+ slug = Sanitize.hostname_label(variant)
759
+ puts "# Paste into deploy.yml proxy section for a preview of variant #{slug}:"
760
+ puts "proxy:"
761
+ puts " ssl: true"
762
+ puts " hosts:"
763
+ puts " - #{app}-#{slug}.#{domain}"
764
+ puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
765
+ end
766
+ end
767
+ end
768
+ end