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,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "monitor"
5
+
6
+ module Yamine
7
+ # Daemon-owned supervision for managed socket apps (puma-dev model).
8
+ #
9
+ # The proxy daemon is long-lived and sees every request, so it owns:
10
+ # last-used tracking (idle kill), tmp/restart.txt watching, backend
11
+ # liveness, and boot-on-request for stopped apps. Run-mode (tcp)
12
+ # routes and static aliases are never supervised.
13
+ #
14
+ # State is in-memory (the daemon is the only supervisor); routes.json
15
+ # stays declarative. Killing is graceful-first with a short KILL
16
+ # fallback so puma drains.
17
+ class Supervisor
18
+ DEFAULT_IDLE = 900 # 15 minutes
19
+
20
+ attr_reader :interval
21
+
22
+ def initialize(store:, runner:, interval: 5.0, idle_timeout: nil, on_event: nil)
23
+ @store = store
24
+ @runner = runner
25
+ @interval = interval
26
+ @idle_timeout = idle_timeout || Supervisor.env_idle || DEFAULT_IDLE
27
+ @on_event = on_event || ->(msg) { warn "[yamine] #{msg}" }
28
+ # Monitor (reentrant): touch → st → state_for nest legitimately.
29
+ @state = {}.extend(MonitorMixin)
30
+ @boot_locks = {}
31
+ @thread = nil
32
+ end
33
+
34
+ def self.env_idle
35
+ v = ENV["YAMINE_IDLE_TIMEOUT"]
36
+ return nil unless v && v.to_f >= 0
37
+
38
+ v.to_f
39
+ end
40
+
41
+ def supervised?(route)
42
+ route["kind"] == "socket" && route["spec"].is_a?(Hash) && route["spec"]["dir"].is_a?(String)
43
+ end
44
+
45
+ def touch(hostname)
46
+ @state.synchronize do
47
+ st(hostname)[:last_used] = clock_now
48
+ end
49
+ end
50
+
51
+ # Boot a stopped app on demand. Returns the route (unchanged — the
52
+ # target path is deterministic) or nil on boot failure.
53
+ def ensure_running(route, timeout: 60)
54
+ return route unless supervised?(route)
55
+
56
+ socket_alive?(route) ? route : boot(route, timeout)
57
+ end
58
+
59
+ # Start the background supervision thread.
60
+ def start
61
+ @thread ||= Thread.new do
62
+ loop do
63
+ sleep @interval
64
+ begin
65
+ tick
66
+ rescue StandardError => e
67
+ @on_event.call("supervision error: #{e.message}")
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def stop
74
+ @thread&.kill
75
+ @thread = nil
76
+ end
77
+
78
+ # One supervision pass (public for tests): kills idle/dead/restarted
79
+ # backends. Rebooting happens lazily on the next request.
80
+ def tick(now = clock_now)
81
+ @store.load_routes.each do |route|
82
+ next unless supervised?(route)
83
+
84
+ hostname = route["hostname"]
85
+ st = state_for(hostname, route, now)
86
+ next if st[:restarting]
87
+
88
+ if restart_changed?(route, st)
89
+ @on_event.call("restart.txt changed for #{hostname} — stopping backend")
90
+ kill_backend(route)
91
+ st[:restarting] = true
92
+ elsif !socket_alive?(route)
93
+ @on_event.call("backend for #{hostname} is down — will boot on next request")
94
+ st[:restarting] = true
95
+ elsif idle?(st, now)
96
+ @on_event.call("#{hostname} idle — stopping backend (boots on next request)")
97
+ kill_backend(route)
98
+ st[:restarting] = true
99
+ end
100
+ end
101
+ end
102
+
103
+ # Kill every supervised backend (daemon shutdown).
104
+ def shutdown
105
+ @store.load_routes.each do |route|
106
+ kill_backend(route) if supervised?(route)
107
+ end
108
+ end
109
+
110
+ def idle?(st, now)
111
+ return false if @idle_timeout.zero?
112
+ return false unless st[:last_used]
113
+
114
+ (now - st[:last_used]) > @idle_timeout
115
+ end
116
+
117
+ def socket_alive?(route)
118
+ target = route["target"]
119
+ return false unless target && File.socket?(target)
120
+
121
+ UNIXSocket.new(target).close
122
+ true
123
+ rescue SystemCallError, IOError
124
+ false
125
+ end
126
+
127
+ def backend_pid(route)
128
+ sidecar = File.join(@store.dir, "backend-#{route["hostname"]}.pid")
129
+ return nil unless File.file?(sidecar)
130
+
131
+ pid = File.read(sidecar).strip.to_i
132
+ pid.positive? ? pid : nil
133
+ rescue SystemCallError, ArgumentError
134
+ nil
135
+ end
136
+
137
+ def kill_backend(route)
138
+ pid = backend_pid(route)
139
+ if pid && pid_alive?(pid)
140
+ begin
141
+ Process.kill("TERM", pid)
142
+ wait_exit(pid, 10)
143
+ rescue SystemCallError
144
+ nil
145
+ end
146
+ end
147
+ FileUtils.rm_f(route["target"]) if route["target"]
148
+ FileUtils.rm_f(File.join(@store.dir, "backend-#{route["hostname"]}.pid"))
149
+ end
150
+
151
+ private
152
+
153
+ def clock_now
154
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ end
156
+
157
+ def state_for(hostname, route, now)
158
+ @state.synchronize do
159
+ s = st(hostname)
160
+ # First sighting: baseline so a freshly booted app gets a full
161
+ # idle window and an existing restart.txt doesn't count as changed.
162
+ s[:restart_mtime] = restart_mtime(route) if s[:restart_mtime].nil?
163
+ s[:last_used] = now if s[:last_used].nil?
164
+ s
165
+ end
166
+ end
167
+
168
+ def st(hostname)
169
+ @state.synchronize { @state[hostname] ||= {} }
170
+ end
171
+
172
+ def restart_mtime(route)
173
+ path = restart_path(route)
174
+ path && File.exist?(path) ? File.mtime(path) : nil
175
+ rescue SystemCallError
176
+ nil
177
+ end
178
+
179
+ def restart_changed?(route, st)
180
+ path = restart_path(route)
181
+ return false unless path
182
+
183
+ current = File.exist?(path) ? File.mtime(path) : nil
184
+ if current != st[:restart_mtime]
185
+ st[:restart_mtime] = current
186
+ true
187
+ else
188
+ false
189
+ end
190
+ rescue SystemCallError
191
+ false
192
+ end
193
+
194
+ def restart_path(route)
195
+ dir = route.dig("spec", "dir")
196
+ dir ? File.join(dir, "tmp", "restart.txt") : nil
197
+ end
198
+
199
+ def boot(route, timeout)
200
+ hostname = route["hostname"]
201
+ lock = @boot_locks[hostname] ||= Mutex.new
202
+ lock.synchronize do
203
+ # Double-check under the boot lock.
204
+ return route if socket_alive?(route)
205
+
206
+ spec = route["spec"]
207
+ @on_event.call("booting #{hostname} on request (#{spec["dir"]})")
208
+ url = Hostname.url(hostname, port: ProxyControl.proxy_port(@store),
209
+ tls: ProxyControl.proxy_tls(@store))
210
+ @runner.boot_supervised(name: hostname, hostname: hostname,
211
+ url: url, dir: spec["dir"])
212
+ st = st(hostname)
213
+ st[:last_used] = clock_now
214
+ route
215
+ end
216
+ rescue StandardError => e
217
+ @on_event.call("boot failed for #{hostname}: #{e.message.lines.first&.strip}")
218
+ nil
219
+ end
220
+
221
+ def wait_exit(pid, timeout)
222
+ deadline = clock_now + timeout
223
+ while clock_now < deadline
224
+ return unless pid_alive?(pid)
225
+
226
+ sleep 0.2
227
+ end
228
+ begin
229
+ Process.kill("KILL", pid)
230
+ rescue SystemCallError
231
+ nil
232
+ end
233
+ end
234
+
235
+ def pid_alive?(pid)
236
+ Process.kill(0, pid)
237
+ true
238
+ rescue SystemCallError
239
+ false
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Yamine
6
+ # Install the local CA into the OS trust store.
7
+ module Trust
8
+ module_function
9
+
10
+ def platform
11
+ case RUBY_PLATFORM
12
+ when /darwin/ then :macos
13
+ when /linux/ then :linux
14
+ when /mingw|mswin/ then :windows
15
+ else :unknown
16
+ end
17
+ end
18
+
19
+ def trust(dir = Certs.state_dir)
20
+ paths = Certs.ensure_ca(dir)
21
+ case platform
22
+ when :macos then trust_macos(paths[:cert])
23
+ when :linux then trust_linux(paths[:cert])
24
+ when :windows then trust_windows(paths[:cert])
25
+ else return { trusted: false, error: "Unsupported platform: #{RUBY_PLATFORM}" }
26
+ end
27
+ Certs.mark_trusted(dir)
28
+ { trusted: true }
29
+ rescue StandardError => e
30
+ { trusted: false, error: e.message }
31
+ end
32
+
33
+ def trust_macos(cert_path)
34
+ if Process.uid.zero?
35
+ # Running elevated (service install / root proxy): add to the
36
+ # System keychain with the admin (-d) domain. Root can modify it
37
+ # silently — no GUI authorization popup, and every user's
38
+ # browsers trust the proxy.
39
+ _out, status = Command.capture2("security", "add-trusted-cert",
40
+ "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", cert_path)
41
+ raise CertError, "security add-trusted-cert (system) failed" unless status.success?
42
+ else
43
+ keychain = login_keychain
44
+ _out, status = Command.capture2("security", "add-trusted-cert",
45
+ "-r", "trustRoot", "-k", keychain, cert_path)
46
+ raise CertError, "security add-trusted-cert failed" unless status.success?
47
+ end
48
+ end
49
+
50
+ def login_keychain
51
+ out, status = Command.capture2("security", "default-keychain")
52
+ if status.success? && (m = out.match(/"(.+)"/))
53
+ m[1]
54
+ else
55
+ File.join(Certs.home, "Library", "Keychains", "login.keychain-db")
56
+ end
57
+ end
58
+
59
+ def trust_linux(cert_path)
60
+ dest_dir, update_cmd = linux_ca_config
61
+ FileUtils.mkdir_p(dest_dir)
62
+ FileUtils.cp(cert_path, File.join(dest_dir, "yamine-ca.crt"))
63
+ _out, status = Command.capture2(update_cmd)
64
+ raise CertError, "#{update_cmd} failed" unless status.success?
65
+ end
66
+
67
+ def linux_ca_config
68
+ os_release = begin
69
+ File.read("/etc/os-release").downcase
70
+ rescue SystemCallError
71
+ ""
72
+ end
73
+ if os_release.include?("arch")
74
+ ["/etc/ca-certificates/trust-source/anchors", "update-ca-trust"]
75
+ elsif os_release.match?(/fedora|rhel|centos/)
76
+ ["/etc/pki/ca-trust/source/anchors", "update-ca-trust"]
77
+ elsif os_release.include?("suse")
78
+ ["/etc/pki/trust/anchors", "update-ca-certificates"]
79
+ else
80
+ ["/usr/local/share/ca-certificates", "update-ca-certificates"]
81
+ end
82
+ end
83
+
84
+ def trust_windows(cert_path)
85
+ _out, status = Command.capture2("certutil", "-addstore", "-user", "Root", cert_path)
86
+ raise CertError, "certutil failed" unless status.success?
87
+ end
88
+
89
+ # Best-effort removal of the CA from the OS trust store. Only
90
+ # attempts when our marker says we trusted it; used by `clean`.
91
+ def untrust(dir = Certs.state_dir)
92
+ return { removed: true } unless Certs.trusted?(dir)
93
+
94
+ paths = Certs.ca_paths(dir)
95
+ errors = []
96
+ case platform
97
+ when :macos
98
+ Command.capture2("security", "remove-trusted-cert", paths[:cert])
99
+ # delete-certificate fails silently when no match remains; loop
100
+ # to clear duplicate CN entries from each keychain.
101
+ [login_keychain, "/Library/Keychains/System.keychain"].each do |kc|
102
+ 5.times do
103
+ Command.capture2("security", "delete-certificate", "-c", Certs::CA_COMMON_NAME, kc)
104
+ end
105
+ rescue SystemCallError
106
+ nil
107
+ end
108
+ when :linux
109
+ dest_dir, update_cmd = linux_ca_config
110
+ dest = File.join(dest_dir, "yamine-ca.crt")
111
+ FileUtils.rm_f(dest) if File.file?(dest)
112
+ Command.capture2(update_cmd)
113
+ when :windows
114
+ Command.capture2("certutil", "-delstore", "-user", "Root", Certs::CA_COMMON_NAME)
115
+ end
116
+ trusted_after = begin
117
+ Certs.trusted?(dir)
118
+ rescue StandardError
119
+ false
120
+ end
121
+ File.unlink(File.join(dir, "ca.trusted")) if File.file?(File.join(dir, "ca.trusted"))
122
+ if trusted_after
123
+ { removed: false, error: errors.empty? ? "CA still trusted (remove manually)" : errors.join("; ") }
124
+ else
125
+ { removed: true }
126
+ end
127
+ rescue StandardError => e
128
+ { removed: false, error: e.message }
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module Yamine
7
+ # Variant resolution: the malleable axis of the hostname.
8
+ #
9
+ # Precedence: explicit --variant flag -> YAMINE_VARIANT env ->
10
+ # linked git worktree branch -> current git branch (opt-in via
11
+ # --branch / YAMINE_BRANCH=1) -> none.
12
+ #
13
+ # Main/master (and detached HEAD) never produce a variant.
14
+ module Variant
15
+ DEFAULT_BRANCHES = %w[main master].freeze
16
+
17
+ module_function
18
+
19
+ # Returns [variant, source] or nil.
20
+ def resolve(cwd = Dir.pwd, explicit: nil, use_branch: false)
21
+ return labeled(explicit, "flag") if present?(explicit)
22
+
23
+ env = ENV["YAMINE_VARIANT"]
24
+ return labeled(env, "YAMINE_VARIANT") if present?(env)
25
+
26
+ worktree = worktree_prefix(cwd)
27
+ return [worktree, "git worktree"] if worktree
28
+
29
+ branch_flag = use_branch || %w[1 true].include?(ENV["YAMINE_BRANCH"])
30
+ return nil unless branch_flag
31
+
32
+ branch = current_branch(cwd)
33
+ prefix = branch_to_prefix(branch)
34
+ prefix ? [prefix, "git branch"] : nil
35
+ end
36
+
37
+ def apply(base_name, variant)
38
+ variant ? "#{variant}.#{base_name}" : base_name
39
+ end
40
+
41
+ # NOTE: do not add a `private` keyword in this module — it would
42
+ # cancel `module_function` mode and demote the helpers below to
43
+ # plain private instance methods. They stay module functions
44
+ # (private as instance methods) by omitting it.
45
+ def present?(value)
46
+ !value.nil? && !value.to_s.strip.empty?
47
+ end
48
+
49
+ def labeled(value, source)
50
+ label = Sanitize.hostname_label(value)
51
+ label.empty? ? nil : [label, source]
52
+ end
53
+
54
+ def branch_to_prefix(branch)
55
+ return nil if branch.nil? || branch.empty?
56
+ return nil if branch == "HEAD" || DEFAULT_BRANCHES.include?(branch)
57
+
58
+ last = branch.split("/").last.to_s
59
+ label = Sanitize.hostname_label(last)
60
+ label.empty? ? nil : label
61
+ end
62
+
63
+ # Only linked worktrees (created via `git worktree add`) get a prefix.
64
+ # Developers on feature branches in their main checkout keep the bare name.
65
+ def worktree_prefix(cwd)
66
+ list_out, list_status = git(cwd, "worktree", "list", "--porcelain")
67
+ return nil unless list_status.success?
68
+
69
+ count = list_out.lines.count { |l| l.start_with?("worktree ") }
70
+ return nil if count <= 1
71
+
72
+ git_dir, s1 = git(cwd, "rev-parse", "--git-dir")
73
+ common_dir, s2 = git(cwd, "rev-parse", "--git-common-dir")
74
+ return nil unless s1.success? && s2.success?
75
+
76
+ # Same dir => main worktree, no prefix.
77
+ expanded = File.expand_path(git_dir.strip, cwd)
78
+ expanded_common = File.expand_path(common_dir.strip, cwd)
79
+ return nil if expanded == expanded_common
80
+
81
+ branch, s3 = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
82
+ return nil unless s3.success?
83
+
84
+ branch_to_prefix(branch.strip)
85
+ rescue SystemCallError
86
+ filesystem_worktree_prefix(cwd)
87
+ end
88
+
89
+ # Fallback when git CLI is unavailable: a linked worktree has a .git
90
+ # FILE pointing into a /worktrees/ path (submodules point to /modules/).
91
+ def filesystem_worktree_prefix(cwd)
92
+ dir = Pathname.new(File.expand_path(cwd))
93
+ until dir.root?
94
+ git_path = dir.join(".git")
95
+ if git_path.file?
96
+ content = git_path.read.strip
97
+ match = content.match(/\Agitdir:\s*(.+)\z/)
98
+ if match && match[1].match?(%r{[/\\]worktrees[/\\][^/\\]+\z})
99
+ head = File.join(File.expand_path(match[1], dir.to_s), "HEAD")
100
+ branch = read_branch_from_head(head)
101
+ prefix = branch_to_prefix(branch.to_s)
102
+ return prefix ? [prefix, "git worktree"].first : nil
103
+ end
104
+ return nil
105
+ end
106
+ return nil if git_path.directory?
107
+
108
+ dir = dir.parent
109
+ end
110
+ nil
111
+ rescue SystemCallError
112
+ nil
113
+ end
114
+
115
+ def read_branch_from_head(head_path)
116
+ content = File.read(head_path).strip
117
+ match = content.match(%r{\Aref:\s*refs/heads/(.+)\z})
118
+ match && match[1]
119
+ rescue SystemCallError
120
+ nil
121
+ end
122
+
123
+ def current_branch(cwd)
124
+ out, status = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
125
+ status.success? ? out.strip : nil
126
+ end
127
+
128
+ def git(cwd, *args)
129
+ Open3.capture2("git", *args, chdir: cwd, err: File::NULL)
130
+ rescue SystemCallError, ArgumentError
131
+ ["", nil]
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamine
4
+ VERSION = "0.3.0"
5
+ end
data/lib/yamine.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "yamine/version"
4
+ require_relative "yamine/errors"
5
+ require_relative "yamine/sanitize"
6
+ require_relative "yamine/hostname"
7
+ require_relative "yamine/inference"
8
+ require_relative "yamine/variant"
9
+ require_relative "yamine/framework"
10
+ require_relative "yamine/config"
11
+ require_relative "yamine/ownership"
12
+ require_relative "yamine/command"
13
+ require_relative "yamine/log"
14
+ require_relative "yamine/route_store"
15
+ require_relative "yamine/certs"
16
+ require_relative "yamine/ports"
17
+ require_relative "yamine/hosts"
18
+ require_relative "yamine/proxy"
19
+ require_relative "yamine/proxy_control"
20
+ require_relative "yamine/supervisor"
21
+ require_relative "yamine/runner"
22
+ require_relative "yamine/resolver"
23
+ require_relative "yamine/trust"
24
+ require_relative "yamine/doctor"
25
+ require_relative "yamine/cli/context"
26
+ require_relative "yamine/procfile"
27
+ require_relative "yamine/cli/boot"
28
+ require_relative "yamine/cli/routes"
29
+ require_relative "yamine/cli/system"
30
+ require_relative "yamine/cli"
31
+
32
+ # Stable named .localhost URLs for Ruby development.
33
+ #
34
+ # Yamine replaces memorized ports with stable hostnames:
35
+ # `yamine` in your app dir boots it at https://<app>.localhost.
36
+ module Yamine
37
+ end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yamine
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.25'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.25'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mocha
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ description: Gives every Ruby app a stable https://<app>.localhost URL instead of
69
+ a memorized port. Explicit-run reverse proxy with zero-config name inference, git-worktree
70
+ variants, per-host TLS, and agent-friendly list/get/doctor commands. Ruby stdlib
71
+ only.
72
+ email:
73
+ - kaka@myrrlabs.com
74
+ executables:
75
+ - yamine
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - CHANGELOG.md
80
+ - LICENSE
81
+ - README.md
82
+ - bin/yamine
83
+ - lib/ask/skills/yamine/SKILL.md
84
+ - lib/yamine.rb
85
+ - lib/yamine/certs.rb
86
+ - lib/yamine/cli.rb
87
+ - lib/yamine/cli/boot.rb
88
+ - lib/yamine/cli/context.rb
89
+ - lib/yamine/cli/routes.rb
90
+ - lib/yamine/cli/system.rb
91
+ - lib/yamine/command.rb
92
+ - lib/yamine/config.rb
93
+ - lib/yamine/doctor.rb
94
+ - lib/yamine/errors.rb
95
+ - lib/yamine/framework.rb
96
+ - lib/yamine/hostname.rb
97
+ - lib/yamine/hosts.rb
98
+ - lib/yamine/inference.rb
99
+ - lib/yamine/log.rb
100
+ - lib/yamine/ownership.rb
101
+ - lib/yamine/ports.rb
102
+ - lib/yamine/procfile.rb
103
+ - lib/yamine/proxy.rb
104
+ - lib/yamine/proxy_control.rb
105
+ - lib/yamine/resolver.rb
106
+ - lib/yamine/route_store.rb
107
+ - lib/yamine/runner.rb
108
+ - lib/yamine/sanitize.rb
109
+ - lib/yamine/supervisor.rb
110
+ - lib/yamine/trust.rb
111
+ - lib/yamine/variant.rb
112
+ - lib/yamine/version.rb
113
+ homepage: https://github.com/ask-rb/yamine
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ homepage_uri: https://github.com/ask-rb/yamine
118
+ source_code_uri: https://github.com/ask-rb/yamine
119
+ changelog_uri: https://github.com/ask-rb/yamine/blob/master/CHANGELOG.md
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '3.2'
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 4.0.18
135
+ specification_version: 4
136
+ summary: Stable named .localhost URLs for Ruby development
137
+ test_files: []