yamine 0.22.1 → 0.23.1

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: 1f20b021d3a702ab4aef776ac24e10683b12c1b4ba58b7ad4e5b11b91316eb78
4
- data.tar.gz: 4ff8a2e59f075f38aa81d084d9aea45041346790aefd687ef2fc08669bc34737
3
+ metadata.gz: c868efafc13a1f8bf3aadad4cf4d0cacffe110e51e091e30b79eb1e81d2f4a24
4
+ data.tar.gz: f888229b7d618e2936108a27342f9227c8f986e46611ba22ea880052ca190a49
5
5
  SHA512:
6
- metadata.gz: 2e2542be9656d87375b9ab29ac2862f33b2be289c7932aa99f2f29abf3b2a47f40e24623f2ab8b902021e6b42fe4ed48ebe7dfc4a86a3184ddf4f859cb359d40
7
- data.tar.gz: 85d76ec111ee796d23b89879d414bf5a248504f58983a0064c3d43ffaa88d532a0f3b5d6542f21b09d2fba5acb57f20e673fcb5f7d13490108f321adbf1eb896
6
+ metadata.gz: 3ad556cdad8d143e9beb87ba0cdcfc2fa5a686ad04d10ad763e048f4ace90d084527ecc23064998ac37d9a65705e831b3216675ab5306a5da8b869680f302371
7
+ data.tar.gz: b1b11d113410b72139b9f8ea19592342b29b40edab39869861e2488f4c4c8f193de00ad564f2ba771dba5e2a33072abd41c5e0f10c649dd0ff55bceaaeab98ff
data/CHANGELOG.md CHANGED
@@ -2,6 +2,63 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.23.1] - 2026-10-02
6
+
7
+ ### Fixed
8
+
9
+ - **A chunked response now ends at its terminator instead of at end of
10
+ file.** The relay had one way to end a body with no Content-Length —
11
+ copy until the backend hung up — and it read every such body as
12
+ close-delimited. But a chunked body ends at its terminating chunk, and
13
+ the backend holding the connection open past it is keep-alive doing
14
+ exactly what it promised. So the relay blocked for the whole idle
15
+ bound, sixty seconds by default, with the client's connection pinned
16
+ the entire time. Every streamed page, every ActionController::Live
17
+ response and every event stream is framed this way, and the request
18
+ that followed on that connection — typically the page's main
19
+ JavaScript chunk — got nothing back until the connection was reaped.
20
+ A load test could not see it, because every request in one opened its
21
+ own connection. Chunked framing is now read rather than assumed, bytes
22
+ still cross to the client exactly as the backend wrote them, and the
23
+ connection is free for the next request the moment the terminator
24
+ arrives.
25
+ - `minitest/mock` is required by the test helper, so `Object#stub` is
26
+ available to every test rather than only the ones that load it.
27
+
28
+ ## [0.23.0] - 2026-10-02
29
+
30
+ ### Fixed
31
+
32
+ - **A stalled response body closes the connection instead of appending a
33
+ 502 into a live 200.** When a response relay went quiet past the idle
34
+ bound, the IdleTimeout escaped `forward`'s two rescues (it is neither
35
+ HeadTimeout nor EOFError), landed in `handle`'s generic IOError rescue,
36
+ and rendered a whole second response head into the body the browser was
37
+ already reading. The page died of the corruption with no error anywhere
38
+ naming a cause — DevTools even showed the 502 as if a server had sent
39
+ it. A response whose head has been sent can now only end one way: the
40
+ connection closes, the stall is logged with the request that died, and
41
+ no second head is ever written.
42
+ - **`backend-refused` no longer lies about what failed.** `dial` mapped
43
+ every SystemCallError to :refused, so a proxy out of file descriptors
44
+ answered "Nothing is listening at 127.0.0.1:4486" and sent the reader
45
+ off to restart an app that was perfectly fine. EMFILE/ENFILE now read
46
+ as `proxy-exhausted` and blame the proxy's own limit; every other dial
47
+ error reads as `proxy-unreachable`; a backend that accepts and closes
48
+ without answering reads as `backend-gone` instead of masquerading as
49
+ refused. The 502 page says which of these it was, and carries the fix
50
+ for that failure rather than a start command for an app that is up.
51
+
52
+ ### Added
53
+
54
+ - **The root daemon can finally be debugged.** The launchd plist and the
55
+ systemd unit carried no log paths, so the one proxy process serving
56
+ traffic discarded every error it ever reported — the mislabeled 502
57
+ above was invisible precisely because of this. Both streams now land
58
+ in `proxy.log` in the state dir, and the daemon rotates that log at
59
+ boot (one generation kept), the same rule the spawned proxy already
60
+ applied.
61
+
5
62
  ## [0.22.1] - 2026-09-29
6
63
 
7
64
  ### Fixed
@@ -97,6 +97,11 @@ module Yamine
97
97
  if foreground
98
98
  write_tls_marker(ctx, tls)
99
99
  write_tlds_file(ctx, tlds)
100
+ # The daemon appends to this log for its whole life (launchd
101
+ # and systemd both redirect their streams here), so each boot
102
+ # rotates first — one generation kept, the same rule the
103
+ # spawned proxy's Log.open_append already applies.
104
+ Log.rotate(File.join(ctx.store.dir, "proxy.log"))
100
105
  # Record BEFORE serving. This process (the launchd/systemd
101
106
  # service, or a hand-run foreground proxy) IS the proxy, and
102
107
  # every reader trusts these files: doctor, Context's URL
@@ -468,8 +473,37 @@ module Yamine
468
473
  # the user-writable gem directory it was staged from. (The
469
474
  # interpreter stays RbConfig.ruby: no root-owned Ruby >= 3.2
470
475
  # exists, so that residual risk is reported, not hidden.)
476
+ plist = launchd_plist(state_dir: state_dir, home: home)
477
+ remove_legacy_launchd(dir)
478
+ FileUtils.mkdir_p(dir)
479
+ path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
480
+ File.write(path, plist)
481
+ File.chmod(0o644, path)
482
+ # launchd requires /Library/LaunchDaemons plists to be
483
+ # root-owned; we are root here (sudo re-exec). Enforce it
484
+ # explicitly: File.write keeps an existing file's owner, so a
485
+ # stale user-owned plist from an older version would otherwise
486
+ # survive the overwrite and bootstrap fails with error 5.
487
+ # Trust the CA into the System keychain while elevated: silent
488
+ # (no GUI popup) and trusted for every user on the machine.
489
+ File.chown(0, 0, path) if Process.uid.zero?
490
+ ensure_system_ca_trust
491
+ puts " Registering the launchd service on port 443..."
492
+ launchctl_bootstrap(path)
493
+ puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
494
+ puts " Payload: #{PrivilegedPayload.bin_path} (root-owned, version-independent)."
495
+ puts " Log: #{File.join(state_dir, "proxy.log")}."
496
+ end
497
+
498
+ # Pure plist builder (testable without root). Both output streams
499
+ # point at the state dir's proxy.log: a root daemon whose errors
500
+ # launchd discards cannot be debugged at all, and one whole
501
+ # misdiagnosed failure class came from exactly that blindness —
502
+ # the proxy's own account of a dial failure never reached disk.
503
+ def launchd_plist(state_dir:, home:)
471
504
  payload_bin = PrivilegedPayload.bin_path
472
- plist = <<~PLIST
505
+ log_path = File.join(state_dir, "proxy.log")
506
+ <<~PLIST
473
507
  <?xml version="1.0" encoding="UTF-8"?>
474
508
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
475
509
  <plist version="1.0">
@@ -489,27 +523,11 @@ module Yamine
489
523
  </dict>
490
524
  <key>KeepAlive</key><true/>
491
525
  <key>RunAtLoad</key><true/>
526
+ <key>StandardOutPath</key><string>#{log_path}</string>
527
+ <key>StandardErrorPath</key><string>#{log_path}</string>
492
528
  </dict>
493
529
  </plist>
494
530
  PLIST
495
- remove_legacy_launchd(dir)
496
- FileUtils.mkdir_p(dir)
497
- path = File.join(dir, "#{LAUNCHD_LABEL}.plist")
498
- File.write(path, plist)
499
- File.chmod(0o644, path)
500
- # launchd requires /Library/LaunchDaemons plists to be
501
- # root-owned; we are root here (sudo re-exec). Enforce it
502
- # explicitly: File.write keeps an existing file's owner, so a
503
- # stale user-owned plist from an older version would otherwise
504
- # survive the overwrite and bootstrap fails with error 5.
505
- # Trust the CA into the System keychain while elevated: silent
506
- # (no GUI popup) and trusted for every user on the machine.
507
- File.chown(0, 0, path) if Process.uid.zero?
508
- ensure_system_ca_trust
509
- puts " Registering the launchd service on port 443..."
510
- launchctl_bootstrap(path)
511
- puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
512
- puts " Payload: #{payload_bin} (root-owned, version-independent)."
513
531
  end
514
532
 
515
533
  # True when the trust store covers the CA this machine should be
@@ -587,9 +605,12 @@ module Yamine
587
605
  # Pure unit-file builder (testable without root). Binds 80/443 at
588
606
  # boot; the proxy runs with the invoking user's state dir. The
589
607
  # payload is the root-owned staged path — never the gem directory.
590
- def systemd_unit
591
- home = user_home_for_service
592
- state_dir = ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
608
+ # Both output streams append to the state dir's proxy.log, matching
609
+ # the launchd unit, so a daemon that misbehaves can be read.
610
+ def systemd_unit(state_dir: nil, home: nil)
611
+ home ||= user_home_for_service
612
+ state_dir ||= ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
613
+ log_path = File.join(state_dir, "proxy.log")
593
614
  <<~UNIT
594
615
  # /etc/systemd/system/yamine.service (binds 80/443 at boot)
595
616
  [Unit]
@@ -599,6 +620,8 @@ module Yamine
599
620
  ExecStart=#{RbConfig.ruby} #{PrivilegedPayload.bin_path} proxy start --foreground --port #{ProxyControl::DEFAULT_TLS_PORT}
600
621
  Environment=YAMINE_STATE_DIR=#{state_dir}
601
622
  Environment=HOME=#{home}
623
+ StandardOutput=append:#{log_path}
624
+ StandardError=append:#{log_path}
602
625
 
603
626
  [Install]
604
627
  WantedBy=multi-user.target
data/lib/yamine/proxy.rb CHANGED
@@ -64,6 +64,7 @@ module Yamine
64
64
  # which is also the only one a retry can be safe for.
65
65
  HeadTimeout = Class.new(IdleTimeout)
66
66
  CHUNK_BYTES = 16_384
67
+ CRLF = "\r\n"
67
68
  # Route cache: routes.json is the source of truth, but re-reading
68
69
  # and re-parsing it on every request is wasteful under HMR polling.
69
70
  # Keyed on file mtime, so a freshly registered route is visible on
@@ -329,6 +330,13 @@ module Yamine
329
330
  keep, reason = forward(sock, entry, method, target, headers, buf)
330
331
  end
331
332
 
333
+ if reason == :body_lost
334
+ # The response head already reached the client; there is no
335
+ # answer left to give, only a connection to close. A 502 here
336
+ # would be garbage appended to a live response.
337
+ break
338
+ end
339
+
332
340
  if reason
333
341
  render_bad_gateway(sock, entry, host, reason)
334
342
  break
@@ -344,7 +352,7 @@ module Yamine
344
352
 
345
353
  # One attempt at the backend: dial, forward the request, relay the
346
354
  # response. Returns [keep_alive?, reason], where reason is nil on
347
- # success and otherwise names which backend failure this was. That one
355
+ # success and otherwise names which failure this was. That one
348
356
  # value decides both whether a retry is safe and what the 502 says, so
349
357
  # "refused" and "silent" can never drift apart again.
350
358
  #
@@ -355,8 +363,8 @@ module Yamine
355
363
  begin
356
364
  backend = dial(entry)
357
365
  rescue SystemCallError => e
358
- @on_error.call("dial failed for #{entry["hostname"]}: #{e.message}")
359
- return [:close, :refused]
366
+ @on_error.call("dial failed for #{entry["hostname"]}: #{e.class}: #{e.message}")
367
+ return [:close, failure_reason(e)]
360
368
  end
361
369
 
362
370
  begin
@@ -370,15 +378,29 @@ module Yamine
370
378
  end
371
379
  end
372
380
 
381
+ # What a failed dial means. ECONNREFUSED is the one failure that
382
+ # indicts the backend; the rest indict the path or the proxy itself,
383
+ # and reporting them as "Nothing is listening" sends the reader off to
384
+ # restart an app that is perfectly fine — an fd-exhausted proxy spent
385
+ # one whole debugging evening dressed as a dead backend.
386
+ def failure_reason(error)
387
+ case error
388
+ when Errno::ECONNREFUSED then :refused
389
+ when Errno::EMFILE, Errno::ENFILE then :exhausted
390
+ else :unreachable
391
+ end
392
+ end
393
+
373
394
  # A refused dial never reached the app, so any method replays safely —
374
395
  # the request body is still unread on the client socket. A head
375
- # timeout means the head DID reach the app, so the only safe replays
376
- # are the bodiless methods: the body has already been consumed off the
377
- # client socket and cannot be faithfully resent, and a duplicated
378
- # message is a worse outcome than a slow page.
396
+ # timeout or an exhausted proxy means the attempt died on our side of
397
+ # the wire, so the only safe replays are the bodiless methods: the
398
+ # body has already been consumed off the client socket and cannot be
399
+ # faithfully resent, and a duplicated message is a worse outcome than
400
+ # a slow page.
379
401
  def retriable?(reason, method, headers)
380
402
  return true if reason == :refused
381
- return false unless reason == :silent
403
+ return false unless [:silent, :exhausted].include?(reason)
382
404
  return false unless %w[GET HEAD OPTIONS].include?(method.to_s.upcase)
383
405
 
384
406
  request_body_length(headers) == 0
@@ -413,17 +435,53 @@ module Yamine
413
435
  return [:close, :gone] if rhead.nil?
414
436
 
415
437
  _rm, _rt, rheaders = parse_head(rhead)
416
- write_all(sock, rhead)
417
- write_all(sock, rbuf) unless rbuf.empty?
438
+ relay_after_head(sock, backend, rhead, rbuf, rheaders, headers, target)
439
+ end
418
440
 
419
- rlen = content_length(rheaders)
420
- if rlen.nil?
421
- # No Content-Length: close-delimited response.
422
- copy_stream(backend, sock)
423
- return [:close, nil]
441
+ # Everything from the response head onward. Once the head is on the
442
+ # wire the 502 vocabulary is spent: a failure here can only end the
443
+ # connection, never answer it, and rendering a 502 into the middle of
444
+ # a live 200 corrupts the stream the browser is already reading (the
445
+ # second head arrives as body bytes; the page dies of it with no
446
+ # error naming anything). [:close, :body_lost] tells handle to close
447
+ # quietly instead — the stall itself is logged with the request that
448
+ # died, since a relay that goes quiet mid-body is exactly the failure
449
+ # that is otherwise invisible.
450
+ def relay_after_head(sock, backend, rhead, rbuf, rheaders, headers, target)
451
+ begin
452
+ write_all(sock, rhead)
453
+
454
+ rlen = content_length(rheaders)
455
+ if chunked?(rheaders)
456
+ # The chunk relay reads the framing to find where the body ends,
457
+ # so it takes the read-ahead itself — handing it bytes already on
458
+ # the wire would send the body twice.
459
+ relay_chunked(backend, sock, rbuf)
460
+ else
461
+ write_all(sock, rbuf) unless rbuf.empty?
462
+
463
+ if rlen.nil?
464
+ # No Content-Length and no chunk framing: the body really does
465
+ # end at end of file, because the backend is closing to say so.
466
+ copy_stream(backend, sock)
467
+ return [:close, nil]
468
+ else
469
+ remaining = rlen - rbuf.bytesize
470
+ copy_stream(backend, sock, remaining) if remaining > 0
471
+ end
472
+ end
473
+ rescue EOFError
474
+ @on_error.call("backend for #{headers["host"]}#{target} closed mid-body, " \
475
+ "before its Content-Length of #{rheaders["content-length"]} was met")
476
+ return [:close, :body_lost]
477
+ rescue IdleTimeout => e
478
+ @on_error.call("response for #{headers["host"]}#{target} died mid-body: #{e.message}")
479
+ return [:close, :body_lost]
480
+ rescue IOError, SystemCallError
481
+ # The client or the backend hung up mid-relay — ordinary web
482
+ # traffic (a cancelled fetch, a departed tab), not a failure.
483
+ return [:close, :body_lost]
424
484
  end
425
- remaining = rlen - rbuf.bytesize
426
- copy_stream(backend, sock, remaining) if remaining > 0
427
485
 
428
486
  if keep_alive?(headers) && keep_alive?(rheaders)
429
487
  # Any bytes beyond Content-Length on the backend are a second
@@ -439,8 +497,13 @@ module Yamine
439
497
  return if rhead.nil?
440
498
 
441
499
  write_all(sock, rhead)
442
- write_all(sock, rbuf) unless rbuf.empty?
443
- copy_stream(backend, sock)
500
+ _rm, _rt, rheaders = parse_head(rhead)
501
+ if chunked?(rheaders)
502
+ relay_chunked(backend, sock, rbuf)
503
+ else
504
+ write_all(sock, rbuf) unless rbuf.empty?
505
+ copy_stream(backend, sock)
506
+ end
444
507
  rescue IOError, SystemCallError
445
508
  nil
446
509
  end
@@ -605,6 +668,79 @@ module Yamine
605
668
  end
606
669
  end
607
670
 
671
+ # Chunked framing is the one body the proxy has to read rather than
672
+ # copy, because it is the only thing that says where the body ends. It
673
+ # ends at the terminating chunk — not at end of file, and the backend
674
+ # holding the connection open past it is keep-alive doing exactly what
675
+ # it promised. Every streamed page, every ActionController::Live
676
+ # response and every event stream is framed this way.
677
+ #
678
+ # Bytes cross to the client exactly as the backend wrote them; only the
679
+ # framing is read, never rewritten. `buf` is the read-ahead already
680
+ # past the response head.
681
+ def relay_chunked(src, dst, buf)
682
+ loop do
683
+ line = take_line(src, buf)
684
+ return if line.nil?
685
+
686
+ write_all(dst, line)
687
+ size = chunk_size(line)
688
+ if size.zero?
689
+ relay_trailers(src, dst, buf)
690
+ return
691
+ end
692
+
693
+ write_all(dst, take_bytes(src, buf, size) || +"")
694
+ write_all(dst, take_line(src, buf) || +"")
695
+ end
696
+ end
697
+
698
+ # Trailers run to the blank line that closes them.
699
+ def relay_trailers(src, dst, buf)
700
+ loop do
701
+ line = take_line(src, buf)
702
+ return if line.nil?
703
+
704
+ write_all(dst, line)
705
+ return if line == CRLF
706
+ end
707
+ end
708
+
709
+ # A chunk-size line, less the CRLF that ends it. Extensions after a
710
+ # semicolon are the sender's and are not ours to weigh.
711
+ def chunk_size(line)
712
+ line.split(";", 2).first.to_s.strip.to_i(16)
713
+ end
714
+
715
+ def take_line(sock, buf)
716
+ until (index = buf.index(CRLF))
717
+ return nil unless fill_buf(sock, buf)
718
+ end
719
+ buf.slice!(0, index + 2)
720
+ end
721
+
722
+ def take_bytes(sock, buf, count)
723
+ out = +""
724
+ while out.bytesize < count
725
+ return nil if buf.empty? && !fill_buf(sock, buf)
726
+
727
+ take = [count - out.bytesize, buf.bytesize].min
728
+ out << buf.slice!(0, take)
729
+ end
730
+ out
731
+ end
732
+
733
+ def fill_buf(sock, buf)
734
+ buf << read_chunk(sock, CHUNK_BYTES)
735
+ true
736
+ rescue EOFError
737
+ false
738
+ end
739
+
740
+ def chunked?(headers)
741
+ headers["transfer-encoding"].to_s.downcase.include?("chunked")
742
+ end
743
+
608
744
  # Block until the socket is ready for the direction a nonblocking op
609
745
  # asked for; a timeout when the bound passes with no progress. A
610
746
  # caller-supplied bound only changes how long we wait — what a timeout
@@ -783,8 +919,7 @@ module Yamine
783
919
  # command to run. Modeled on render_not_found, which already answers
784
920
  # "nobody knows what this hostname is" properly.
785
921
  def render_bad_gateway(sock, entry, host, reason = :refused)
786
- kind = reason == :silent ? "backend-silent" : "backend-refused"
787
- headers = { ERROR_HEADER => kind }
922
+ headers = { ERROR_HEADER => error_kind(reason) }
788
923
  bare = Hostname.strip_port(host)
789
924
  # Same DNS-rebinding boundary as render_not_found: a Host outside our
790
925
  # TLDs is a website that got us to answer, not the local developer
@@ -793,16 +928,31 @@ module Yamine
793
928
 
794
929
  target = entry ? entry["target"].to_s : ""
795
930
  body = "<h1>Bad Gateway</h1><p>#{what_happened(reason, target)}</p>" \
796
- "#{bad_gateway_owner(entry, bare)}#{bad_gateway_fix(entry)}"
931
+ "#{bad_gateway_owner(entry, bare)}#{bad_gateway_fix(entry, reason)}"
797
932
  respond(sock, 502, body, headers: headers)
798
933
  rescue IOError, SystemCallError
799
934
  nil
800
935
  end
801
936
 
802
- # Which of the two failures this was, said the way each one has to be
803
- # read: not listening means start the app, silent means go find out why
804
- # an app that is up is not answering. One "not responding" for both
805
- # sends the reader to the wrong place either way.
937
+ # The machine-readable failure class. Agents branch on this without
938
+ # parsing the page, so each failure must carry its own name — a closed
939
+ # connection labeled "refused", or a proxy out of file descriptors
940
+ # labeled "refused", points the reader at the app when the app is fine.
941
+ def error_kind(reason)
942
+ case reason
943
+ when :silent then "backend-silent"
944
+ when :gone then "backend-gone"
945
+ when :exhausted then "proxy-exhausted"
946
+ when :unreachable then "proxy-unreachable"
947
+ else "backend-refused"
948
+ end
949
+ end
950
+
951
+ # Which of the failures this was, said the way each one has to be
952
+ # read: not listening means start the app, silent means go find out
953
+ # why an app that is up is not answering, exhausted means the trouble
954
+ # is the proxy's own resource limit. One "not responding" for all of
955
+ # them sends the reader to the wrong place every time.
806
956
  def what_happened(reason, target)
807
957
  at = target.empty? ? "" : " at <code>#{escape(target)}</code>"
808
958
  case reason
@@ -812,6 +962,12 @@ module Yamine
812
962
  when :gone
813
963
  "The backend#{at} accepted the connection, then closed it without " \
814
964
  "sending a response."
965
+ when :exhausted
966
+ "The proxy ran out of file descriptors dialing#{at} — the limit is the " \
967
+ "proxy process's own, not the app's, and the backend may be perfectly fine."
968
+ when :unreachable
969
+ "The proxy could not reach the backend#{at} — the address is wrong or the " \
970
+ "path there failed, which is not the same as nothing listening."
815
971
  else
816
972
  "Nothing is listening#{at}."
817
973
  end
@@ -832,7 +988,14 @@ module Yamine
832
988
  "<p>The backend for <strong>#{escape(host)}</strong> is registered#{by}#{where}.</p>"
833
989
  end
834
990
 
835
- def bad_gateway_fix(entry)
991
+ def bad_gateway_fix(entry, reason)
992
+ if [:exhausted, :unreachable].include?(reason)
993
+ # The app is not the suspect here, so the start command would be
994
+ # the wrong advice on a page that already says so.
995
+ return "<p>The backend looks up — check the proxy's own log: " \
996
+ "<code>proxy.log</code> in the yamine state dir.</p>"
997
+ end
998
+
836
999
  dir = entry&.dig("spec", "dir")
837
1000
  return "<p>Start it with <code>yamine start</code> in that app's directory.</p>" unless dir
838
1001
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.22.1"
4
+ VERSION = "0.23.1"
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.22.1
4
+ version: 0.23.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto