terminalwire 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []