terminalwire 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +1 -0
- data/README.md +37 -0
- data/Rakefile +8 -0
- data/lib/terminalwire/version.rb +5 -0
- data/lib/terminalwire.rb +695 -0
- data/sig/terminalwire.rbs +4 -0
- metadata +126 -0
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
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -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
data/lib/terminalwire.rb
ADDED
@@ -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
|
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: []
|