terminalwire 0.3.5.alpha2 → 2.0.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/terminalwire/v2/codec.rb +52 -0
- data/lib/terminalwire/v2/conformance.rb +229 -0
- data/lib/terminalwire/v2/errors.rb +26 -0
- data/lib/terminalwire/v2/frames.rb +107 -0
- data/lib/terminalwire/v2/mux.rb +48 -0
- data/lib/terminalwire/v2/negotiator.rb +30 -0
- data/lib/terminalwire/v2/protocol.rb +91 -0
- data/lib/terminalwire/v2/rails.rb +335 -0
- data/lib/terminalwire/v2/server/connection.rb +179 -0
- data/lib/terminalwire/v2/server/context.rb +251 -0
- data/lib/terminalwire/v2/server/dual_thor.rb +61 -0
- data/lib/terminalwire/v2/server/flow.rb +77 -0
- data/lib/terminalwire/v2/server/handler.rb +143 -0
- data/lib/terminalwire/v2/server/io.rb +91 -0
- data/lib/terminalwire/v2/server/rack.rb +377 -0
- data/lib/terminalwire/v2/server/redirect.rb +71 -0
- data/lib/terminalwire/v2/server/runtime.rb +267 -0
- data/lib/terminalwire/v2/server/session.rb +51 -0
- data/lib/terminalwire/v2/server/stream_router.rb +60 -0
- data/lib/terminalwire/v2/server/terminal.rb +99 -0
- data/lib/terminalwire/v2/server/thor.rb +78 -0
- data/lib/terminalwire/v2/transport/memory.rb +40 -0
- data/lib/terminalwire/v2/transport/queue.rb +46 -0
- data/lib/terminalwire/v2/version.rb +9 -0
- data/lib/terminalwire/v2/window.rb +33 -0
- data/lib/terminalwire/v2.rb +39 -0
- metadata +140 -23
- data/exe/terminalwire +0 -9
- data/exe/terminalwire-exec +0 -11
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Terminalwire::V2
|
|
6
|
+
module Server
|
|
7
|
+
# The server's handle on the client's machine. CLI code calls these methods
|
|
8
|
+
# (puts, gets, file.read, ...) and the Context turns them into protocol frames
|
|
9
|
+
# via the Runtime. Output is one-way; input and filesystem ops are synchronous
|
|
10
|
+
# request/response.
|
|
11
|
+
class Context
|
|
12
|
+
def initialize(runtime)
|
|
13
|
+
@runtime = runtime
|
|
14
|
+
@stdout_sid = nil
|
|
15
|
+
@stderr_sid = nil
|
|
16
|
+
@request = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The incoming HTTP connection profile, captured at the WebSocket upgrade
|
|
20
|
+
# (the Rack adapter sets it): { host:, ip:, user_agent:, headers: }. The
|
|
21
|
+
# client identifies itself in headers — chiefly a real User-Agent — so server
|
|
22
|
+
# code can see who connected (and a `terminalwire about` can light it up).
|
|
23
|
+
attr_accessor :request
|
|
24
|
+
|
|
25
|
+
# The client's real IP (through Fly/proxies), its User-Agent, and the raw
|
|
26
|
+
# incoming HTTP headers. nil/empty when unknown (e.g. non-HTTP transports).
|
|
27
|
+
def remote_ip = @request[:ip]
|
|
28
|
+
def user_agent = @request[:user_agent]
|
|
29
|
+
def http_headers = @request[:headers] || {}
|
|
30
|
+
|
|
31
|
+
# The client build version, parsed from the User-Agent
|
|
32
|
+
# ("terminalwire-exec/<version> (…)"). The version is a client-reported fact
|
|
33
|
+
# in a header, never derived from the URL. nil if the client sent no UA.
|
|
34
|
+
def client_version
|
|
35
|
+
user_agent && user_agent[%r{terminalwire-exec/(\S+)}, 1]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The client's live terminal (rows/cols/tty?/color?), kept current by the
|
|
39
|
+
# runtime's read pump as resize frames arrive.
|
|
40
|
+
def terminal = @runtime.terminal
|
|
41
|
+
|
|
42
|
+
# The program name + arguments the client launched with (from the hello).
|
|
43
|
+
# CLI parsers (OptionParser, Thor, GLI, …) consume `program_arguments`.
|
|
44
|
+
def program_name = @runtime.program && @runtime.program["name"]
|
|
45
|
+
def program_arguments = Array(@runtime.program && @runtime.program["args"])
|
|
46
|
+
|
|
47
|
+
# The entitlement the client granted this session: its authority (origin),
|
|
48
|
+
# the path globs it will allow writes within, and the permitted schemes/env
|
|
49
|
+
# vars. The CLIENT enforces this; here it's a read-only view so server code
|
|
50
|
+
# can stay inside the sandbox (e.g. learn where it may persist a file).
|
|
51
|
+
def entitlement = @runtime.entitlement
|
|
52
|
+
|
|
53
|
+
# The capability set negotiated for this session — the intersection the
|
|
54
|
+
# client and server agreed on in the handshake. Branch on this to offer
|
|
55
|
+
# optional features only when the connected client supports them.
|
|
56
|
+
def capabilities = @runtime.connection.capabilities
|
|
57
|
+
|
|
58
|
+
# The client directory this origin may persist files into — its sandbox —
|
|
59
|
+
# derived from the first granted path glob (".../**" -> "..."). Returns nil
|
|
60
|
+
# if no writable path was granted. Used like:
|
|
61
|
+
#
|
|
62
|
+
# context.file.write("#{context.storage_path}/session.json", data)
|
|
63
|
+
def storage_path
|
|
64
|
+
glob = entitlement && entitlement.dig("paths", 0, "glob")
|
|
65
|
+
glob && glob.sub(%r{/\*\*\z}, "")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The server-manageable root on the client — the USER subtree
|
|
69
|
+
# (~/.terminalwire/usr), so binary_path = root_path/bin = ~/.terminalwire/usr/bin
|
|
70
|
+
# (installed app launchers). It deliberately does NOT point at ~/.terminalwire:
|
|
71
|
+
# the system bin (the terminalwire-exec engine) and the control dir (authorities)
|
|
72
|
+
# live above this and are off-limits — a server can't reach them even with a
|
|
73
|
+
# grant. A SYMBOLIC tilde path the client expands + enforces; only the privileged
|
|
74
|
+
# terminalwire.com origin is granted ~/.terminalwire/usr/bin/** (installer-seeded).
|
|
75
|
+
def root_path = Pathname.new("~/.terminalwire/usr")
|
|
76
|
+
|
|
77
|
+
# Register a callback fired when the client's window resizes.
|
|
78
|
+
def on_resize(&block) = @runtime.on_resize(&block)
|
|
79
|
+
|
|
80
|
+
# Output is flow-controlled and chunked by the runtime: write_data sizes each
|
|
81
|
+
# data frame to the client's available credit and blocks when the window is
|
|
82
|
+
# exhausted, so a fast server can't outrun a slow client.
|
|
83
|
+
def print(data, stream: :stdout)
|
|
84
|
+
sid = stream == :stderr ? (@stderr_sid ||= open(:stderr)) : (@stdout_sid ||= open(:stdout))
|
|
85
|
+
@runtime.write_data(sid, data.to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def puts(data = "", stream: :stdout)
|
|
89
|
+
print("#{data}\n", stream: stream)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def warn(data = "")
|
|
93
|
+
puts(data, stream: :stderr)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stdin
|
|
97
|
+
@stdin ||= Stdin.new(@runtime)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Convenience delegators (Thor's shell uses these).
|
|
101
|
+
def gets = stdin.gets
|
|
102
|
+
def getpass = stdin.getpass
|
|
103
|
+
|
|
104
|
+
def env(name)
|
|
105
|
+
@runtime.request(:env, :read, { "name" => name.to_s })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Read keystrokes for the duration of the block: the client puts its terminal
|
|
109
|
+
# in `mode` and streams keypresses, restoring it when the block exits (even
|
|
110
|
+
# on error). Foundation for REPLs and interactive TUIs.
|
|
111
|
+
#
|
|
112
|
+
# mode: :raw — char-at-a-time, no echo, signals as bytes (TUIs)
|
|
113
|
+
# mode: :cbreak — char-at-a-time, echo + signal keys on (single-key y/n)
|
|
114
|
+
#
|
|
115
|
+
# context.raw_input { |keys| keys.each { |bytes| handle(bytes) } }
|
|
116
|
+
def raw_input(mode: Protocol::Mode::RAW)
|
|
117
|
+
sid = @runtime.open_raw_input(mode: mode.to_s)
|
|
118
|
+
yield RawInput.new(@runtime, sid)
|
|
119
|
+
ensure
|
|
120
|
+
@runtime.close_raw_input(sid) if sid
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Single-keypress read with echo + signal keys left on (cbreak): the y/n
|
|
124
|
+
# prompt case. Returns the first byte(s) the user types.
|
|
125
|
+
def read_key
|
|
126
|
+
raw_input(mode: Protocol::Mode::CBREAK) { |keys| return keys.read }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Query the client's terminal with a control sequence and return its reply
|
|
130
|
+
# (e.g. cursor-position report "\e[6n" -> "\e[row;colR"). The client writes
|
|
131
|
+
# the query to its tty and reads the terminal's response. Used by advanced
|
|
132
|
+
# TUI libraries that probe the terminal. Support is advertised via the
|
|
133
|
+
# terminal-query capability (see #capabilities); a client without a tty
|
|
134
|
+
# answers with an io error. (Enforcement isn't wired yet — this issues the
|
|
135
|
+
# request regardless — so check #capabilities yourself if that matters.)
|
|
136
|
+
def query_terminal(sequence, timeout: 1.0)
|
|
137
|
+
@runtime.request(:terminal, :query, { "sequence" => sequence.b, "timeout" => timeout })
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def file
|
|
141
|
+
@file ||= File.new(@runtime)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def directory
|
|
145
|
+
@directory ||= Directory.new(@runtime)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def browser
|
|
149
|
+
@browser ||= Browser.new(@runtime)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def exit(status = 0)
|
|
153
|
+
@runtime.emit(Frames.exit(status: status))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def open(stream)
|
|
159
|
+
@runtime.open_output(stream)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Reads keystroke chunks from a raw input stream. #read returns the next
|
|
163
|
+
# chunk (or nil when the stream closes); #each yields until close.
|
|
164
|
+
class RawInput
|
|
165
|
+
def initialize(runtime, sid)
|
|
166
|
+
@runtime = runtime
|
|
167
|
+
@sid = sid
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def read = @runtime.read_raw(@sid)
|
|
171
|
+
|
|
172
|
+
def each
|
|
173
|
+
return enum_for(:each) unless block_given?
|
|
174
|
+
|
|
175
|
+
while (bytes = read)
|
|
176
|
+
yield bytes
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Resource facades — thin request wrappers with a Ruby-ish interface.
|
|
182
|
+
|
|
183
|
+
class Stdin
|
|
184
|
+
DEFAULT_CHUNK = 64 * 1024
|
|
185
|
+
|
|
186
|
+
def initialize(runtime) = @runtime = runtime
|
|
187
|
+
|
|
188
|
+
def gets = @runtime.request(:stdin, :gets)
|
|
189
|
+
def getpass = @runtime.request(:stdin, :getpass)
|
|
190
|
+
|
|
191
|
+
# Is the client's stdin a terminal (vs a pipe/file)? Branch on this to
|
|
192
|
+
# decide between prompting and draining piped data.
|
|
193
|
+
def tty? = @runtime.terminal.stdin.tty?
|
|
194
|
+
|
|
195
|
+
# Pull up to `n` bytes from the client's stdin. Returns [data, eof].
|
|
196
|
+
def read_chunk(n = DEFAULT_CHUNK)
|
|
197
|
+
response = @runtime.request(:stdin, :read_chunk, { "n" => n })
|
|
198
|
+
[response["data"] || "".b, response["eof"]]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Drain the client's stdin to EOF (for piped input).
|
|
202
|
+
def read
|
|
203
|
+
buffer = +"".b
|
|
204
|
+
loop do
|
|
205
|
+
data, eof = read_chunk
|
|
206
|
+
buffer << data.b # force binary: chunks may arrive as UTF-8 (msgpack
|
|
207
|
+
break if eof # str) or binary (bin); mixing them would raise.
|
|
208
|
+
end
|
|
209
|
+
buffer
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Yield each chunk as it arrives until EOF (streaming without buffering).
|
|
213
|
+
def each_chunk(n = DEFAULT_CHUNK)
|
|
214
|
+
return enum_for(:each_chunk, n) unless block_given?
|
|
215
|
+
|
|
216
|
+
loop do
|
|
217
|
+
data, eof = read_chunk(n)
|
|
218
|
+
yield data unless data.empty?
|
|
219
|
+
break if eof
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
class File
|
|
225
|
+
def initialize(runtime) = @runtime = runtime
|
|
226
|
+
def read(path) = @runtime.request(:file, :read, { "path" => path.to_s })
|
|
227
|
+
def write(path, content) = @runtime.request(:file, :write, { "path" => path.to_s, "content" => content })
|
|
228
|
+
def append(path, content) = @runtime.request(:file, :append, { "path" => path.to_s, "content" => content })
|
|
229
|
+
def delete(path) = @runtime.request(:file, :delete, { "path" => path.to_s })
|
|
230
|
+
def exist?(path) = @runtime.request(:file, :exist, { "path" => path.to_s })
|
|
231
|
+
# chmod the file to `mode` (an integer like 0o755). The client requires an
|
|
232
|
+
# rw grant on the path and rejects a missing mode.
|
|
233
|
+
def change_mode(path, mode) = @runtime.request(:file, :change_mode, { "path" => path.to_s, "mode" => mode })
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
class Directory
|
|
237
|
+
def initialize(runtime) = @runtime = runtime
|
|
238
|
+
def list(path) = @runtime.request(:directory, :list, { "path" => path.to_s })
|
|
239
|
+
alias_method :ls, :list # v1 commands call `directory.ls`; same op, no protocol change
|
|
240
|
+
def create(path) = @runtime.request(:directory, :create, { "path" => path.to_s })
|
|
241
|
+
def exist?(path) = @runtime.request(:directory, :exist, { "path" => path.to_s })
|
|
242
|
+
def delete(path) = @runtime.request(:directory, :delete, { "path" => path.to_s })
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
class Browser
|
|
246
|
+
def initialize(runtime) = @runtime = runtime
|
|
247
|
+
def launch(url) = @runtime.request(:browser, :launch, { "url" => url.to_s })
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "thor" # reuse the v2 Shell + Helpers
|
|
4
|
+
|
|
5
|
+
module Terminalwire::V2
|
|
6
|
+
module Server
|
|
7
|
+
# ONE Thor CLI, BOTH protocols. Apply `Server.dualize(MyCLI)` to a Thor class
|
|
8
|
+
# that already includes the v1 `Terminalwire::Thor` adapter, and it runs
|
|
9
|
+
# unchanged over the v1 AND v2 wire. This works because both servers invoke a
|
|
10
|
+
# CLI through the SAME entry — `cli_class.terminalwire(arguments:, context:)` —
|
|
11
|
+
# and both contexts expose the same I/O surface (puts/print/warn/gets/getpass).
|
|
12
|
+
#
|
|
13
|
+
# The adapter:
|
|
14
|
+
# - its single `terminalwire` entry builds the v2 Shell for a v2 context and
|
|
15
|
+
# otherwise delegates to the v1 adapter via `super` (preserving the v1/rails
|
|
16
|
+
# shell exactly);
|
|
17
|
+
# - its helpers route bare puts/print/warn/gets/getpass through the active
|
|
18
|
+
# `shell.context`, which is whichever protocol's context is live.
|
|
19
|
+
module DualThor
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.extend ClassMethods
|
|
22
|
+
base.include Terminalwire::V2::Server::Thor::Helpers
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module ClassMethods
|
|
26
|
+
def terminalwire(arguments:, context:, &block)
|
|
27
|
+
if context.is_a?(Terminalwire::V2::Server::Context)
|
|
28
|
+
dispatch(nil, arguments.dup, nil, shell: Terminalwire::V2::Server::Thor::Shell.new(context)) do |instance|
|
|
29
|
+
block.call(instance) if block
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
super # v1 adapter's terminalwire (its own shell)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Walk a Thor class + its subcommand tree, making every command class respond to
|
|
39
|
+
# both protocols. Idempotent. Returns the class so it reads as a transform.
|
|
40
|
+
def self.dualize(klass, seen = {})
|
|
41
|
+
return klass if seen[klass]
|
|
42
|
+
seen[klass] = true
|
|
43
|
+
klass.include(DualThor) unless klass.include?(DualThor)
|
|
44
|
+
klass.subcommand_classes.each_value { |sub| dualize(sub, seen) } if klass.respond_to?(:subcommand_classes)
|
|
45
|
+
klass
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Walk a Thor class + its subcommand tree, making every command class speak v2
|
|
49
|
+
# NATIVELY — it includes Terminalwire::V2::Server::Thor (whose `terminalwire`
|
|
50
|
+
# dispatches with the v2 shell, no v1 `super`). This is the v2-only path: the
|
|
51
|
+
# app loads only the v2 gem. `dualize` is the transitional both-protocols path.
|
|
52
|
+
# Idempotent. Returns the class.
|
|
53
|
+
def self.terminalize(klass, seen = {})
|
|
54
|
+
return klass if seen[klass]
|
|
55
|
+
seen[klass] = true
|
|
56
|
+
klass.include(Terminalwire::V2::Server::Thor) unless klass.include?(Terminalwire::V2::Server::Thor)
|
|
57
|
+
klass.subcommand_classes.each_value { |sub| terminalize(sub, seen) } if klass.respond_to?(:subcommand_classes)
|
|
58
|
+
klass
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
module Server
|
|
5
|
+
# Credit-based flow control for server -> client output streams (the SSH /
|
|
6
|
+
# HTTP-2 window model). Each output stream has a window: the number of bytes
|
|
7
|
+
# the client will accept before the server must wait. #reserve is called on
|
|
8
|
+
# the sending (CLI) thread before emitting a data frame — it blocks only until
|
|
9
|
+
# *some* credit exists, then takes up to what's asked (so a write larger than
|
|
10
|
+
# the window can never deadlock); #grant is called on the read-pump thread when
|
|
11
|
+
# a window_adjust arrives and wakes the sender. This is what stops a fast
|
|
12
|
+
# server from outrunning a slow client and ballooning the transport's buffers.
|
|
13
|
+
#
|
|
14
|
+
# The core invariant it enforces: at any instant, the bytes the server has
|
|
15
|
+
# sent but not yet had credited never exceed the window the client granted.
|
|
16
|
+
class FlowController
|
|
17
|
+
def initialize
|
|
18
|
+
@windows = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@cv = ConditionVariable.new
|
|
21
|
+
@closed = false
|
|
22
|
+
@error = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Begin tracking a stream with an initial window (the client's offer). The
|
|
26
|
+
# credit accounting itself lives in the pure Window (the protocol rule);
|
|
27
|
+
# this class only adds the blocking + thread-safety (the implementation).
|
|
28
|
+
def open(sid, initial)
|
|
29
|
+
@mutex.synchronize { @windows[sid] = Window.new(initial) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reserve up to `max` bytes for `sid`, blocking only until at least one byte
|
|
33
|
+
# of credit exists, then taking min(available, max). Returns the amount
|
|
34
|
+
# taken. Sizing each frame to current credit means a single write larger
|
|
35
|
+
# than the window never deadlocks. Raises if shut down while waiting.
|
|
36
|
+
def reserve(sid, max)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
loop do
|
|
39
|
+
raise(@error || ProtocolError.new("flow closed")) if @closed
|
|
40
|
+
|
|
41
|
+
window = @windows[sid]
|
|
42
|
+
taken = window ? window.take(max) : 0
|
|
43
|
+
return taken if taken.positive?
|
|
44
|
+
|
|
45
|
+
@cv.wait(@mutex)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Return `bytes` of credit for `sid` (from a window_adjust) and wake senders.
|
|
51
|
+
def grant(sid, bytes)
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
# A grant for an unknown/closed stream is harmless and ignored.
|
|
54
|
+
@windows[sid]&.grant(bytes)
|
|
55
|
+
@cv.broadcast
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def available(sid)
|
|
60
|
+
@mutex.synchronize { @windows[sid]&.available || 0 }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close(sid)
|
|
64
|
+
@mutex.synchronize { @windows.delete(sid) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Unblock every waiting sender with an error (connection died).
|
|
68
|
+
def shutdown(error)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
@closed = true
|
|
71
|
+
@error = error
|
|
72
|
+
@cv.broadcast
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
module Server
|
|
5
|
+
# The framework-agnostic server entrypoint: performs the handshake, runs your
|
|
6
|
+
# CLI with a Terminalwire-backed context, handles errors, and exits the client.
|
|
7
|
+
# A Rails or Rack adapter just builds a transport and calls this.
|
|
8
|
+
#
|
|
9
|
+
# Your CLI can be ANY of:
|
|
10
|
+
# * a block / callable run(context, args) — works with OptionParser, GLI,
|
|
11
|
+
# dry-cli, or hand-rolled parsing. The block runs inside `Server.redirect`,
|
|
12
|
+
# so $stdout/$stderr/$stdin and bare puts/gets already target the client.
|
|
13
|
+
# * a Thor class via `cli_class:` (it gets Thor's dedicated shell adapter).
|
|
14
|
+
#
|
|
15
|
+
# # OptionParser (or anything using the standard IO globals):
|
|
16
|
+
# Handler.new do |ctx, args|
|
|
17
|
+
# opts = {}
|
|
18
|
+
# OptionParser.new { |o| o.on("--name NAME") { |v| opts[:name] = v } }.parse!(args)
|
|
19
|
+
# puts "hello #{opts[:name]}"
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # Thor:
|
|
23
|
+
# Handler.new(cli_class: MyThorCLI)
|
|
24
|
+
class Handler
|
|
25
|
+
DEFAULT_ERROR_MESSAGE = "An error occurred. Please try again."
|
|
26
|
+
|
|
27
|
+
# @param cli_class [Class, nil] a Thor CLI that `include`s Server::Thor
|
|
28
|
+
# @param run [#call, nil] a callable (context, args) for non-Thor CLIs
|
|
29
|
+
# @param report [#call, nil] optional callable invoked with unexpected errors
|
|
30
|
+
# @param verbose [Boolean] show full backtraces to the client (dev only)
|
|
31
|
+
# @yield [context, args] block form of `run:`
|
|
32
|
+
def initialize(cli_class: nil, run: nil, report: nil, verbose: false,
|
|
33
|
+
error_message: DEFAULT_ERROR_MESSAGE, &block)
|
|
34
|
+
@cli_class = cli_class
|
|
35
|
+
@run = run || block
|
|
36
|
+
@report = report
|
|
37
|
+
@verbose = verbose
|
|
38
|
+
@error_message = error_message
|
|
39
|
+
|
|
40
|
+
return if @cli_class || @run
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, "provide a Thor cli_class:, a run: callable, or a block"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Run one session over the given transport. Returns the exit status.
|
|
46
|
+
# `request` is the incoming HTTP connection profile from the Rack env
|
|
47
|
+
# ({ host:, ip:, user_agent:, headers: }) — threaded in so URL helpers can use
|
|
48
|
+
# the host (v1 set `cli.default_url_options[:host]` the same way) and so server
|
|
49
|
+
# code / `about` can see who connected.
|
|
50
|
+
def call(transport:, request: {})
|
|
51
|
+
runtime = Runtime.new(transport: transport).handshake
|
|
52
|
+
context = Context.new(runtime)
|
|
53
|
+
context.request = request
|
|
54
|
+
arguments = context.program_arguments
|
|
55
|
+
status = 0
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
begin
|
|
59
|
+
dispatch(context, arguments, request[:host])
|
|
60
|
+
rescue Interrupt, Interrupted
|
|
61
|
+
status = 130
|
|
62
|
+
rescue SystemExit => e
|
|
63
|
+
# A command that called `exit`/`abort` raises SystemExit (not a
|
|
64
|
+
# StandardError). Without this it would slip past the rescue below, the
|
|
65
|
+
# ensure would send the client `exit(0)` — reporting success — and the
|
|
66
|
+
# exception would then silently kill the CLI thread. Honor the real code.
|
|
67
|
+
status = e.status
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
status = handle_error(e, context)
|
|
70
|
+
ensure
|
|
71
|
+
# Teardown must not be interrupted. A late Ctrl-C (delivered as an async
|
|
72
|
+
# Interrupted via Thread#raise) landing here would abort the exit-frame
|
|
73
|
+
# write or runtime close and hang the client — the very failure the
|
|
74
|
+
# interrupt machinery exists to avoid. Mask async interrupts for the
|
|
75
|
+
# duration so the exit frame always flushes and the runtime always closes.
|
|
76
|
+
Thread.handle_interrupt(Interrupt => :never, Interrupted => :never) do
|
|
77
|
+
context.exit(status)
|
|
78
|
+
runtime.close
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
rescue Interrupt, Interrupted
|
|
82
|
+
# An interrupt that fired in a rescue clause above, before the mask took
|
|
83
|
+
# hold, surfaces here. Teardown still ran in the ensure, so just report it.
|
|
84
|
+
status = 130
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
status
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Route to the Thor adapter or the generic redirect-based runner. `host`, when
|
|
93
|
+
# present, is set on the per-session Thor instance so Rails URL helpers resolve
|
|
94
|
+
# (instance-level, like v1 — no shared class-global to race across sessions).
|
|
95
|
+
def dispatch(context, arguments, host = nil)
|
|
96
|
+
if @cli_class
|
|
97
|
+
@cli_class.terminalwire(arguments: arguments, context: context) do |cli|
|
|
98
|
+
cli.default_url_options[:host] = host if host && cli.respond_to?(:default_url_options)
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
# Generic path: point the global IO streams at the client, then run the
|
|
102
|
+
# user's callable. OptionParser/GLI/dry-cli/bare puts all Just Work.
|
|
103
|
+
Server.redirect(context, argv: arguments) do
|
|
104
|
+
@run.call(context, arguments)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def handle_error(error, context)
|
|
110
|
+
# A denied resource op means the client refused the grant: show an actionable
|
|
111
|
+
# message (the client also prints the exact `terminalwire-policy … --approve`
|
|
112
|
+
# locally) instead of the scary generic one — and don't report it (it's a
|
|
113
|
+
# consent decision, not a bug).
|
|
114
|
+
if denied_error?(error)
|
|
115
|
+
context.warn("Terminalwire couldn't manage your install — it needs filesystem " \
|
|
116
|
+
"access you haven't granted (#{error.message}). Your client printed " \
|
|
117
|
+
"the `terminalwire-policy … --approve` command to grant it.")
|
|
118
|
+
# Thor's own user-facing errors (unknown command, bad args) are friendly
|
|
119
|
+
# already — pass them through verbatim. OptionParser's are too.
|
|
120
|
+
elsif friendly_error?(error)
|
|
121
|
+
context.warn(error.message)
|
|
122
|
+
else
|
|
123
|
+
@report&.call(error)
|
|
124
|
+
context.warn(@verbose ? backtrace(error) : @error_message)
|
|
125
|
+
end
|
|
126
|
+
1
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def denied_error?(error)
|
|
130
|
+
error.is_a?(ResponseError) && error.code == "denied"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def friendly_error?(error)
|
|
134
|
+
(defined?(::Thor::Error) && error.is_a?(::Thor::Error)) ||
|
|
135
|
+
(defined?(::OptionParser::ParseError) && error.is_a?(::OptionParser::ParseError))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def backtrace(error)
|
|
139
|
+
"#{error.class}: #{error.message}\n#{Array(error.backtrace).join("\n")}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terminalwire::V2
|
|
4
|
+
module Server
|
|
5
|
+
# An IO-shaped object backed by the Terminalwire Context. This is the
|
|
6
|
+
# universal adapter: anything that writes to an IO (Kernel#puts, OptionParser
|
|
7
|
+
# help/errors, Logger, a library's `output:`) or reads from one (`$stdin.gets`)
|
|
8
|
+
# works against the client's terminal when handed one of these.
|
|
9
|
+
#
|
|
10
|
+
# `Server.redirect` swaps the global $stdout/$stderr/$stdin for these so an
|
|
11
|
+
# ordinary CLI needs no Terminalwire-specific code; Thor uses them directly
|
|
12
|
+
# because its shell captures the streams at construction.
|
|
13
|
+
class IO
|
|
14
|
+
# @param context [Context]
|
|
15
|
+
# @param stream [:stdout, :stderr, :stdin]
|
|
16
|
+
def initialize(context, stream)
|
|
17
|
+
@context = context
|
|
18
|
+
@stream = stream
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# --- writing (stdout/stderr) ---
|
|
22
|
+
|
|
23
|
+
def print(*args)
|
|
24
|
+
args.each { |arg| @context.print(arg.to_s, stream: @stream) }
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(*args)
|
|
29
|
+
args.sum { |arg| s = arg.to_s; @context.print(s, stream: @stream); s.bytesize }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def <<(arg)
|
|
33
|
+
@context.print(arg.to_s, stream: @stream)
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def puts(*args)
|
|
38
|
+
if args.empty?
|
|
39
|
+
@context.print("\n", stream: @stream)
|
|
40
|
+
else
|
|
41
|
+
args.flatten.each { |arg| @context.print("#{arg}\n", stream: @stream) }
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def printf(format, *args)
|
|
47
|
+
print(format % args)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def flush = self
|
|
51
|
+
def sync = true
|
|
52
|
+
def sync=(value)
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# --- reading (stdin) ---
|
|
57
|
+
|
|
58
|
+
def gets(*) = @context.stdin.gets
|
|
59
|
+
def getpass(*) = @context.stdin.getpass
|
|
60
|
+
def read = @context.stdin.read
|
|
61
|
+
|
|
62
|
+
def each_line(&block)
|
|
63
|
+
return enum_for(:each_line) unless block
|
|
64
|
+
|
|
65
|
+
while (line = gets)
|
|
66
|
+
block.call(line)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
alias each each_line
|
|
70
|
+
|
|
71
|
+
# --- terminal reflection (so tty-screen/tty-table/pastel target the client) ---
|
|
72
|
+
|
|
73
|
+
# Per-stream: this proxy's own stream is what isatty(stdout) etc. ask about.
|
|
74
|
+
def tty? = @context.terminal.stream(@stream).tty?
|
|
75
|
+
def winsize = @context.terminal.winsize
|
|
76
|
+
def isatty = tty?
|
|
77
|
+
|
|
78
|
+
# Answer the window-size ioctl (TIOCGWINSZ) that tty-screen probes, filling
|
|
79
|
+
# the caller's buffer with the CLIENT's [rows, cols] — so tty-screen-based
|
|
80
|
+
# libraries (tty-progressbar, tty-spinner, …) size to the client instead of
|
|
81
|
+
# crashing on a non-IO stream. Other ioctls are no-ops.
|
|
82
|
+
def ioctl(_cmd, buf = nil)
|
|
83
|
+
if buf.is_a?(String)
|
|
84
|
+
rows, cols = @context.terminal.winsize
|
|
85
|
+
buf[0, 8] = [rows.to_i, cols.to_i, 0, 0].pack("S4") # matches tty-screen's "SSSS"
|
|
86
|
+
end
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|