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.
@@ -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