yamine 0.8.1 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46a9c0208ca1d5581c3c25d45b60370a6e37c7dd4fcf721bda7a5573fc1c70db
4
- data.tar.gz: 0b534671965987794b3872969dcc3065dfca5532a401f12701e531a0779e7820
3
+ metadata.gz: 65dc796c2e121f6376d6a76b1baa7bb1ada3598c7facf8634262c83915c9cf2c
4
+ data.tar.gz: 0a2cea7a624133d68512b79e5e1060a96337a7da4948504313ea24ba6ecce080
5
5
  SHA512:
6
- metadata.gz: 339e64fabec8dab3c019bfdec8ec8f9f558dc4700b647fd462c4898e4f8796f93c1b65078d9ff171c338f38c70d055a8185b1d21bb547c63831bd816c5b7c6de
7
- data.tar.gz: a71d56eef8327103f46a050307bb01095810ccd70085f3728a430617d3230cf567ffe3e9a04047428d6988d63efa7dd6965546bbb5e689f270991f8d4cc55d6d
6
+ metadata.gz: c1f73c73193f3e9c4eae8615ad0b790111bd2c6f29615a19f35c380e99db2d2a1a239e38b10c9328795a6f1c7cf1b28b0ecce721cb5414710769ba63a99b622b
7
+ data.tar.gz: 2200ab3b518fac757902b25e7ec2e6933d08e2183830aba2bbf5d893622b2ded021cc7bf4f07064e6efe9ff7500dad408ea73f7bc30a4c83c25b645829bdfdf2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] — 2026-09-10
4
+
5
+ ### Fixed
6
+
7
+ - **The service proxy now records its own state.** The launchd/systemd
8
+ service runs `proxy start --foreground`, and that path recorded
9
+ nothing: `write_pid` was reachable only from `spawn_daemon`. So after
10
+ `yamine setup`, the pid/port/scheme files still held the PREVIOUS
11
+ daemon's values. On a machine whose root service was serving clean
12
+ `https://<app>.localhost` on 443, the recorded port still said 8443 —
13
+ `yamine start` printed and baked `:8443` into `YAMINE_URL`, doctor
14
+ warned about the long-dead 8443, and `proxy stop` aimed at a pid that
15
+ was already gone. `setup` even contradicted itself in one run:
16
+ "proxy is up on port 443" followed by "listening on port 8443". The
17
+ foreground path now writes pid/port/tls plus a new `proxy.version`
18
+ marker BEFORE it serves.
19
+ - The launchd plist and systemd unit pass `--port 443` explicitly. They
20
+ relied on the default, which consults `YAMINE_PORT` — one stray export
21
+ would silently move the boot service off 443.
22
+ - **`proxy stop` no longer claims success on a root-owned proxy.** The
23
+ kill raised `Errno::EPERM`, which was swallowed by a blanket rescue:
24
+ the state files were cleared and "Proxy stopped." printed while the
25
+ service kept serving — now with nothing on disk to find or stop it by.
26
+ It reports `needs_root` and points at `sudo yamine service uninstall`.
27
+
28
+ ### Added
29
+
30
+ - `doctor` gains a `proxy state` check and now finds the proxy that is
31
+ actually serving (`ProxyControl.serving_port`, ownership-proven)
32
+ instead of trusting the recorded port. It warns when the recorded port
33
+ disagrees with reality, and when the running proxy is an older gem
34
+ version than the CLI (the root service keeps serving old code after an
35
+ upgrade until `sudo yamine service install` re-registers it).
36
+ - The clean default port wins when our proxy serves there, so a leftover
37
+ daemon on another port can no longer make the machine report a
38
+ downgraded URL as its state. A non-default port is still reported when
39
+ it is the only proxy — the deliberate CI/sandbox case.
40
+
41
+
42
+ ## [0.8.2] — 2026-09-10
43
+
44
+ ### Fixed
45
+
46
+ - **The rest of the `--json` stdout leaks.** Two writers still reached
47
+ stdout during a machine-readable run, corrupting the stream a parser
48
+ reads: the `-> <url>` line from the sequential boot path, and
49
+ "Starting proxy..." — which is printed before the reporter is even
50
+ installed, so it would have been the FIRST line of a `--json` run.
51
+ Both now route through the same stderr-when-json rule as the rest of
52
+ the narration, and the contract is pinned by tests rather than assumed
53
+ (two separate leaks have now been caught here).
54
+
55
+
3
56
  ## [0.8.1] — 2026-09-10
4
57
 
5
58
  ### Fixed
@@ -21,7 +21,7 @@ module Yamine
21
21
  # Ownership gate before any side effects: no proxy spawn, no
22
22
  # port allocation when we'd refuse anyway.
23
23
  check_worktree_ownership!(ctx, resolved, force: opts[:force])
24
- ensure_proxy!(ctx)
24
+ ensure_proxy!(ctx, json: opts[:json])
25
25
  boot_all(ctx, resolved, opts)
26
26
  end
27
27
 
@@ -180,7 +180,7 @@ module Yamine
180
180
  rails_dev_host: item[:hostname], database_url: db_url)
181
181
  routes_registered << { hostnames: item[:hostnames], app: app }
182
182
 
183
- puts " -> #{item[:url]}"
183
+ say opts, " -> #{item[:url]}"
184
184
  end
185
185
  children
186
186
  end
@@ -548,7 +548,11 @@ module Yamine
548
548
  Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
549
549
  end
550
550
 
551
- def ensure_proxy!(ctx)
551
+ # json: keeps stdout free for the machine-readable stream — this runs
552
+ # BEFORE boot_all installs the reporter, so its progress line is the
553
+ # one piece of narration that could still land mid-JSON and break a
554
+ # parser on the first line of a --json run.
555
+ def ensure_proxy!(ctx, json: false)
552
556
  port = ctx.proxy_port
553
557
  tls = ctx.proxy_tls
554
558
  if ProxyControl.listening?(port)
@@ -570,7 +574,8 @@ module Yamine
570
574
  $stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
571
575
  exit 1
572
576
  end
573
- puts "Starting proxy#{privileged ? " (sudo)" : ""}..."
577
+ starting = "Starting proxy#{privileged ? " (sudo)" : ""}..."
578
+ json ? $stderr.puts(starting) : puts(starting)
574
579
  begin
575
580
  Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
576
581
  rescue Yamine::ProxyNotRunningError => e
@@ -28,10 +28,7 @@ module Yamine
28
28
  # listening there. A dead proxy leaves the machine on the clean
29
29
  # default (443), and an explicit YAMINE_PORT always wins.
30
30
  def proxy_port
31
- recorded = ProxyControl.proxy_port(store)
32
- return recorded if recorded && ProxyControl.listening?(recorded)
33
-
34
- ProxyControl.default_port(proxy_tls)
31
+ ProxyControl.active_port(store)
35
32
  end
36
33
 
37
34
  def proxy_tls
@@ -70,6 +70,14 @@ module Yamine
70
70
  if foreground
71
71
  write_tls_marker(ctx, tls)
72
72
  write_tlds_file(ctx, tlds)
73
+ # Record BEFORE serving. This process (the launchd/systemd
74
+ # service, or a hand-run foreground proxy) IS the proxy, and
75
+ # every reader trusts these files: doctor, Context's URL
76
+ # building, the supervisor, `proxy stop`. Not recording left
77
+ # the previous proxy's port on disk, so a machine serving
78
+ # clean 443 still advertised :8443 everywhere.
79
+ ProxyControl.write_proxy_state(ctx.store,
80
+ pid: Process.pid, port: port, tls: tls)
73
81
  sup = Supervisor.new(store: ctx.store, runner: Runner.new(store: ctx.store),
74
82
  on_event: ->(m) { warn m })
75
83
  sup.start
@@ -85,6 +93,13 @@ module Yamine
85
93
  when :stale then puts "Removed stale proxy state."
86
94
  when :not_running then puts "Proxy is not running."
87
95
  when :unknown_process then puts "Port in use by an unknown process."
96
+ when :needs_root
97
+ # A root service keeps serving; say so rather than implying
98
+ # success. Uninstalling is the real answer — signalling it
99
+ # would just leave launchd's KeepAlive restarting it.
100
+ $stderr.puts "The proxy runs as root (the launchd/systemd service)."
101
+ $stderr.puts " Uninstall it: sudo yamine service uninstall"
102
+ exit 1
88
103
  end
89
104
  else
90
105
  raise Error, "Usage: yamine proxy [start|stop]"
@@ -282,6 +297,7 @@ module Yamine
282
297
  <string>#{RbConfig.ruby}</string>
283
298
  <string>#{ProxyControl.bin_path}</string>
284
299
  <string>proxy</string><string>start</string><string>--foreground</string>
300
+ <string>--port</string><string>#{ProxyControl::DEFAULT_TLS_PORT}</string>
285
301
  </array>
286
302
  <key>EnvironmentVariables</key>
287
303
  <dict>
@@ -357,7 +373,7 @@ module Yamine
357
373
  After=network.target
358
374
 
359
375
  [Service]
360
- ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground
376
+ ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground --port #{ProxyControl::DEFAULT_TLS_PORT}
361
377
  Environment=YAMINE_STATE_DIR=#{state_dir}
362
378
  Environment=HOME=#{home}
363
379
 
data/lib/yamine/doctor.rb CHANGED
@@ -18,12 +18,18 @@ module Yamine
18
18
 
19
19
  def run(store:, port: nil, tls: nil)
20
20
  checks = []
21
- port ||= ProxyControl.proxy_port(store)
22
21
  tls = ProxyControl.proxy_tls(store) if tls.nil?
22
+ # Prove ownership rather than trusting the port file: doctor is
23
+ # where a wrong answer is expensive, and a connection is cheap. This
24
+ # finds the proxy on the scheme default even when a stale port file
25
+ # says otherwise, which is how a root service serving clean 443 went
26
+ # unreported while doctor warned about a dead 8443.
27
+ port ||= ProxyControl.serving_port(store)
23
28
 
24
29
  checks << check_state_dir(store)
25
30
  checks << check_disk(store)
26
31
  checks << check_proxy(port, tls: tls)
32
+ checks << check_proxy_state(store, port)
27
33
  checks << check_routes(store)
28
34
  checks << check_dns(store)
29
35
  checks << check_ca
@@ -93,6 +99,41 @@ module Yamine
93
99
  end
94
100
  end
95
101
 
102
+ # The recorded port, and whether the proxy serving is the version we
103
+ # have installed.
104
+ #
105
+ # A file that disagrees with reality is the failure mode this whole
106
+ # area keeps producing: a stopped daemon's port outliving it made
107
+ # `yamine start` bake :8443 into URLs while the machine served clean
108
+ # 443, and a root service left from an old gem keeps serving old code
109
+ # after an upgrade (`sudo yamine service install` re-registers it).
110
+ # Neither is visible from the URL bar, so both are named here.
111
+ def check_proxy_state(store, serving)
112
+ recorded = ProxyControl.proxy_port(store)
113
+ version = ProxyControl.proxy_version(store)
114
+ problems = []
115
+
116
+ if recorded && serving && recorded != serving
117
+ problems << "state says port #{recorded} but the proxy is on #{serving}" \
118
+ " — run: yamine proxy stop"
119
+ elsif recorded && serving.nil?
120
+ problems << "state says port #{recorded}, but no yamine proxy is serving" \
121
+ " — run: yamine proxy stop"
122
+ end
123
+
124
+ if version && version != Yamine::VERSION
125
+ problems << "the running proxy is v#{version}, this CLI is v#{Yamine::VERSION}" \
126
+ " — re-register it: sudo yamine service install"
127
+ end
128
+
129
+ if problems.empty?
130
+ Check.new(name: "proxy state", ok: true, message: "consistent")
131
+ else
132
+ Check.new(name: "proxy state", ok: true, warn: true,
133
+ message: problems.join("; "))
134
+ end
135
+ end
136
+
96
137
  def check_routes(store)
97
138
  routes = store.load_routes
98
139
  if routes.empty?
@@ -156,17 +156,33 @@ module Yamine
156
156
  nil
157
157
  end
158
158
 
159
- def write_pid(store, pid, port, tls)
159
+ # Record this process as THE running proxy: pid, port, scheme, and the
160
+ # gem version serving it.
161
+ #
162
+ # Every reader trusts these files — doctor reports the port, Context
163
+ # and the supervisor build URLs from it, `proxy stop` signals the pid
164
+ # — so a proxy that serves without recording is worse than one that
165
+ # fails to start: it leaves the previous proxy's state on disk and the
166
+ # whole CLI acts on it. The launchd/systemd service used to take
167
+ # exactly that path (it runs `proxy start --foreground`, which
168
+ # recorded nothing), so a stopped 8443 daemon's port stayed on disk
169
+ # and `yamine start` baked :8443 into URLs while the machine served
170
+ # clean https://<app>.localhost on 443.
171
+ def write_proxy_state(store, pid:, port:, tls:)
160
172
  store.ensure_dir
173
+ version_path = File.join(store.dir, "proxy.version")
161
174
  File.write(store.pid_path, "#{pid}\n")
162
175
  File.write(store.port_path, "#{port}\n")
163
176
  File.write(File.join(store.dir, "proxy.tls"), tls ? "1" : "0")
164
- Ownership.fix(store.pid_path, store.port_path, File.join(store.dir, "proxy.tls"))
177
+ File.write(version_path, "#{Yamine::VERSION}\n")
178
+ Ownership.fix(store.pid_path, store.port_path,
179
+ File.join(store.dir, "proxy.tls"), version_path)
165
180
  end
166
181
 
167
182
  def clear_pid(store)
168
183
  FileUtils.rm_f(store.pid_path)
169
184
  FileUtils.rm_f(store.port_path)
185
+ FileUtils.rm_f(File.join(store.dir, "proxy.version"))
170
186
  end
171
187
 
172
188
  def proxy_port(store)
@@ -178,6 +194,54 @@ module Yamine
178
194
  nil
179
195
  end
180
196
 
197
+ # The gem version of the proxy that recorded its state, or nil when
198
+ # nothing is recorded. Compared against Yamine::VERSION so a service
199
+ # left behind by an older install is visible instead of silently
200
+ # serving stale code.
201
+ def proxy_version(store)
202
+ path = File.join(store.dir, "proxy.version")
203
+ return nil unless File.file?(path)
204
+
205
+ version = File.read(path).strip
206
+ version.empty? ? nil : version
207
+ rescue SystemCallError
208
+ nil
209
+ end
210
+
211
+ # Cheap resolution for the hot paths (URL building on every boot):
212
+ # the recorded port while something is listening there, else the
213
+ # scheme default. Proves liveness, not ownership — a foreign process
214
+ # on the recorded port still wins here and is then caught by
215
+ # `ensure_proxy!` ("port in use by another process"). Use
216
+ # `serving_port` when the answer must be OUR proxy.
217
+ def active_port(store)
218
+ recorded = proxy_port(store)
219
+ return recorded if recorded && listening?(recorded)
220
+
221
+ default_port(proxy_tls(store))
222
+ end
223
+
224
+ # Where OUR proxy is actually serving, or nil when it is not running.
225
+ # Proves ownership, so it costs a probe — doctor-only, where an
226
+ # occasional connection is free and a wrong answer is not.
227
+ #
228
+ # The default port wins when our proxy serves there, because that is
229
+ # the configuration yamine exists to produce: after `yamine setup`
230
+ # installs the 443 service, a leftover daemon on another port must not
231
+ # make doctor report the downgraded URL as the state of the machine.
232
+ # Only when the default has no proxy do we report the recorded port —
233
+ # which is the deliberate CI/sandbox case (`proxy start -p 1355`).
234
+ def serving_port(store)
235
+ tls = proxy_tls(store)
236
+ default = default_port(tls)
237
+ return default if ours?(default, tls: tls)
238
+
239
+ recorded = proxy_port(store)
240
+ return recorded if recorded && recorded != default && ours?(recorded, tls: tls)
241
+
242
+ nil
243
+ end
244
+
181
245
  # Start the proxy as a detached daemon. Sudo is used for privileged
182
246
  # ports (portless auto-elevate pattern); the state dir is passed
183
247
  # explicitly because sudo does not preserve the environment.
@@ -205,7 +269,7 @@ module Yamine
205
269
 
206
270
  sleep 0.25
207
271
  end
208
- write_pid(store, pid, port, tls)
272
+ write_proxy_state(store, pid: pid, port: port, tls: tls)
209
273
  pid
210
274
  end
211
275
 
@@ -231,6 +295,11 @@ module Yamine
231
295
  end
232
296
  begin
233
297
  Process.kill("TERM", pid)
298
+ rescue Errno::EPERM
299
+ # The proxy runs as root (launchd/systemd service) and we do not.
300
+ # Clearing the state here would be a lie: the proxy keeps serving,
301
+ # now with nothing on disk to find or stop it by.
302
+ return :needs_root
234
303
  rescue SystemCallError
235
304
  clear_pid(store)
236
305
  return :stale
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto