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,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamine
4
+ class CLI
5
+ # Route and app-lifecycle commands: get/alias/list/prune,
6
+ # stop/restart/log/status/open.
7
+ module RoutesCommand
8
+ module_function
9
+
10
+ # yamine get <name> [--service x] [--variant y] [--tld z]
11
+ #
12
+ # Variant and TLDs are inherited from the CURRENT directory's
13
+ # context (worktree branch, YAMINE_* env, config) so cross-service
14
+ # wiring works inside a variant: from a fix-ui worktree,
15
+ # `get backend` -> https://fix-ui.backend.localhost.
16
+ def get(ctx, args)
17
+ name = args.first
18
+ raise Error, "Usage: yamine get <name> [--service s] [--variant v] [--tld t]" unless name
19
+
20
+ opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
21
+ context = Resolver.resolve(Dir.pwd, variant: opts[:variant])
22
+ hostnames = Hostname.build(
23
+ app: Sanitize.hostname_label(name),
24
+ tlds: Array(context.tld || Yamine::Hostname::DEFAULT_TLD),
25
+ variant: context.variant
26
+ )
27
+ puts Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
28
+ end
29
+
30
+ def list(ctx, args)
31
+ json = args.delete("--json")
32
+ routes = ctx.store.load_routes
33
+ port = ctx.proxy_port
34
+ tls = ctx.proxy_tls
35
+ entries = routes.map do |r|
36
+ { hostname: r["hostname"],
37
+ url: Hostname.url(r["hostname"], port: port, tls: tls),
38
+ target: r["target"], kind: r["kind"],
39
+ pid: r["pid"], supervised: !r["spec"].nil?,
40
+ alive: alive_state(ctx, r) }
41
+ end
42
+ if json
43
+ require "json"
44
+ puts JSON.generate({ routes: entries, proxy_port: port, tls: tls })
45
+ return
46
+ end
47
+ if entries.empty?
48
+ puts "No active routes."
49
+ puts "Start an app with: yamine"
50
+ return
51
+ end
52
+ puts "\nActive routes:\n"
53
+ entries.each do |e|
54
+ puts " #{e[:url]} -> #{e[:target]} #{label_for(e)}"
55
+ end
56
+ puts
57
+ end
58
+
59
+ def alive_state(ctx, route)
60
+ if route["pid"] == 0
61
+ ctx.backend_alive?(route) ? "reachable" : "unreachable"
62
+ elsif ProxyControl.pid_alive?(route["pid"])
63
+ "running"
64
+ else
65
+ "owner-gone"
66
+ end
67
+ rescue StandardError
68
+ "unknown"
69
+ end
70
+
71
+ def label_for(entry)
72
+ if entry[:pid] == 0
73
+ "(alias, #{entry[:alive]})"
74
+ else
75
+ "(pid #{entry[:pid]}, #{entry[:alive]})"
76
+ end
77
+ end
78
+
79
+ # Backend liveness per route: agents can see at a glance whether
80
+ # the route points at something alive. Static aliases (pid 0)
81
+ # report the probe, not a process.
82
+ def route_label(ctx, route)
83
+ entry = { pid: route["pid"], alive: alive_state(ctx, route) }
84
+ label_for(entry)
85
+ end
86
+
87
+ def prune(ctx, _args)
88
+ stale = ctx.store.prune_stale
89
+ if stale.empty?
90
+ puts "No stale routes."
91
+ else
92
+ stale.each { |r| puts "Removed stale route #{r["hostname"]}" }
93
+ end
94
+ end
95
+
96
+ def alias_add(ctx, args)
97
+ if args.first == "--remove"
98
+ name = args[1] or raise Error, "Usage: yamine alias --remove <name>"
99
+ hostname = alias_hostname(name)
100
+ ctx.store.remove_route(hostname)
101
+ puts "Removed alias #{hostname}."
102
+ return
103
+ end
104
+ name, port_or_url = args
105
+ raise Error, "Usage: yamine alias <name> <port|url>" unless name && port_or_url
106
+
107
+ hostname = alias_hostname(name)
108
+ target = port_or_url.match?(/\A\d+\z/) ? "127.0.0.1:#{port_or_url}" : port_or_url
109
+ force = args.include?("--force")
110
+ ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force)
111
+ puts "#{hostname} -> #{target}"
112
+ end
113
+
114
+ # A name containing dots is treated as a full hostname (any TLD);
115
+ # otherwise it is a label under the current TLD context
116
+ # (YAMINE_TLD first entry, else localhost).
117
+ def alias_hostname(name)
118
+ return Hostname.strip_port(name.downcase) if name.include?(".")
119
+
120
+ tld = ENV["YAMINE_TLD"]&.split(",")&.map(&:strip)&.reject(&:empty?)&.first
121
+ "#{Sanitize.hostname_label(name)}.#{tld || Hostname::DEFAULT_TLD}"
122
+ end
123
+
124
+ # Stop the app in the current directory (route + backend).
125
+ # Exit codes are machine-readable for agents: 0 stopped something,
126
+ # 2 no route here, 3 route existed but the backend was already gone.
127
+ def stop(ctx, _args, out: $stdout)
128
+ resolved = Resolver.resolve(Dir.pwd)
129
+ hostnames = Resolver.hostnames(resolved)
130
+ stopped = []
131
+ gone = []
132
+ hostnames.each do |hostname|
133
+ entry = ctx.store.find(hostname)
134
+ next unless entry
135
+
136
+ backend_pid = ctx.backend_pid_for(entry)
137
+ if backend_pid && ProxyControl.pid_alive?(backend_pid)
138
+ begin
139
+ Process.kill("TERM", backend_pid)
140
+ if ctx.wait_for_exit(backend_pid, timeout: 10)
141
+ stopped << "#{hostname} (backend #{backend_pid})"
142
+ else
143
+ stopped << "#{hostname} (backend #{backend_pid} still draining)"
144
+ end
145
+ rescue SystemCallError
146
+ gone << hostname
147
+ end
148
+ else
149
+ gone << hostname
150
+ end
151
+ ctx.store.remove_route(hostname)
152
+ FileUtils.rm_f(File.join(ctx.store.dir, "backend-#{hostname}.pid"))
153
+ end
154
+ if stopped.any?
155
+ stopped.each { |s| out.puts "Stopped #{s}." }
156
+ return 0
157
+ end
158
+ if gone.any?
159
+ out.puts "Route existed but the backend was already gone: #{gone.join(", ")}."
160
+ return 3
161
+ end
162
+
163
+ out.puts "No yamine app running here."
164
+ 2
165
+ end
166
+
167
+ # Touch tmp/restart.txt so a supervised managed app reboots.
168
+ def restart(_ctx, _args)
169
+ path = File.join(Dir.pwd, "tmp", "restart.txt")
170
+ require "fileutils"
171
+ FileUtils.mkdir_p(File.dirname(path))
172
+ FileUtils.touch(path)
173
+ puts "Touched #{path} — managed app restarts on next request."
174
+ end
175
+
176
+ # Tail the app log (default 50 lines); --follow streams.
177
+ def log(ctx, args)
178
+ follow = args.delete("--follow") || args.delete("-f")
179
+ lines = (args.first || 50).to_i
180
+ resolved = Resolver.resolve(Dir.pwd)
181
+ path = File.expand_path(File.join(Dir.pwd, "log", "yamine-#{resolved.app}.log"))
182
+ unless File.file?(path)
183
+ puts "No log at #{path} yet."
184
+ return
185
+ end
186
+ if follow
187
+ exec("tail", "-F", "-n", lines.to_s, path)
188
+ else
189
+ puts File.readlines(path).last(lines).join
190
+ end
191
+ end
192
+
193
+ # Print the effective naming context for this directory: what
194
+ # `yamine` would boot here and why. Answers "why did I get
195
+ # this URL" without booting anything. --json emits stable keys
196
+ # for agents instead of prose.
197
+ def status(_ctx, args)
198
+ json = args.delete("--json")
199
+ resolved = Resolver.resolve(Dir.pwd)
200
+ hostnames = Resolver.hostnames(resolved)
201
+ urls = hostnames.map do |h|
202
+ Hostname.url(h, port: ProxyControl.default_port(true), tls: true)
203
+ end
204
+ payload = {
205
+ app: resolved.app, app_source: resolved.sources[:app],
206
+ tld: resolved.tld, tld_source: resolved.sources[:tld],
207
+ host: resolved.host, host_source: resolved.sources[:host],
208
+ variant: resolved.variant, variant_source: resolved.sources[:variant],
209
+ urls: urls,
210
+ processes: resolved.processes.keys,
211
+ framework: Framework.detect(Dir.pwd).to_s
212
+ }
213
+ if json
214
+ require "json"
215
+ puts JSON.generate(payload)
216
+ return
217
+ end
218
+ puts "app: #{payload[:app]} (from #{payload[:app_source]})"
219
+ if payload[:host]
220
+ puts "host: #{payload[:host]} (from #{payload[:host_source]})"
221
+ else
222
+ puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
223
+ end
224
+ puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no overlay file, flag, or env"})"
225
+ puts "processes: #{payload[:processes].join(", ")}"
226
+ puts "urls:"
227
+ urls.each { |u| puts " #{u}" }
228
+ puts "framework: #{payload[:framework]}"
229
+ end
230
+
231
+ # Open the app URL in the default browser (macOS `open`).
232
+ def open(ctx, args)
233
+ name = args.first
234
+ url =
235
+ if name
236
+ opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
237
+ context = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld])
238
+ hostnames = Hostname.build(app: Sanitize.hostname_label(name),
239
+ tlds: Array(context.tld || Yamine::Hostname::DEFAULT_TLD),
240
+ variant: context.variant)
241
+ Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
242
+ else
243
+ resolved = Resolver.resolve(Dir.pwd)
244
+ Hostname.url(Resolver.hostnames(resolved).first,
245
+ port: ctx.proxy_port, tls: ctx.proxy_tls)
246
+ end
247
+ case RUBY_PLATFORM
248
+ when /darwin/ then exec("open", url)
249
+ when /linux/ then exec("xdg-open", url)
250
+ else puts url
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end