terminalwire 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1240b61fdcc7553eb7d55495ca20b4401c9516641a024a7a59f64ac3fda9144
4
- data.tar.gz: 6511112c08684aa011b2026537b8f62c3e7900233f2ea98c4df2f47ccdf2b621
3
+ metadata.gz: 9fec6bb413813bdaa761403bca8fcaebb92b884a448cfe3b5cba7fc56e3589b0
4
+ data.tar.gz: 319498815fe666a7266f1dcfd2648af1e15477f7f42f22cd8661f2c42bda45c9
5
5
  SHA512:
6
- metadata.gz: 9f5761849e14d51d6491ea7c024769724607081db57968d05abcc25c713dd4afb7808ab4d29873b50c4b2611bcc5e246be3dc7c0bb436fd9ef8c8c4e98e35e10
7
- data.tar.gz: f23b101082f1cd1395742ffafffb5c87b5a31d0255153ec5b8c7139cde00a4419575056593a25da6276e33a27b190da2e45b6f3512fa38c7489c4ce98a91a0f3
6
+ metadata.gz: bbc8104d13cd4acf420c0c5873b4f1df0749ea94768c91a9ceb6100d34c9fe6da0cd62bf4bb717a5c5e5fdc4f3d7395db0a0cd44b5d88cb1e18006dab6c3f791
7
+ data.tar.gz: daeeb21577a26536a4daad6c61b07f1b6a96a23ea52adfbc4eb25881b2948a1010f81ffc6b4958eb92befc0bae348b18cbecf4aeecea813c0b84b18ea0e33ace
data/LICENSE.txt CHANGED
@@ -1 +1 @@
1
- Copyright (c) 2024 Brad Gessler. Email brad@terminalwire.com to discuss commercial licensing.
1
+ Copyright (c) 2024 Brad Gessler. Email brad@terminalwire.com to discuss licensing.
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # Terminalwire
2
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.
3
+ Unlike most command-line tools for web services that require an API, Terminalwire streams terminal I/O between a web server and client over WebSockets. This means you can use your preferred command-line parser within your favorite web server framework to deliver a delightful CLI experience to your users.
6
4
 
7
5
  ## Installation
8
6
 
@@ -14,9 +12,19 @@ If bundler is not being used to manage dependencies, install the gem by executin
14
12
 
15
13
  $ gem install terminalwire
16
14
 
17
- ## Usage
15
+ ## Rails
16
+
17
+ Run the intallation command:
18
+
19
+ $ rails g terminalwire:install my-app
20
+
21
+ This generates the `./bin/my-app` file. Run it to verify that it connects to the server.
22
+
23
+ $ bin/my-app
24
+ Commands:
25
+ my-app help [COMMAND] # Describe available commands or one specific command
18
26
 
19
- TODO: Write usage instructions here
27
+ To edit the command-line, open `./app/cli/main_cli.rb` and make changes to the `MainCLI` class.
20
28
 
21
29
  ## Development
22
30
 
@@ -30,7 +38,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/termin
30
38
 
31
39
  ## License
32
40
 
33
- The gem is available as a propietary license. Email brad@terminalwire.com to discuss commercial licensing.
41
+ The gem is available as a propietary license. Email brad@terminalwire.com to discuss licensing.
34
42
 
35
43
  ## Code of Conduct
36
44
 
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env terminalwire-exec
2
+ url: "ws://localhost:3000/terminal"
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../lib/terminalwire.rb"
3
+
4
+ begin
5
+ Terminalwire::Client::Binary.execute
6
+ rescue Terminalwire::Error => e
7
+ puts e.message
8
+ exit 1
9
+ end
@@ -0,0 +1,9 @@
1
+ Description:
2
+ Installs Terminalwire
3
+
4
+ Example:
5
+ bin/rails generate terminalwire:install
6
+
7
+ This will create:
8
+ app/terminal/application_terminal.rb
9
+ bin/<your-app>
@@ -0,0 +1,37 @@
1
+ require "bundler"
2
+
3
+ class Terminalwire::InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ argument :binary_name, type: :string, required: true, banner: "binary_name"
7
+
8
+ def create_terminal_files
9
+ template "application_terminal.rb.tt", Rails.root.join("app/terminal/application_terminal.rb")
10
+ end
11
+
12
+ def create_binary_files
13
+ copy_file "bin/terminalwire", binary_path
14
+ chmod binary_path, 0755, verbose: false
15
+ end
16
+
17
+ def add_route
18
+ route <<~ROUTE
19
+ match "/terminal",
20
+ to: Terminalwire::WebSocket::ThorServer.new(ApplicationTerminal),
21
+ via: [:get, :connect]
22
+ ROUTE
23
+ end
24
+
25
+ def print_post_install_message
26
+ say ""
27
+ say "Terminalwire has been successfully installed!", :green
28
+ say "Run `#{binary_path.relative_path_from(Rails.root)}` to verify everything is in working order. For support visit https://terminalwire.com."
29
+ say ""
30
+ end
31
+
32
+ private
33
+
34
+ def binary_path
35
+ Rails.root.join("bin", binary_name)
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # Learn how to use Thor at http://whatisthor.com.
2
+ class ApplicationTerminal < Thor
3
+ include Terminalwire::Thor
4
+
5
+ def self.basename = "<%= binary_name %>"
6
+
7
+ desc "hello NAME", "say hello to NAME"
8
+ def hello(name)
9
+ puts "Hello #{name}"
10
+ end
11
+
12
+ desc "login", "Login to your account"
13
+ def login
14
+ print "Email: "
15
+ email = gets
16
+
17
+ print "Password: "
18
+ password = getch
19
+
20
+ if self.current_user = User.authenticate(email, password)
21
+ puts "Successfully logged in as #{user.email}."
22
+ else
23
+ puts "Could not find a user with that email and password."
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def current_user=(user)
30
+ session["user_id"] = user.id
31
+ end
32
+
33
+ def current_user
34
+ @current_user ||= User.find(session.fetch("user_id"))
35
+ end
36
+ end
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env terminalwire-exec
2
+ url: "ws://localhost:3000/terminal"
@@ -0,0 +1,35 @@
1
+ require "pathname"
2
+ require "yaml"
3
+ require "uri"
4
+
5
+ module Terminalwire::Client
6
+ class Binary
7
+ attr_reader :arguments, :path, :configuration, :url
8
+
9
+ def initialize(path:, arguments:)
10
+ @arguments = arguments
11
+ @path = Pathname.new(path)
12
+ @configuration = YAML.load_file(@path)
13
+ @url = URI(@configuration.fetch("url"))
14
+ rescue Errno::ENOENT => e
15
+ raise Terminalwire::Error, "File not found: #{@path}"
16
+ rescue URI::InvalidURIError => e
17
+ raise Terminalwire::Error, "Invalid URI: #{@url}"
18
+ rescue KeyError => e
19
+ raise Terminalwire::Error, "Missing key in configuration: #{e}"
20
+ end
21
+
22
+ def start
23
+ Terminalwire::Client.websocket(url:, arguments:)
24
+ end
25
+
26
+ def self.execute
27
+ case ARGV
28
+ in path, *arguments
29
+ new(path:, arguments:).start
30
+ end
31
+ rescue NoMatchingPatternError => e
32
+ raise Terminalwire::Error, "Launched with incorrect arguments: #{ARGV}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,213 @@
1
+ module Terminalwire
2
+ module Client
3
+ module Resource
4
+ class IO < Terminalwire::Resource::Base
5
+ def dispatch(action, data)
6
+ if @device.respond_to?(action)
7
+ respond @device.public_send(action, data)
8
+ else
9
+ raise "Unknown action #{action} for device ID #{@id}"
10
+ end
11
+ end
12
+ end
13
+
14
+ class STDOUT < IO
15
+ def connect
16
+ @device = $stdout
17
+ end
18
+ end
19
+
20
+ class STDIN < IO
21
+ def connect
22
+ @device = $stdin
23
+ end
24
+
25
+ def dispatch(action, data)
26
+ respond case action
27
+ when "puts"
28
+ @device.puts(data)
29
+ when "gets"
30
+ @device.gets
31
+ when "getpass"
32
+ @device.getpass
33
+ end
34
+ end
35
+ end
36
+
37
+ class STDERR < IO
38
+ def connect
39
+ @device = $stderr
40
+ end
41
+ end
42
+
43
+ class File < Terminalwire::Resource::Base
44
+ def connect
45
+ @files = {}
46
+ end
47
+
48
+ def dispatch(action, data)
49
+ respond case action
50
+ when "read"
51
+ read_file(data)
52
+ when "write"
53
+ write_file(data.fetch(:path), data.fetch(:content))
54
+ when "append"
55
+ append_to_file(data.fetch(:path), data.fetch(:content))
56
+ when "mkdir"
57
+ mkdir(data.fetch(:path))
58
+ when "exist"
59
+ exist?(data.fetch(:path))
60
+ else
61
+ raise "Unknown action #{action} for file device"
62
+ end
63
+ end
64
+
65
+ def mkdir(path)
66
+ FileUtils.mkdir_p(::File.expand_path(path))
67
+ end
68
+
69
+ def exist?(path)
70
+ ::File.exist? ::File.expand_path(path)
71
+ end
72
+
73
+ def read_file(path)
74
+ ::File.read ::File.expand_path(path)
75
+ end
76
+
77
+ def write_file(path, content)
78
+ ::File.open(::File.expand_path(path), "w") { |f| f.write(content) }
79
+ end
80
+
81
+ def append_to_file(path, content)
82
+ ::File.open(::File.expand_path(path), "a") { |f| f.write(content) }
83
+ end
84
+
85
+ def disconnect
86
+ @files.clear
87
+ end
88
+ end
89
+
90
+ class Browser < Terminalwire::Resource::Base
91
+ def dispatch(action, data)
92
+ respond case action
93
+ when "launch"
94
+ Launchy.open(data)
95
+ "Launched browser with URL: #{data}"
96
+ else
97
+ raise "Unknown action #{action} for browser device"
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ class ResourceMapper
104
+ def initialize(connection, resources)
105
+ @connection = connection
106
+ @resources = resources
107
+ @devices = Hash.new { |h,k| h[Integer(k)] }
108
+ end
109
+
110
+ def connect_device(id, type)
111
+ klass = @resources.find(type)
112
+ if klass
113
+ device = klass.new(id, @connection)
114
+ device.connect
115
+ @devices[id] = device
116
+ @connection.write(event: "device", action: "connect", status: "success", id: id, type: type)
117
+ else
118
+ @connection.write(event: "device", action: "connect", status: "failure", id: id, type: type, message: "Unknown device type")
119
+ end
120
+ end
121
+
122
+ def dispatch(id, action, data)
123
+ device = @devices[id]
124
+ if device
125
+ device.dispatch(action, data)
126
+ else
127
+ raise "Unknown device ID: #{id}"
128
+ end
129
+ end
130
+
131
+ def disconnect_device(id)
132
+ device = @devices.delete(id)
133
+ device&.disconnect
134
+ @connection.write(event: "device", action: "disconnect", id: id)
135
+ end
136
+ end
137
+
138
+ class Handler
139
+ VERSION = "0.1.0".freeze
140
+
141
+ include Logging
142
+
143
+ attr_reader :arguments, :program_name
144
+
145
+ def initialize(connection, resources = self.class.resources, arguments: ARGV, program_name: $0)
146
+ @connection = connection
147
+ @resources = resources
148
+ @arguments = arguments
149
+ end
150
+
151
+ def connect
152
+ @devices = ResourceMapper.new(@connection, @resources)
153
+
154
+ @connection.write(event: "initialize", protocol: { version: VERSION }, arguments:, program_name:)
155
+
156
+ loop do
157
+ handle @connection.recv
158
+ end
159
+ end
160
+
161
+ def handle(message)
162
+ case message
163
+ in { event: "device", action: "connect", id:, type: }
164
+ @devices.connect_device(id, type)
165
+ in { event: "device", action: "command", id:, command:, data: }
166
+ @devices.dispatch(id, command, data)
167
+ in { event: "device", action: "disconnect", id: }
168
+ @devices.disconnect_device(id)
169
+ in { event: "exit", status: }
170
+ exit Integer(status)
171
+ end
172
+ end
173
+
174
+ def self.resources
175
+ ResourceRegistry.new.tap do |resources|
176
+ resources << Client::Resource::STDOUT
177
+ resources << Client::Resource::STDIN
178
+ resources << Client::Resource::STDERR
179
+ resources << Client::Resource::Browser
180
+ resources << Client::Resource::File
181
+ end
182
+ end
183
+ end
184
+
185
+ def self.tcp(...)
186
+ socket = TCPSocket.new(...)
187
+ transport = Terminalwire::Transport::Socket.new(socket)
188
+ connection = Terminalwire::Connection.new(transport)
189
+ Terminalwire::Client::Handler.new(connection)
190
+ end
191
+
192
+ def self.socket(...)
193
+ socket = UNIXSocket.new(...)
194
+ transport = Terminalwire::Transport::Socket.new(socket)
195
+ connection = Terminalwire::Connection.new(transport)
196
+ Terminalwire::Client::Handler.new(connection)
197
+ end
198
+
199
+ def self.websocket(url:, arguments: ARGV)
200
+ url = URI(url)
201
+
202
+ Async do |task|
203
+ endpoint = Async::HTTP::Endpoint.parse(url)
204
+
205
+ Async::WebSocket::Client.connect(endpoint) do |connection|
206
+ transport = Terminalwire::Transport::WebSocket.new(connection)
207
+ connection = Terminalwire::Connection.new(transport)
208
+ Terminalwire::Client::Handler.new(connection, arguments:).connect
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,239 @@
1
+ module Terminalwire
2
+ module Server
3
+ module Resource
4
+ class IO < Terminalwire::Resource::Base
5
+ def puts(data)
6
+ command("puts", data: data)
7
+ end
8
+
9
+ def print(data)
10
+ command("print", data: data)
11
+ end
12
+
13
+ def gets
14
+ command("gets")
15
+ end
16
+
17
+ def flush
18
+ # @connection.flush
19
+ end
20
+
21
+ private
22
+
23
+ def command(command, data: nil)
24
+ @connection.write(event: "device", id: @id, action: "command", command: command, data: data)
25
+ @connection.recv&.fetch(:response)
26
+ end
27
+ end
28
+
29
+ class STDOUT < IO
30
+ end
31
+
32
+ class STDIN < IO
33
+ def getpass
34
+ command("getpass")
35
+ end
36
+ end
37
+
38
+ class STDERR < IO
39
+ end
40
+
41
+ class File < Terminalwire::Resource::Base
42
+ def read(path)
43
+ command("read", path.to_s)
44
+ end
45
+
46
+ def write(path, content)
47
+ command("write", { 'path' => path.to_s, 'content' => content })
48
+ end
49
+
50
+ def append(path, content)
51
+ command("append", { 'path' => path.to_s, 'content' => content })
52
+ end
53
+
54
+ def mkdir(path)
55
+ command("mkdir", { 'path' => path.to_s })
56
+ end
57
+
58
+ def exist?(path)
59
+ command("exist", { 'path' => path.to_s })
60
+ end
61
+
62
+ private
63
+
64
+ def command(action, data)
65
+ @connection.write(event: "device", id: @id, action: "command", command: action, data: data)
66
+ response = @connection.recv
67
+ response.fetch(:response)
68
+ end
69
+ end
70
+
71
+ class Browser < Terminalwire::Resource::Base
72
+ def launch(url)
73
+ command("launch", data: url)
74
+ end
75
+
76
+ private
77
+
78
+ def command(command, data: nil)
79
+ @connection.write(event: "device", id: @id, action: "command", command: command, data: data)
80
+ @connection.recv.fetch(:response)
81
+ end
82
+ end
83
+ end
84
+
85
+ class ResourceMapper
86
+ include Logging
87
+
88
+ def initialize(connection, resources = self.class.resources)
89
+ @id = -1
90
+ @resources = resources
91
+ @devices = Hash.new { |h,k| h[Integer(k)] }
92
+ @connection = connection
93
+ end
94
+
95
+ def connect_device(type)
96
+ id = next_id
97
+ logger.debug "Server: Requesting client to connect device #{type} with ID #{id}"
98
+ @connection.write(event: "device", action: "connect", id: id, type: type)
99
+ response = @connection.recv
100
+ case response
101
+ in { status: "success" }
102
+ logger.debug "Server: Resource #{type} connected with ID #{id}."
103
+ @devices[id] = @resources.find(type).new(id, @connection)
104
+ else
105
+ logger.debug "Server: Failed to connect device #{type} with ID #{id}."
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def next_id
112
+ @id += 1
113
+ end
114
+
115
+ def self.resources
116
+ ResourceRegistry.new.tap do |resources|
117
+ resources << Server::Resource::STDOUT
118
+ resources << Server::Resource::STDIN
119
+ resources << Server::Resource::STDERR
120
+ resources << Server::Resource::Browser
121
+ resources << Server::Resource::File
122
+ end
123
+ end
124
+ end
125
+
126
+ class Session
127
+ extend Forwardable
128
+
129
+ attr_reader :stdout, :stdin, :stderr, :browser, :file
130
+
131
+ def_delegators :@stdout, :puts, :print
132
+ def_delegators :@stdin, :gets, :getpass
133
+
134
+ def initialize(connection:)
135
+ @connection = connection
136
+ @devices = ResourceMapper.new(@connection)
137
+ @stdout = @devices.connect_device("stdout")
138
+ @stdin = @devices.connect_device("stdin")
139
+ @stderr = @devices.connect_device("stderr")
140
+ @browser = @devices.connect_device("browser")
141
+ @file = @devices.connect_device("file")
142
+
143
+ if block_given?
144
+ begin
145
+ yield self
146
+ ensure
147
+ exit
148
+ end
149
+ end
150
+ end
151
+
152
+ def exec(&shell)
153
+ instance_eval(&shell)
154
+ ensure
155
+ exit
156
+ end
157
+
158
+ def exit(status = 0)
159
+ @connection.write(event: "exit", status: status)
160
+ end
161
+
162
+ def close
163
+ @connection.close
164
+ end
165
+ end
166
+
167
+ class MyCLI < ::Thor
168
+ include Terminalwire::Thor
169
+
170
+ desc "greet NAME", "Greet a person"
171
+ def greet(name)
172
+ name = ask "What's your name?"
173
+ say "Hello, #{name}!"
174
+ end
175
+ end
176
+
177
+ class Socket
178
+ include Logging
179
+
180
+ def initialize(server_socket)
181
+ @server_socket = server_socket
182
+ end
183
+
184
+ def listen
185
+ logger.info "Socket: Sistening..."
186
+ loop do
187
+ client_socket = @server_socket.accept
188
+ logger.debug "Socket: Client #{client_socket.inspect} connected"
189
+ handle_client(client_socket)
190
+ end
191
+ end
192
+
193
+ private
194
+
195
+ def handle_client(socket)
196
+ transport = Transport::Socket.new(socket)
197
+ connection = Connection.new(transport)
198
+
199
+ Thread.new do
200
+ handler = Handler.new(connection)
201
+ handler.run
202
+ end
203
+ end
204
+ end
205
+
206
+ class Handler
207
+ include Logging
208
+
209
+ def initialize(connection)
210
+ @connection = connection
211
+ end
212
+
213
+ def run
214
+ logger.info "Server Handler: Running"
215
+ loop do
216
+ message = @connection.recv
217
+ case message
218
+ in { event: "initialize", arguments:, program_name: }
219
+ Session.new(connection: @connection) do |session|
220
+ MyCLI.start(arguments, session: session)
221
+ end
222
+ end
223
+ end
224
+ rescue EOFError, Errno::ECONNRESET
225
+ logger.info "Server Handler: Client disconnected"
226
+ ensure
227
+ @connection.close
228
+ end
229
+ end
230
+
231
+ def self.tcp(...)
232
+ Server::Socket.new(TCPServer.new(...))
233
+ end
234
+
235
+ def self.socket(...)
236
+ Server::Socket.new(UNIXServer.new(...))
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,43 @@
1
+ module Terminalwire
2
+ module Thor
3
+ class Shell < ::Thor::Shell::Basic
4
+ extend Forwardable
5
+
6
+ # Encapsulates all of the IO devices for a Terminalwire connection.
7
+ attr_reader :session
8
+
9
+ def_delegators :@session, :stdin, :stdout, :stderr
10
+
11
+ def initialize(session)
12
+ @session = session
13
+ super()
14
+ end
15
+ end
16
+
17
+ def self.included(base)
18
+ base.extend ClassMethods
19
+
20
+ # I have to do this in a block to deal with some of Thor's DSL
21
+ base.class_eval do
22
+ extend Forwardable
23
+
24
+ protected
25
+
26
+ no_commands do
27
+ def_delegators :shell, :session
28
+ def_delegators :session, :stdout, :stdin, :stderr, :browser
29
+ def_delegators :stdout, :puts, :print
30
+ def_delegators :stdin, :gets
31
+ end
32
+ end
33
+ end
34
+
35
+ module ClassMethods
36
+ def start(given_args = ARGV, config = {})
37
+ session = config.delete(:session)
38
+ config[:shell] = Shell.new(session) if session
39
+ super(given_args, config)
40
+ end
41
+ end
42
+ end
43
+ end