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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +243 -0
- data/LICENSE +21 -0
- data/README.md +294 -0
- data/bin/yamine +6 -0
- data/lib/ask/skills/yamine/SKILL.md +113 -0
- data/lib/yamine/certs.rb +177 -0
- data/lib/yamine/cli/boot.rb +247 -0
- data/lib/yamine/cli/context.rb +104 -0
- data/lib/yamine/cli/routes.rb +255 -0
- data/lib/yamine/cli/system.rb +768 -0
- data/lib/yamine/cli.rb +108 -0
- data/lib/yamine/command.rb +28 -0
- data/lib/yamine/config.rb +394 -0
- data/lib/yamine/doctor.rb +133 -0
- data/lib/yamine/errors.rb +18 -0
- data/lib/yamine/framework.rb +75 -0
- data/lib/yamine/hostname.rb +44 -0
- data/lib/yamine/hosts.rb +86 -0
- data/lib/yamine/inference.rb +142 -0
- data/lib/yamine/log.rb +59 -0
- data/lib/yamine/ownership.rb +53 -0
- data/lib/yamine/ports.rb +45 -0
- data/lib/yamine/procfile.rb +137 -0
- data/lib/yamine/proxy.rb +489 -0
- data/lib/yamine/proxy_control.rb +222 -0
- data/lib/yamine/resolver.rb +92 -0
- data/lib/yamine/route_store.rb +148 -0
- data/lib/yamine/runner.rb +243 -0
- data/lib/yamine/sanitize.rb +41 -0
- data/lib/yamine/supervisor.rb +242 -0
- data/lib/yamine/trust.rb +131 -0
- data/lib/yamine/variant.rb +134 -0
- data/lib/yamine/version.rb +5 -0
- data/lib/yamine.rb +37 -0
- metadata +137 -0
|
@@ -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
|
data/lib/yamine/trust.rb
ADDED
|
@@ -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
|
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: []
|