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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d36d2151f58701a6eab470774617b2c81dd99b78caa9829a152017e9840652e9
4
- data.tar.gz: 1315f5032b754d7a45418604850cd96f9168ddeb25a7faf24df91608df7ede8c
3
+ metadata.gz: effe761f70b4c3220e02788d26a8fc49278a52f3e3876c331e3f9f484867592f
4
+ data.tar.gz: 9e589059bea3245ad9540696a24edfb9261736e0d1f30467698aad051c78541e
5
5
  SHA512:
6
- metadata.gz: a2a6201f5ecae1781fecca6750a8952250d78e22e8b45c224c696fab8717a4ff2bb173b591d1e8724d69c695633358a8fb5320e74e2e849fcab738d5f2870b68
7
- data.tar.gz: acaed9ef6364a807dcdf85378e33bac32f99e5309be5e9fcc404c257f6c2dc348f5f61b5e2482582a4bfca56495677da97dcf9de51f308ef9522b8919854185f
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
- teardown_children
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
- Console.print(qr_ansi(link)) if $stdout.tty?
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 wait
342
+ # invitation — including the copy pinned in the footer.)
343
+ def watch
255
344
  loop do
256
- sleep 1
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
- # --- child supervision (dev.rb's pattern, without the dock) ---------------
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
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Everywhere
6
- VERSION = "0.9.1"
6
+ VERSION = "0.10.0"
7
7
 
8
8
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
9
9
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
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.9.1
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