ruby_everywhere 0.9.1 → 0.10.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 +4 -4
- data/lib/everywhere/commands/preview.rb +137 -9
- data/lib/everywhere/dock/footer.rb +43 -0
- data/lib/everywhere/dock/info.rb +72 -0
- data/lib/everywhere/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: effe761f70b4c3220e02788d26a8fc49278a52f3e3876c331e3f9f484867592f
|
|
4
|
+
data.tar.gz: 9e589059bea3245ad9540696a24edfb9261736e0d1f30467698aad051c78541e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca496e477bd712bc8a8687916f8f9c17e1a3736d944f245bd633812a479d7771e1457107dd3e7135a2536657e70c4a5aecebe6a5c3df5ee5d12428fe70b423ba
|
|
7
|
+
data.tar.gz: 28e6a3a1c4a6c6c90a455ad545354a1d6812ea3bd1c3ef57a6a1d252c7e7ddf30d5edf7375a7b99e53dd15e6e059c70b11c55ef7070f2df90906b9ca2b13f36b
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "dry/cli"
|
|
4
|
+
require "fileutils"
|
|
4
5
|
require "socket"
|
|
5
6
|
require "securerandom"
|
|
6
7
|
require "shellwords"
|
|
7
8
|
require "timeout"
|
|
8
9
|
require "uri"
|
|
10
|
+
require "yaml"
|
|
9
11
|
require_relative "../shellout"
|
|
10
12
|
require_relative "../console"
|
|
11
13
|
require_relative "../child_processes"
|
|
12
14
|
require_relative "../child_supervision"
|
|
15
|
+
require_relative "../dock/info"
|
|
13
16
|
require_relative "../framework"
|
|
14
17
|
require_relative "../config"
|
|
15
18
|
require_relative "../jump"
|
|
19
|
+
require_relative "../paths"
|
|
20
|
+
require_relative "../raw_tty"
|
|
16
21
|
|
|
17
22
|
module Everywhere
|
|
18
23
|
module Commands
|
|
@@ -70,12 +75,18 @@ module Everywhere
|
|
|
70
75
|
|
|
71
76
|
start_server(port)
|
|
72
77
|
warn_if_loopback_only if lan
|
|
78
|
+
# Everything below this line scrolls: the dev server's log starts
|
|
79
|
+
# flowing the moment a device connects, and the invitation was gone off
|
|
80
|
+
# the top of the screen with it. The dock pins it instead.
|
|
81
|
+
@dock = Dock::Info.open(keys: $stdin.tty?)
|
|
73
82
|
announce
|
|
74
83
|
wait
|
|
75
84
|
rescue Interrupt
|
|
76
85
|
nil
|
|
77
86
|
ensure
|
|
78
|
-
|
|
87
|
+
# The dock has to let go of the terminal before anything else prints —
|
|
88
|
+
# teardown_children's own warnings included.
|
|
89
|
+
teardown_children(before: [-> { @dock&.close }])
|
|
79
90
|
end
|
|
80
91
|
|
|
81
92
|
private
|
|
@@ -115,6 +126,7 @@ module Everywhere
|
|
|
115
126
|
def start_tunnel
|
|
116
127
|
if @tunnel_name
|
|
117
128
|
UI.step("starting named tunnel #{UI.bold(@tunnel_name)} → #{UI.cyan("https://#{@tunnel_host}")}")
|
|
129
|
+
warn_if_ingress_mismatch
|
|
118
130
|
spawn_tunnel("cloudflared tunnel run --url http://127.0.0.1:#{@port} #{@tunnel_name.shellescape}")
|
|
119
131
|
"https://#{@tunnel_host}"
|
|
120
132
|
else
|
|
@@ -128,7 +140,7 @@ module Everywhere
|
|
|
128
140
|
# and the on_line hook fishes out the URL.
|
|
129
141
|
def quick_tunnel_url
|
|
130
142
|
found = Queue.new
|
|
131
|
-
spawn_tunnel("cloudflared tunnel --url http://127.0.0.1:#{@port}",
|
|
143
|
+
spawn_tunnel("cloudflared --config #{blank_config.shellescape} tunnel --url http://127.0.0.1:#{@port}",
|
|
132
144
|
on_line: ->(line) { (url = line[TRYCLOUDFLARE_URL]) && found << url },
|
|
133
145
|
quiet: true)
|
|
134
146
|
begin
|
|
@@ -139,6 +151,62 @@ module Everywhere
|
|
|
139
151
|
end
|
|
140
152
|
end
|
|
141
153
|
|
|
154
|
+
# Where cloudflared looks for a local config, in its own search order.
|
|
155
|
+
CLOUDFLARED_CONFIGS = ["~/.cloudflared/config.yml", "~/.cloudflared/config.yaml",
|
|
156
|
+
"/etc/cloudflared/config.yml", "/usr/local/etc/cloudflared/config.yml"].freeze
|
|
157
|
+
|
|
158
|
+
# A named tunnel keeps the user's cloudflared config on purpose — its
|
|
159
|
+
# credentials live there — but when that config declares ingress rules
|
|
160
|
+
# they beat our --url: cloudflared routes by Host header. A host with no
|
|
161
|
+
# matching rule falls to the catch-all (conventionally http_status:404);
|
|
162
|
+
# a rule aimed at another port reaches the wrong server. Both look like
|
|
163
|
+
# a dead preview with no hint of why, so say so before it happens.
|
|
164
|
+
# Best-effort: a config we can't read just means cloudflared honors --url.
|
|
165
|
+
def warn_if_ingress_mismatch
|
|
166
|
+
rules = cloudflared_ingress
|
|
167
|
+
return if rules.nil? || rules.empty?
|
|
168
|
+
|
|
169
|
+
rule = rules.find { |r| r["hostname"].nil? || host_matches?(r["hostname"], @tunnel_host) }
|
|
170
|
+
service = rule&.fetch("service", nil).to_s
|
|
171
|
+
if rule.nil? || service.start_with?("http_status:")
|
|
172
|
+
UI.warn("your cloudflared config's ingress has no rule for #{@tunnel_host}, so requests will get " \
|
|
173
|
+
"the catch-all#{" (#{service})" unless service.empty?} — add " \
|
|
174
|
+
"{hostname: #{@tunnel_host}, service: http://localhost:#{@port}} above it")
|
|
175
|
+
elsif (svc_port = service[/:(\d+)\z/, 1]) && Integer(svc_port) != @port
|
|
176
|
+
UI.warn("your cloudflared config's ingress routes #{@tunnel_host} to #{service}, but this preview " \
|
|
177
|
+
"runs on port #{@port} — point that rule at the preview, or match it with --port #{svc_port}")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def cloudflared_ingress(paths: CLOUDFLARED_CONFIGS)
|
|
182
|
+
path = paths.map { |p| File.expand_path(p) }.find { |p| File.file?(p) }
|
|
183
|
+
return nil unless path
|
|
184
|
+
|
|
185
|
+
config = YAML.safe_load(File.read(path))
|
|
186
|
+
rules = config.is_a?(Hash) ? config["ingress"] : nil
|
|
187
|
+
rules.is_a?(Array) ? rules.grep(Hash) : nil
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# cloudflared ingress hostnames may hold a * wildcard; fnmatch covers it.
|
|
193
|
+
def host_matches?(pattern, host)
|
|
194
|
+
File.fnmatch?(pattern.to_s, host.to_s, File::FNM_CASEFOLD)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# cloudflared reads ~/.cloudflared/config.yml even in quick-tunnel mode,
|
|
198
|
+
# and config ingress rules win over --url: a leftover named-tunnel config
|
|
199
|
+
# sees a *.trycloudflare.com Host, matches nothing, and its catch-all
|
|
200
|
+
# (conventionally http_status:404) answers every request. A blank config
|
|
201
|
+
# keeps the quick tunnel self-contained. Named tunnels (--tunnel-name)
|
|
202
|
+
# still read the user's config on purpose — their credentials live there.
|
|
203
|
+
def blank_config
|
|
204
|
+
path = File.join(Paths.cache_dir, "cloudflared-blank.yml")
|
|
205
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
206
|
+
File.write(path, "")
|
|
207
|
+
path
|
|
208
|
+
end
|
|
209
|
+
|
|
142
210
|
# quiet drops the child's routine chatter but always lets errors
|
|
143
211
|
# through — a quick tunnel that can't connect must say why.
|
|
144
212
|
def spawn_tunnel(cmd, on_line: nil, quiet: false)
|
|
@@ -205,13 +273,26 @@ module Everywhere
|
|
|
205
273
|
end
|
|
206
274
|
|
|
207
275
|
def announce
|
|
208
|
-
link = share_link || connect_url
|
|
276
|
+
@link = share_link || connect_url
|
|
209
277
|
UI.step("preview of #{UI.bold(@config.name)} is live → #{UI.cyan(@url)}")
|
|
210
|
-
|
|
211
|
-
Console.print(" Scan with your phone's camera, or send:\n\n #{UI.cyan(link)}\n\n")
|
|
278
|
+
show_qr
|
|
212
279
|
Console.print(" #{UI.dim("Manual entry in Jump:")} #{@url} #{UI.dim("token:")} #{@token}\n")
|
|
213
280
|
Console.print(UI.dim(" The link and QR die with this session — Ctrl-C to stop.\n"))
|
|
214
281
|
UI.note("waiting for devices…", marker: "⌁", color: :cyan)
|
|
282
|
+
@dock.show(footer_facts)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# What stays on screen. The QR is ~17 rows — far too tall to pin — so the
|
|
286
|
+
# footer carries what a person can act on without it: the link to forward,
|
|
287
|
+
# and the URL and token for Jump's manual entry form.
|
|
288
|
+
def footer_facts = [["link", @link], ["url", @url], ["token", @token]]
|
|
289
|
+
|
|
290
|
+
# Printed at announce time and again on `s`, once the logs have scrolled
|
|
291
|
+
# the code away. A non-tty stdout gets the link alone — an ANSI QR down a
|
|
292
|
+
# pipe is noise.
|
|
293
|
+
def show_qr
|
|
294
|
+
Console.print(qr_ansi(@link)) if $stdout.tty?
|
|
295
|
+
Console.print(" Scan with your phone's camera, or send:\n\n #{UI.cyan(@link)}\n\n")
|
|
215
296
|
end
|
|
216
297
|
|
|
217
298
|
# Half-block rendering: ▀ painted fg-for-top-module, bg-for-bottom packs
|
|
@@ -246,14 +327,23 @@ module Everywhere
|
|
|
246
327
|
|
|
247
328
|
# --- the wait loop --------------------------------------------------------
|
|
248
329
|
|
|
330
|
+
# RawTty only when the dock is offering keys: it puts the terminal in
|
|
331
|
+
# -icanon -echo for the whole process, which a piped or CI run has no use
|
|
332
|
+
# for — and its ensure is what puts the terminal back, so the watch loop
|
|
333
|
+
# has to run inside it rather than beside it.
|
|
334
|
+
def wait
|
|
335
|
+
@dock.keys? ? RawTty.with { watch } : watch
|
|
336
|
+
end
|
|
337
|
+
|
|
249
338
|
# The server's death ends the session; the tunnel's doesn't. Quick
|
|
250
339
|
# tunnels are explicitly best-effort — Cloudflare recycles them — so a
|
|
251
340
|
# dead tunnel is restarted and the fresh URL re-announced. (The token
|
|
252
341
|
# survives, but the origin changed, so recents/short links need the new
|
|
253
|
-
# invitation.)
|
|
254
|
-
def
|
|
342
|
+
# invitation — including the copy pinned in the footer.)
|
|
343
|
+
def watch
|
|
255
344
|
loop do
|
|
256
|
-
|
|
345
|
+
break if pause == :quit
|
|
346
|
+
|
|
257
347
|
web = @children.synchronize { @pids[:web] }
|
|
258
348
|
UI.die!("the dev server exited — scroll up for its output") unless ChildProcesses.alive?(web)
|
|
259
349
|
|
|
@@ -267,7 +357,45 @@ module Everywhere
|
|
|
267
357
|
end
|
|
268
358
|
end
|
|
269
359
|
|
|
270
|
-
#
|
|
360
|
+
# A second between checks, spent waiting on a keystroke when there is a
|
|
361
|
+
# terminal to take one from — so a key is acted on the moment it's pressed
|
|
362
|
+
# rather than up to a second later, and the loop keeps its one pacing
|
|
363
|
+
# point either way.
|
|
364
|
+
def pause
|
|
365
|
+
return sleep(1) unless @dock.keys?
|
|
366
|
+
return unless IO.select([$stdin], nil, nil, 1)
|
|
367
|
+
|
|
368
|
+
case $stdin.getc
|
|
369
|
+
when "s" then show_qr
|
|
370
|
+
when "r" then restart_server
|
|
371
|
+
when "q", "\u0004", nil then :quit # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Replace just the dev server, keeping the tunnel and the pairing token —
|
|
376
|
+
# how a gem update or an everywhere.yml change gets picked up without
|
|
377
|
+
# invalidating the invitation everyone already scanned. start_server
|
|
378
|
+
# rebuilds the env, so the same @token rides back in; connected devices
|
|
379
|
+
# see a blip, not a disconnect. The config is reloaded so a renamed app
|
|
380
|
+
# announces under its new name if the tunnel later restarts.
|
|
381
|
+
def restart_server
|
|
382
|
+
UI.step("restarting the dev server #{UI.dim("(the tunnel, link and token stay)")}")
|
|
383
|
+
stop_child(:web)
|
|
384
|
+
wait_for_port_close
|
|
385
|
+
@config = Everywhere::Config.load(@framework.root)
|
|
386
|
+
start_server(@port.to_s)
|
|
387
|
+
UI.note("server is back — same URL, same token", marker: "⌁", color: :cyan)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# stop_child has reaped the process, but the kernel can hold its listener
|
|
391
|
+
# a beat longer; start_server's already-running guard would read that as
|
|
392
|
+
# a stranger's server and kill the whole session.
|
|
393
|
+
def wait_for_port_close(timeout: 10)
|
|
394
|
+
deadline = Clock.monotonic + timeout
|
|
395
|
+
sleep 0.1 while port_open?(@port) && Clock.monotonic < deadline
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# --- child supervision (dev.rb's pattern, its own no-pty fallback) --------
|
|
271
399
|
|
|
272
400
|
def spawn_child(env, cmd, chdir:)
|
|
273
401
|
return Shellout.spawn_pty(env, cmd, chdir: chdir) if Shellout.pty?
|
|
@@ -95,6 +95,49 @@ module Everywhere
|
|
|
95
95
|
|
|
96
96
|
def plain_width(segments) = segments.sum { |s| plain(s).length } + (GAP.length * [segments.size - 1, 0].max)
|
|
97
97
|
|
|
98
|
+
# --- info rows -----------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
# `every preview`'s footer: labelled facts (the share link, the tunnel
|
|
101
|
+
# URL, the pairing token) instead of target states. items: [[label,
|
|
102
|
+
# value], …].
|
|
103
|
+
#
|
|
104
|
+
# Packed across as many rows as it takes rather than truncated to one,
|
|
105
|
+
# which is the one place this parts ways with status_row: these values
|
|
106
|
+
# exist to be scanned, read aloud and typed in by hand, and half a URL or
|
|
107
|
+
# a clipped token is worse than useless.
|
|
108
|
+
def info(items, cols:, keys: nil, rule: true)
|
|
109
|
+
cols = [cols.to_i, 1].max
|
|
110
|
+
lines = []
|
|
111
|
+
lines << UI.gray("─" * cols) if rule
|
|
112
|
+
lines.concat(pack(items.map { |label, value| info_pair(label, value) }, cols))
|
|
113
|
+
lines << keys_row(keys, cols) if keys
|
|
114
|
+
lines
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Greedy: keep adding facts to the current row while they fit, start a new
|
|
118
|
+
# row when they don't. A single fact wider than the terminal is the only
|
|
119
|
+
# thing that ever gets cut (by fit).
|
|
120
|
+
def pack(pairs, cols)
|
|
121
|
+
rows = []
|
|
122
|
+
row = []
|
|
123
|
+
pairs.each do |pair|
|
|
124
|
+
if row.any? && row_width(row + [pair]) > cols
|
|
125
|
+
rows << fit(row, cols)
|
|
126
|
+
row = [pair]
|
|
127
|
+
else
|
|
128
|
+
row << pair
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
rows << fit(row, cols) if row.any?
|
|
132
|
+
rows
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def info_pair(label, value)
|
|
136
|
+
["#{label} #{value}", "#{UI.dim(label)} #{UI.cyan(value)}"]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def row_width(pairs) = pairs.sum { |plain, _| plain.length } + (GAP.length * [pairs.size - 1, 0].max)
|
|
140
|
+
|
|
98
141
|
# --- keys row ------------------------------------------------------------
|
|
99
142
|
|
|
100
143
|
# Named keys with dot separators, then named keys packed tighter, then
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../dock"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
class Dock
|
|
7
|
+
# The pinned footer for `every preview`.
|
|
8
|
+
#
|
|
9
|
+
# Same machinery as the dev dock — it owns the terminal through Console and
|
|
10
|
+
# repaints around every write (see Dock::Screen) — but what it pins is a
|
|
11
|
+
# handful of facts rather than a set of running targets: the share link, the
|
|
12
|
+
# URL to type in by hand and the pairing token. Preview prints those once
|
|
13
|
+
# and then relays the dev server's log, which used to bury them within
|
|
14
|
+
# seconds; the whole point of the command is that someone can still see how
|
|
15
|
+
# to connect five minutes in.
|
|
16
|
+
#
|
|
17
|
+
# It carries no state of its own to spin, so the base class's ticker only
|
|
18
|
+
# ever redraws it on a resize.
|
|
19
|
+
class Info < Dock
|
|
20
|
+
KEYS = [
|
|
21
|
+
["s", "show the QR"],
|
|
22
|
+
["r", "restart server"],
|
|
23
|
+
["q", "quit"]
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
def self.open(items = [], io: $stdout, keys: true, size: nil)
|
|
27
|
+
return Null.new unless drawable?(io, size)
|
|
28
|
+
|
|
29
|
+
new(items, io: io, keys: keys, size: size).tap(&:install)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(items = [], io: $stdout, keys: true, size: nil)
|
|
33
|
+
super([], io: io, keys: keys, size: size)
|
|
34
|
+
@items = items
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether the keymap in the footer is a promise we can keep — the caller
|
|
38
|
+
# owns the key loop, so it has to know whether to read stdin at all.
|
|
39
|
+
def keys? = @keys
|
|
40
|
+
|
|
41
|
+
# Replace the facts and repaint now. A quick tunnel that dropped comes
|
|
42
|
+
# back on a different hostname, so the footer must not outlive its URL.
|
|
43
|
+
def show(items)
|
|
44
|
+
@items = items
|
|
45
|
+
@dirty = true
|
|
46
|
+
refresh
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def footer_rows
|
|
52
|
+
Footer.info(@items,
|
|
53
|
+
cols: [@screen.cols - 1, 1].max,
|
|
54
|
+
keys: (@keys ? KEYS : nil),
|
|
55
|
+
rule: @screen.rows >= 24)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The headless stand-in, as with Dock::Null: no escape sequences, and
|
|
59
|
+
# never installed as Console's sink, so a piped or CI run prints exactly
|
|
60
|
+
# what it printed before the footer existed.
|
|
61
|
+
class Null
|
|
62
|
+
def install = self
|
|
63
|
+
def keys? = false
|
|
64
|
+
def show(_items) = nil
|
|
65
|
+
def emit(_text) = nil
|
|
66
|
+
def refresh = nil
|
|
67
|
+
def clear = nil
|
|
68
|
+
def close = nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/everywhere/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_everywhere
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrea Fomera
|
|
@@ -156,6 +156,7 @@ files:
|
|
|
156
156
|
- lib/everywhere/desktop_dev_app.rb
|
|
157
157
|
- lib/everywhere/dock.rb
|
|
158
158
|
- lib/everywhere/dock/footer.rb
|
|
159
|
+
- lib/everywhere/dock/info.rb
|
|
159
160
|
- lib/everywhere/dock/screen.rb
|
|
160
161
|
- lib/everywhere/dock/state.rb
|
|
161
162
|
- lib/everywhere/emulator.rb
|