terminalwire 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b1240b61fdcc7553eb7d55495ca20b4401c9516641a024a7a59f64ac3fda9144
4
+ data.tar.gz: 6511112c08684aa011b2026537b8f62c3e7900233f2ea98c4df2f47ccdf2b621
5
+ SHA512:
6
+ metadata.gz: 9f5761849e14d51d6491ea7c024769724607081db57968d05abcc25c713dd4afb7808ab4d29873b50c4b2611bcc5e246be3dc7c0bb436fd9ef8c8c4e98e35e10
7
+ data.tar.gz: f23b101082f1cd1395742ffafffb5c87b5a31d0255153ec5b8c7139cde00a4419575056593a25da6276e33a27b190da2e45b6f3512fa38c7489c4ce98a91a0f3
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-08-29
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1 @@
1
+ Copyright (c) 2024 Brad Gessler. Email brad@terminalwire.com to discuss commercial licensing.
data/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # Terminalwire
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/terminalwire`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ Install the gem and add to the application's Gemfile by executing:
10
+
11
+ $ bundle add terminalwire
12
+
13
+ If bundler is not being used to manage dependencies, install the gem by executing:
14
+
15
+ $ gem install terminalwire
16
+
17
+ ## Usage
18
+
19
+ TODO: Write usage instructions here
20
+
21
+ ## Development
22
+
23
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
24
+
25
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
26
+
27
+ ## Contributing
28
+
29
+ Bug reports and pull requests are welcome on GitHub at https://github.com/terminalwire/ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/terminalwire/ruby/blob/main/CODE_OF_CONDUCT.md).
30
+
31
+ ## License
32
+
33
+ The gem is available as a propietary license. Email brad@terminalwire.com to discuss commercial licensing.
34
+
35
+ ## Code of Conduct
36
+
37
+ Everyone interacting in the Terminalwire project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/terminalwire/ruby/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminalwire
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,695 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "terminalwire/version"
4
+
5
+ require 'socket'
6
+ require 'msgpack'
7
+ require 'launchy'
8
+ require 'logger'
9
+ require 'io/console'
10
+ require 'forwardable'
11
+ require 'uri'
12
+ require 'zeitwerk'
13
+
14
+ require 'thor'
15
+ require 'fileutils'
16
+
17
+ require 'async'
18
+ require 'async/http/endpoint'
19
+ require 'async/websocket/client'
20
+ require 'async/websocket/adapters/rack'
21
+
22
+ module Terminalwire
23
+ class Error < StandardError; end
24
+
25
+ Loader = Zeitwerk::Loader.for_gem.tap do |loader|
26
+ loader.setup
27
+ end
28
+
29
+ module Logging
30
+ DEVICE = Logger.new($stdout, level: ENV.fetch("LOG_LEVEL", "info"))
31
+ def logger = DEVICE
32
+ end
33
+
34
+ module Thor
35
+ class Shell < ::Thor::Shell::Basic
36
+ extend Forwardable
37
+
38
+ # Encapsulates all of the IO devices for a Terminalwire connection.
39
+ attr_reader :session
40
+
41
+ def_delegators :@session, :stdin, :stdout, :stderr
42
+
43
+ def initialize(session)
44
+ @session = session
45
+ super()
46
+ end
47
+ end
48
+
49
+ def self.included(base)
50
+ base.extend ClassMethods
51
+
52
+ # I have to do this in a block to deal with some of Thor's DSL
53
+ base.class_eval do
54
+ extend Forwardable
55
+
56
+ protected
57
+
58
+ no_commands do
59
+ def_delegators :shell, :session
60
+ def_delegators :session, :stdout, :stdin, :stderr, :browser
61
+ def_delegators :stdout, :puts, :print
62
+ def_delegators :stdin, :gets
63
+ end
64
+ end
65
+ end
66
+
67
+ module ClassMethods
68
+ def start(given_args = ARGV, config = {})
69
+ session = config.delete(:session)
70
+ config[:shell] = Shell.new(session) if session
71
+ super(given_args, config)
72
+ end
73
+ end
74
+ end
75
+
76
+ module Transport
77
+ class Base
78
+ def initialize
79
+ raise NotImplementedError, "This is an abstract base class"
80
+ end
81
+
82
+ def read
83
+ raise NotImplementedError, "Subclass must implement #read"
84
+ end
85
+
86
+ def write(data)
87
+ raise NotImplementedError, "Subclass must implement #write"
88
+ end
89
+
90
+ def close
91
+ raise NotImplementedError, "Subclass must implement #close"
92
+ end
93
+ end
94
+
95
+ class WebSocket
96
+ def initialize(websocket)
97
+ @websocket = websocket
98
+ end
99
+
100
+ def read
101
+ @websocket.read&.buffer
102
+ end
103
+
104
+ def write(data)
105
+ @websocket.write(data)
106
+ end
107
+
108
+ def close
109
+ @websocket.close
110
+ end
111
+ end
112
+
113
+ class Socket < Base
114
+ def initialize(socket)
115
+ @socket = socket
116
+ end
117
+
118
+ def read
119
+ length = @socket.read(4)
120
+ return nil if length.nil?
121
+ length = length.unpack('L>')[0]
122
+ @socket.read(length)
123
+ end
124
+
125
+ def write(data)
126
+ length = [data.bytesize].pack('L>')
127
+ @socket.write(length + data)
128
+ end
129
+
130
+ def close
131
+ @socket.close
132
+ end
133
+ end
134
+ end
135
+
136
+ class Connection
137
+ include Logging
138
+
139
+ attr_reader :transport
140
+
141
+ def initialize(transport)
142
+ @transport = transport
143
+ end
144
+
145
+ def write(data)
146
+ logger.debug "Connection: Sending #{data.inspect}"
147
+ packed_data = MessagePack.pack(data, symbolize_keys: true)
148
+ @transport.write(packed_data)
149
+ end
150
+
151
+ def recv
152
+ logger.debug "Connection: Reading"
153
+ packed_data = @transport.read
154
+ return nil if packed_data.nil?
155
+ data = MessagePack.unpack(packed_data, symbolize_keys: true)
156
+ logger.debug "Connection: Recieved #{data.inspect}"
157
+ data
158
+ end
159
+
160
+ def close
161
+ @transport.close
162
+ end
163
+ end
164
+
165
+ class ResourceRegistry
166
+ def initialize
167
+ @resources = Hash.new
168
+ end
169
+
170
+ def register(name, to: nil)
171
+ @resources[name.to_s] = to
172
+ end
173
+
174
+ def <<(resource_class)
175
+ register resource_class.protocol_key, to: resource_class
176
+ end
177
+
178
+ def find(name)
179
+ @resources.fetch(name.to_s)
180
+ end
181
+ end
182
+
183
+ module Resource
184
+ class Base
185
+ attr_reader :id, :connection
186
+
187
+ def initialize(id, connection)
188
+ @id = Integer(id)
189
+ @connection = connection
190
+ end
191
+
192
+ def connect; end
193
+ def dispatch(action, data); end
194
+ def disconnect; end
195
+
196
+ def respond(response, status: :success)
197
+ connection.write(event: "device", id: @id, status:, response:)
198
+ end
199
+
200
+ def self.protocol_key
201
+ name.split("::").last.downcase
202
+ end
203
+ end
204
+ end
205
+
206
+ module Client
207
+ module Resource
208
+ class IO < Terminalwire::Resource::Base
209
+ def dispatch(action, data)
210
+ if @device.respond_to?(action)
211
+ respond @device.public_send(action, data)
212
+ else
213
+ raise "Unknown action #{action} for device ID #{@id}"
214
+ end
215
+ end
216
+ end
217
+
218
+ class STDOUT < IO
219
+ def connect
220
+ @device = $stdout
221
+ end
222
+ end
223
+
224
+ class STDIN < IO
225
+ def connect
226
+ @device = $stdin
227
+ end
228
+
229
+ def dispatch(action, data)
230
+ respond case action
231
+ when "puts"
232
+ @device.puts(data)
233
+ when "gets"
234
+ @device.gets
235
+ when "getpass"
236
+ @device.getpass
237
+ end
238
+ end
239
+ end
240
+
241
+ class STDERR < IO
242
+ def connect
243
+ @device = $stderr
244
+ end
245
+ end
246
+
247
+ class File < Terminalwire::Resource::Base
248
+ def connect
249
+ @files = {}
250
+ end
251
+
252
+ def dispatch(action, data)
253
+ respond case action
254
+ when "read"
255
+ read_file(data)
256
+ when "write"
257
+ write_file(data.fetch(:path), data.fetch(:content))
258
+ when "append"
259
+ append_to_file(data.fetch(:path), data.fetch(:content))
260
+ when "mkdir"
261
+ mkdir(data.fetch(:path))
262
+ when "exist"
263
+ exist?(data.fetch(:path))
264
+ else
265
+ raise "Unknown action #{action} for file device"
266
+ end
267
+ end
268
+
269
+ def mkdir(path)
270
+ FileUtils.mkdir_p(::File.expand_path(path))
271
+ end
272
+
273
+ def exist?(path)
274
+ ::File.exist? ::File.expand_path(path)
275
+ end
276
+
277
+ def read_file(path)
278
+ ::File.read ::File.expand_path(path)
279
+ end
280
+
281
+ def write_file(path, content)
282
+ ::File.open(::File.expand_path(path), "w") { |f| f.write(content) }
283
+ end
284
+
285
+ def append_to_file(path, content)
286
+ ::File.open(::File.expand_path(path), "a") { |f| f.write(content) }
287
+ end
288
+
289
+ def disconnect
290
+ @files.clear
291
+ end
292
+ end
293
+
294
+ class Browser < Terminalwire::Resource::Base
295
+ def dispatch(action, data)
296
+ respond case action
297
+ when "launch"
298
+ Launchy.open(data)
299
+ "Launched browser with URL: #{data}"
300
+ else
301
+ raise "Unknown action #{action} for browser device"
302
+ end
303
+ end
304
+ end
305
+ end
306
+
307
+ class ResourceMapper
308
+ def initialize(connection, resources)
309
+ @connection = connection
310
+ @resources = resources
311
+ @devices = Hash.new { |h,k| h[Integer(k)] }
312
+ end
313
+
314
+ def connect_device(id, type)
315
+ klass = @resources.find(type)
316
+ if klass
317
+ device = klass.new(id, @connection)
318
+ device.connect
319
+ @devices[id] = device
320
+ @connection.write(event: "device", action: "connect", status: "success", id: id, type: type)
321
+ else
322
+ @connection.write(event: "device", action: "connect", status: "failure", id: id, type: type, message: "Unknown device type")
323
+ end
324
+ end
325
+
326
+ def dispatch(id, action, data)
327
+ device = @devices[id]
328
+ if device
329
+ device.dispatch(action, data)
330
+ else
331
+ raise "Unknown device ID: #{id}"
332
+ end
333
+ end
334
+
335
+ def disconnect_device(id)
336
+ device = @devices.delete(id)
337
+ device&.disconnect
338
+ @connection.write(event: "device", action: "disconnect", id: id)
339
+ end
340
+ end
341
+
342
+ class Handler
343
+ include Logging
344
+
345
+ def initialize(connection, resources = self.class.resources)
346
+ @connection = connection
347
+ @resources = resources
348
+ end
349
+
350
+ def connect
351
+ @devices = ResourceMapper.new(@connection, @resources)
352
+
353
+ @connection.write(event: "initialize", protocol: { version: "0.1.0" }, arguments: ARGV, program_name: $0)
354
+
355
+ loop do
356
+ handle @connection.recv
357
+ end
358
+ end
359
+
360
+ def handle(message)
361
+ case message
362
+ in { event: "device", action: "connect", id:, type: }
363
+ @devices.connect_device(id, type)
364
+ in { event: "device", action: "command", id:, command:, data: }
365
+ @devices.dispatch(id, command, data)
366
+ in { event: "device", action: "disconnect", id: }
367
+ @devices.disconnect_device(id)
368
+ in { event: "exit", status: }
369
+ exit Integer(status)
370
+ end
371
+ end
372
+
373
+ def self.resources
374
+ ResourceRegistry.new.tap do |resources|
375
+ resources << Client::Resource::STDOUT
376
+ resources << Client::Resource::STDIN
377
+ resources << Client::Resource::STDERR
378
+ resources << Client::Resource::Browser
379
+ resources << Client::Resource::File
380
+ end
381
+ end
382
+ end
383
+
384
+ def self.tcp(...)
385
+ socket = TCPSocket.new(...)
386
+ transport = Terminalwire::Transport::Socket.new(socket)
387
+ connection = Terminalwire::Connection.new(transport)
388
+ Terminalwire::Client::Handler.new(connection)
389
+ end
390
+
391
+ def self.socket(...)
392
+ socket = UNIXSocket.new(...)
393
+ transport = Terminalwire::Transport::Socket.new(socket)
394
+ connection = Terminalwire::Connection.new(transport)
395
+ Terminalwire::Client::Handler.new(connection)
396
+ end
397
+
398
+ def self.websocket(url)
399
+ url = URI(url)
400
+
401
+ Async do |task|
402
+ endpoint = Async::HTTP::Endpoint.parse(url)
403
+
404
+ Async::WebSocket::Client.connect(endpoint) do |connection|
405
+ transport = Terminalwire::Transport::WebSocket.new(connection)
406
+ connection = Terminalwire::Connection.new(transport)
407
+ Terminalwire::Client::Handler.new(connection).connect
408
+ end
409
+ end
410
+ end
411
+ end
412
+
413
+ module Server
414
+ module Resource
415
+ class IO < Terminalwire::Resource::Base
416
+ def puts(data)
417
+ command("puts", data: data)
418
+ end
419
+
420
+ def print(data)
421
+ command("print", data: data)
422
+ end
423
+
424
+ def gets
425
+ command("gets")
426
+ end
427
+
428
+ def flush
429
+ # @connection.flush
430
+ end
431
+
432
+ private
433
+
434
+ def command(command, data: nil)
435
+ @connection.write(event: "device", id: @id, action: "command", command: command, data: data)
436
+ @connection.recv&.fetch(:response)
437
+ end
438
+ end
439
+
440
+ class STDOUT < IO
441
+ end
442
+
443
+ class STDIN < IO
444
+ def getpass
445
+ command("getpass")
446
+ end
447
+ end
448
+
449
+ class STDERR < IO
450
+ end
451
+
452
+ class File < Terminalwire::Resource::Base
453
+ def read(path)
454
+ command("read", path.to_s)
455
+ end
456
+
457
+ def write(path, content)
458
+ command("write", { 'path' => path.to_s, 'content' => content })
459
+ end
460
+
461
+ def append(path, content)
462
+ command("append", { 'path' => path.to_s, 'content' => content })
463
+ end
464
+
465
+ def mkdir(path)
466
+ command("mkdir", { 'path' => path.to_s })
467
+ end
468
+
469
+ def exist?(path)
470
+ command("exist", { 'path' => path.to_s })
471
+ end
472
+
473
+ private
474
+
475
+ def command(action, data)
476
+ @connection.write(event: "device", id: @id, action: "command", command: action, data: data)
477
+ response = @connection.recv
478
+ response.fetch(:response)
479
+ end
480
+ end
481
+
482
+ class Browser < Terminalwire::Resource::Base
483
+ def launch(url)
484
+ command("launch", data: url)
485
+ end
486
+
487
+ private
488
+
489
+ def command(command, data: nil)
490
+ @connection.write(event: "device", id: @id, action: "command", command: command, data: data)
491
+ @connection.recv.fetch(:response)
492
+ end
493
+ end
494
+ end
495
+
496
+ class ResourceMapper
497
+ include Logging
498
+
499
+ def initialize(connection, resources = self.class.resources)
500
+ @id = -1
501
+ @resources = resources
502
+ @devices = Hash.new { |h,k| h[Integer(k)] }
503
+ @connection = connection
504
+ end
505
+
506
+ def connect_device(type)
507
+ id = next_id
508
+ logger.debug "Server: Requesting client to connect device #{type} with ID #{id}"
509
+ @connection.write(event: "device", action: "connect", id: id, type: type)
510
+ response = @connection.recv
511
+ case response
512
+ in { status: "success" }
513
+ logger.debug "Server: Resource #{type} connected with ID #{id}."
514
+ @devices[id] = @resources.find(type).new(id, @connection)
515
+ else
516
+ logger.debug "Server: Failed to connect device #{type} with ID #{id}."
517
+ end
518
+ end
519
+
520
+ private
521
+
522
+ def next_id
523
+ @id += 1
524
+ end
525
+
526
+ def self.resources
527
+ ResourceRegistry.new.tap do |resources|
528
+ resources << Server::Resource::STDOUT
529
+ resources << Server::Resource::STDIN
530
+ resources << Server::Resource::STDERR
531
+ resources << Server::Resource::Browser
532
+ resources << Server::Resource::File
533
+ end
534
+ end
535
+ end
536
+
537
+ class Session
538
+ extend Forwardable
539
+
540
+ attr_reader :stdout, :stdin, :stderr, :browser, :file
541
+
542
+ def_delegators :@stdout, :puts, :print
543
+ def_delegators :@stdin, :gets, :getpass
544
+
545
+ def initialize(connection:)
546
+ @connection = connection
547
+ @devices = ResourceMapper.new(@connection)
548
+ @stdout = @devices.connect_device("stdout")
549
+ @stdin = @devices.connect_device("stdin")
550
+ @stderr = @devices.connect_device("stderr")
551
+ @browser = @devices.connect_device("browser")
552
+ @file = @devices.connect_device("file")
553
+
554
+ if block_given?
555
+ begin
556
+ yield self
557
+ ensure
558
+ exit
559
+ end
560
+ end
561
+ end
562
+
563
+ def exec(&shell)
564
+ instance_eval(&shell)
565
+ ensure
566
+ exit
567
+ end
568
+
569
+ def exit(status = 0)
570
+ @connection.write(event: "exit", status: status)
571
+ end
572
+
573
+ def close
574
+ @connection.close
575
+ end
576
+ end
577
+
578
+ class MyCLI < ::Thor
579
+ include Terminalwire::Thor
580
+
581
+ desc "greet NAME", "Greet a person"
582
+ def greet(name)
583
+ name = ask "What's your name?"
584
+ say "Hello, #{name}!"
585
+ end
586
+ end
587
+
588
+ class Socket
589
+ include Logging
590
+
591
+ def initialize(server_socket)
592
+ @server_socket = server_socket
593
+ end
594
+
595
+ def listen
596
+ logger.info "Socket: Sistening..."
597
+ loop do
598
+ client_socket = @server_socket.accept
599
+ logger.debug "Socket: Client #{client_socket.inspect} connected"
600
+ handle_client(client_socket)
601
+ end
602
+ end
603
+
604
+ private
605
+
606
+ def handle_client(socket)
607
+ transport = Transport::Socket.new(socket)
608
+ connection = Connection.new(transport)
609
+
610
+ Thread.new do
611
+ handler = Handler.new(connection)
612
+ handler.run
613
+ end
614
+ end
615
+ end
616
+
617
+ class Handler
618
+ include Logging
619
+
620
+ def initialize(connection)
621
+ @connection = connection
622
+ end
623
+
624
+ def run
625
+ logger.info "Server Handler: Running"
626
+ loop do
627
+ message = @connection.recv
628
+ case message
629
+ in { event: "initialize", arguments:, program_name: }
630
+ Session.new(connection: @connection) do |session|
631
+ MyCLI.start(arguments, session: session)
632
+ end
633
+ end
634
+ end
635
+ rescue EOFError, Errno::ECONNRESET
636
+ logger.info "Server Handler: Client disconnected"
637
+ ensure
638
+ @connection.close
639
+ end
640
+ end
641
+
642
+ def self.tcp(...)
643
+ Server::Socket.new(TCPServer.new(...))
644
+ end
645
+
646
+ def self.socket(...)
647
+ Server::Socket.new(UNIXServer.new(...))
648
+ end
649
+ end
650
+
651
+ module WebSocket
652
+ class Server
653
+ include Logging
654
+
655
+ def call(env)
656
+ Async::WebSocket::Adapters::Rack.open(env, protocols: ['ws']) do |connection|
657
+ run(Connection.new(Terminalwire::Transport::WebSocket.new(connection)))
658
+ end or [200, { "Content-Type" => "text/plain" }, ["Connect via WebSockets"]]
659
+ end
660
+
661
+ private
662
+
663
+ def run(connection)
664
+ while message = connection.recv
665
+ puts message
666
+ end
667
+ end
668
+ end
669
+
670
+ class ThorServer < Server
671
+ include Logging
672
+
673
+ def initialize(cli_class)
674
+ @cli_class = cli_class
675
+
676
+ # Check if the Terminalwire::Thor module is already included
677
+ unless @cli_class.included_modules.include?(Terminalwire::Thor)
678
+ raise 'Add `include Terminalwire::Thor` to the #{@cli_class.inspect} class.'
679
+ end
680
+ end
681
+
682
+ def run(connection)
683
+ logger.info "ThorServer: Running #{@cli_class.inspect}"
684
+ while message = connection.recv
685
+ case message
686
+ in { event: "initialize", protocol: { version: _ }, arguments:, program_name: }
687
+ Terminalwire::Server::Session.new(connection: connection) do |session|
688
+ @cli_class.start(arguments, session: session)
689
+ end
690
+ end
691
+ end
692
+ end
693
+ end
694
+ end
695
+ end
@@ -0,0 +1,4 @@
1
+ module Terminalwire
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terminalwire
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brad Gessler
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-08-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: async-websocket
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.25'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.25'
27
+ - !ruby/object:Gem::Dependency
28
+ name: zeitwerk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: thor
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: msgpack
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.7'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.7'
69
+ - !ruby/object:Gem::Dependency
70
+ name: launchy
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ description: Stream command-line apps from your server without a web API
84
+ email:
85
+ - brad@terminalwire.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".rspec"
91
+ - CHANGELOG.md
92
+ - CODE_OF_CONDUCT.md
93
+ - LICENSE.txt
94
+ - README.md
95
+ - Rakefile
96
+ - lib/terminalwire.rb
97
+ - lib/terminalwire/version.rb
98
+ - sig/terminalwire.rbs
99
+ homepage: https://terminalwire.com/ruby
100
+ licenses:
101
+ - Proprietary
102
+ metadata:
103
+ allowed_push_host: https://rubygems.org/
104
+ homepage_uri: https://terminalwire.com/ruby
105
+ source_code_uri: https://github.com/terminalwire/ruby
106
+ changelog_uri: https://github.com/terminalwire/ruby/tags
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: 3.0.0
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 3.5.6
123
+ signing_key:
124
+ specification_version: 4
125
+ summary: Ship a CLI for your web app. No API required.
126
+ test_files: []