terminalwire 0.1.0 → 0.1.1

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