terminalwire 0.1.0 → 0.1.2

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: 8eacca5d9e410385fee49a9115cdc9d92ac1276b0666af88b8c343f11306a05f
4
+ data.tar.gz: c94050aef56399743f202c13c3813bb66a05cfffd2333ea9431bccded43234f2
5
5
  SHA512:
6
- metadata.gz: 9f5761849e14d51d6491ea7c024769724607081db57968d05abcc25c713dd4afb7808ab4d29873b50c4b2611bcc5e246be3dc7c0bb436fd9ef8c8c4e98e35e10
7
- data.tar.gz: f23b101082f1cd1395742ffafffb5c87b5a31d0255153ec5b8c7139cde00a4419575056593a25da6276e33a27b190da2e45b6f3512fa38c7489c4ce98a91a0f3
6
+ metadata.gz: 2a25a33121a603fdcb11c5c664d17772f999d4a379379390ad578ed434fa75428f45deda6d2023e490ecf2bedfad61cf58a9fd4d7c880e17fff714978075159c
7
+ data.tar.gz: 76e5bf2edd88ad0ca092328514ebce7bc22ee64ff13aa4ebf83c77319849fe2564515cc89bf27364f3135ca2456143869066ca6eaef70daae15dabedb006b3e3
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::Exec.start
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::Server::Thor.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,54 @@
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 = getpass
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
+ desc "whoami", "Displays current user information."
28
+ def whoami
29
+ if self.current_user
30
+ puts "Logged in as #{user.email}."
31
+ else
32
+ puts "Not logged in. Run `#{self.class.basename} login` to login."
33
+ end
34
+ end
35
+
36
+ desc "logout", "Logout of your account"
37
+ def logout
38
+ session.reset
39
+ puts "Successfully logged out."
40
+ end
41
+
42
+ private
43
+
44
+ def current_user=(user)
45
+ # The Session object is a hash-like object that encrypts and signs a hash that's
46
+ # stored on the client's file sytem. Conceptually, it's similar to Rails signed
47
+ # and encrypted client-side cookies.
48
+ session["user_id"] = user.id
49
+ end
50
+
51
+ def current_user
52
+ @current_user ||= User.find(session.fetch("user_id"))
53
+ end
54
+ end
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env terminalwire-exec
2
+ url: "ws://localhost:3000/terminal"
@@ -0,0 +1,32 @@
1
+ require 'msgpack'
2
+
3
+ module Terminalwire
4
+ class Adapter
5
+ include Logging
6
+
7
+ attr_reader :transport
8
+
9
+ def initialize(transport)
10
+ @transport = transport
11
+ end
12
+
13
+ def write(data)
14
+ logger.debug "Adapter: Sending #{data.inspect}"
15
+ packed_data = MessagePack.pack(data, symbolize_keys: true)
16
+ @transport.write(packed_data)
17
+ end
18
+
19
+ def recv
20
+ logger.debug "Adapter: Reading"
21
+ packed_data = @transport.read
22
+ return nil if packed_data.nil?
23
+ data = MessagePack.unpack(packed_data, symbolize_keys: true)
24
+ logger.debug "Adapter: Received #{data.inspect}"
25
+ data
26
+ end
27
+
28
+ def close
29
+ @transport.close
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,107 @@
1
+ module Terminalwire::Client
2
+ class Entitlement
3
+ class Paths
4
+ include Enumerable
5
+
6
+ def initialize
7
+ @permitted = []
8
+ end
9
+
10
+ def each(&)
11
+ @permitted.each(&)
12
+ end
13
+
14
+ def permit(path)
15
+ @permitted.append Pathname.new(path).expand_path
16
+ end
17
+
18
+ def permitted?(path)
19
+ @permitted.find { |pattern| matches?(permitted: pattern, path:) }
20
+ end
21
+
22
+ def serialize
23
+ @permitted.to_a.map(&:to_s)
24
+ end
25
+
26
+ private
27
+ def matches?(permitted:, path:)
28
+ # This MUST be done via File.fnmatch because Pathname#fnmatch does not work. If you
29
+ # try changing this 🚨 YOU MAY CIRCUMVENT THE SECURITY MEASURES IN PLACE. 🚨
30
+ File.fnmatch permitted.to_s, File.expand_path(path), File::FNM_PATHNAME
31
+ end
32
+ end
33
+
34
+ class Schemes
35
+ include Enumerable
36
+
37
+ def initialize
38
+ @permitted = Set.new
39
+ end
40
+
41
+ def each(&)
42
+ @permitted.each(&)
43
+ end
44
+
45
+ def permit(scheme)
46
+ @permitted << scheme.to_s
47
+ end
48
+
49
+ def permitted?(url)
50
+ include? URI(url).scheme
51
+ end
52
+
53
+ def serialize
54
+ @permitted.to_a.map(&:to_s)
55
+ end
56
+ end
57
+
58
+ attr_reader :paths, :authority, :schemes
59
+
60
+ def initialize(authority:)
61
+ @authority = authority
62
+ @paths = Paths.new
63
+
64
+ # Permit the domain directory. This is necessary for basic operation of the client.
65
+ @paths.permit storage_path
66
+ @paths.permit storage_pattern
67
+
68
+ @schemes = Schemes.new
69
+ # Permit http & https by default.
70
+ @schemes.permit "http"
71
+ @schemes.permit "https"
72
+ end
73
+
74
+ def domain_path
75
+ Pathname.new("~/.terminalwire/authorities/#{@authority}").expand_path
76
+ end
77
+
78
+ def storage_path
79
+ domain_path.join("storage")
80
+ end
81
+
82
+ def storage_pattern
83
+ storage_path.join("**/*")
84
+ end
85
+
86
+ def serialize
87
+ {
88
+ authority: @authority,
89
+ schemes: @schemes.serialize,
90
+ paths: @paths.serialize,
91
+ storage_path: storage_path.to_s,
92
+ }
93
+ end
94
+
95
+ def self.from_url(url)
96
+ # I had to lift this from URI::HTTP because `ws://` doesn't
97
+ # have an authority method.
98
+ authority = if url.port == url.default_port
99
+ url.host
100
+ else
101
+ "#{url.host}:#{url.port}"
102
+ end
103
+
104
+ new authority:
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,35 @@
1
+ require "pathname"
2
+ require "yaml"
3
+ require "uri"
4
+
5
+ module Terminalwire::Client
6
+ class Exec
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.start
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,154 @@
1
+ module Terminalwire::Client::Resource
2
+ # Dispatches messages from the Client::Handler to the appropriate resource.
3
+ class Handler
4
+ include Enumerable
5
+
6
+ def initialize
7
+ @resources = {}
8
+ yield self if block_given?
9
+ end
10
+
11
+ def each(&block)
12
+ @resources.values.each(&block)
13
+ end
14
+
15
+ def add(resource)
16
+ # Detect if the resource is already registered and throw an error
17
+ if @resources.key?(resource.name)
18
+ raise "Resource #{resource.name} already registered"
19
+ else
20
+ @resources[resource.name] = resource
21
+ end
22
+ end
23
+ alias :<< :add
24
+
25
+ def dispatch(**message)
26
+ case message
27
+ in { event:, action:, name:, command:, parameters: }
28
+ resource = @resources.fetch(name)
29
+ resource.command(command, **parameters)
30
+ end
31
+ end
32
+ end
33
+
34
+ # Dispatcher, security, and response macros for resources.
35
+ class Base < Terminalwire::Resource::Base
36
+ def initialize(*, entitlement:, **)
37
+ super(*, **)
38
+ @entitlement = entitlement
39
+ connect
40
+ end
41
+
42
+ def command(command, **parameters)
43
+ begin
44
+ if permit(command, **parameters)
45
+ succeed self.public_send(command, **parameters)
46
+ else
47
+ fail "Client denied #{command}", command:, parameters:
48
+ end
49
+ rescue => e
50
+ fail e.message, command:, parameters:
51
+ raise
52
+ end
53
+ end
54
+
55
+ def permit(...)
56
+ false
57
+ end
58
+ end
59
+
60
+ class STDOUT < Base
61
+ def connect
62
+ @io = $stdout
63
+ end
64
+
65
+ def print(data:)
66
+ @io.print(data)
67
+ end
68
+
69
+ def print_line(data:)
70
+ @io.puts(data)
71
+ end
72
+
73
+ def permit(...)
74
+ true
75
+ end
76
+ end
77
+
78
+ class STDERR < STDOUT
79
+ def connect
80
+ @io = $stderr
81
+ end
82
+ end
83
+
84
+ class STDIN < Base
85
+ def connect
86
+ @io = $stdin
87
+ end
88
+
89
+ def read_line
90
+ @io.gets
91
+ end
92
+
93
+ def read_password
94
+ @io.getpass
95
+ end
96
+
97
+ def permit(...)
98
+ true
99
+ end
100
+ end
101
+
102
+ class File < Base
103
+ File = ::File
104
+
105
+ # Ensure the default file mode is read/write for owner only. This ensures
106
+ # that if the server tries uploading an executable file, it won't be when it
107
+ # lands on the client.
108
+ #
109
+ # Eventually we'll move this into entitlements so the client can set maximum
110
+ # permissions for files and directories.
111
+ FILE_PERMISSIONS = 0o600 # rw-------
112
+
113
+ def read(path:)
114
+ File.read File.expand_path(path)
115
+ end
116
+
117
+ def write(path:, content:)
118
+ File.open(File.expand_path(path), "w", FILE_PERMISSIONS) { |f| f.write(content) }
119
+ end
120
+
121
+ def append(path:, content:)
122
+ File.open(File.expand_path(path), "a", FILE_PERMISSIONS) { |f| f.write(content) }
123
+ end
124
+
125
+ def mkdir(path:)
126
+ FileUtils.mkdir_p(File.expand_path(path))
127
+ end
128
+
129
+ def delete(path:)
130
+ File.delete(File.expand_path(path))
131
+ end
132
+
133
+ def exist(path:)
134
+ File.exist? File.expand_path(path)
135
+ end
136
+
137
+ def permit(command, path:, **)
138
+ @entitlement.paths.permitted? path
139
+ end
140
+ end
141
+
142
+ class Browser < Base
143
+ def permit(command, url:, **)
144
+ @entitlement.schemes.permitted? url
145
+ end
146
+
147
+ def launch(url:)
148
+ Launchy.open(URI(url))
149
+ # TODO: This is a hack to get the `respond` method to work.
150
+ # Maybe explicitly call a `suceed` and `fail` method?
151
+ nil
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,91 @@
1
+ require 'fileutils'
2
+ require 'launchy'
3
+ require 'io/console'
4
+
5
+ module Terminalwire
6
+ module Client
7
+ class Handler
8
+ VERSION = "0.1.0".freeze
9
+
10
+ include Logging
11
+
12
+ attr_reader :adapter, :entitlement, :resources
13
+
14
+ def initialize(adapter, arguments: ARGV, program_name: $0, entitlement:)
15
+ @entitlement = entitlement
16
+ @adapter = adapter
17
+ @program_arguments = arguments
18
+ @program_name = program_name
19
+
20
+ @resources = Resource::Handler.new do |it|
21
+ it << Resource::STDOUT.new("stdout", @adapter, entitlement:)
22
+ it << Resource::STDIN.new("stdin", @adapter, entitlement:)
23
+ it << Resource::STDERR.new("stderr", @adapter, entitlement:)
24
+ it << Resource::Browser.new("browser", @adapter, entitlement:)
25
+ it << Resource::File.new("file", @adapter, entitlement:)
26
+ end
27
+ end
28
+
29
+ def connect
30
+ @adapter.write(event: "initialization",
31
+ protocol: { version: VERSION },
32
+ entitlement: @entitlement.serialize,
33
+ program: {
34
+ name: @program_name,
35
+ arguments: @program_arguments
36
+ })
37
+
38
+ loop do
39
+ handle @adapter.recv
40
+ end
41
+ end
42
+
43
+ def handle(message)
44
+ case message
45
+ in { event: "resource", action: "command", name:, parameters: }
46
+ @resources.dispatch(**message)
47
+ in { event: "exit", status: }
48
+ exit Integer(status)
49
+ end
50
+ end
51
+ end
52
+
53
+ def self.tcp(...)
54
+ socket = TCPSocket.new(...)
55
+ transport = Terminalwire::Transport::Socket.new(socket)
56
+ adapter = Terminalwire::Adapter.new(transport)
57
+ Terminalwire::Client::Handler.new(adapter)
58
+ end
59
+
60
+ def self.socket(...)
61
+ socket = UNIXSocket.new(...)
62
+ transport = Terminalwire::Transport::Socket.new(socket)
63
+ adapter = Terminalwire::Adapter.new(transport)
64
+ Terminalwire::Client::Handler.new(adapter)
65
+ end
66
+
67
+ # Extracted from HTTP. This is so we can
68
+ def self.authority(url)
69
+ if url.port == url.default_port
70
+ url.host
71
+ else
72
+ "#{url.host}:#{url.port}"
73
+ end
74
+ end
75
+
76
+ def self.websocket(url:, arguments: ARGV)
77
+ url = URI(url)
78
+
79
+ Async do |task|
80
+ endpoint = Async::HTTP::Endpoint.parse(url)
81
+
82
+ Async::WebSocket::Client.connect(endpoint) do |adapter|
83
+ transport = Terminalwire::Transport::WebSocket.new(adapter)
84
+ adapter = Terminalwire::Adapter.new(transport)
85
+ entitlement = Entitlement.from_url(url)
86
+ Terminalwire::Client::Handler.new(adapter, arguments:, entitlement:).connect
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,8 @@
1
+ require 'logger'
2
+
3
+ module Terminalwire
4
+ module Logging
5
+ DEVICE = Logger.new($stdout, level: ENV.fetch("LOG_LEVEL", "info"))
6
+ def logger = DEVICE
7
+ end
8
+ end
@@ -0,0 +1,69 @@
1
+ require 'jwt'
2
+ require 'pathname'
3
+ require 'forwardable'
4
+
5
+ module Terminalwire::Rails
6
+ class Session
7
+ # JWT file name for the session file.
8
+ FILENAME = "session.jwt"
9
+
10
+ # Empty dictionary the user can stash all their session data into.
11
+ EMPTY_SESSION = {}.freeze
12
+
13
+ extend Forwardable
14
+
15
+ # Delegate `dig` and `fetch` to the `read` method
16
+ def_delegators :read,
17
+ :dig, :fetch, :[]
18
+
19
+ def initialize(context:, path: nil, secret_key: self.class.secret_key)
20
+ @context = context
21
+ @path = path || context.storage_path
22
+ @config_file_path = @path.join(FILENAME)
23
+ @secret_key = secret_key
24
+
25
+ ensure_file
26
+ end
27
+
28
+ def read
29
+ jwt_token = @context.file.read(@config_file_path)
30
+ decoded_data = JWT.decode(jwt_token, @secret_key, true, algorithm: 'HS256')
31
+ decoded_data[0] # JWT payload is the first element in the array
32
+ rescue JWT::DecodeError => e
33
+ raise "Invalid or tampered file: #{e.message}"
34
+ end
35
+
36
+ def reset
37
+ @context.file.delete @config_file_path
38
+ end
39
+
40
+ def edit
41
+ config = read
42
+ yield config
43
+ write(config)
44
+ end
45
+
46
+ def []=(key, value)
47
+ edit { |config| config[key] = value }
48
+ end
49
+
50
+ def write(config)
51
+ token = JWT.encode(config, @secret_key, 'HS256')
52
+ @context.file.write(@config_file_path, token)
53
+ end
54
+
55
+ private
56
+
57
+ def ensure_file
58
+ return true if @context.file.exist? @config_file_path
59
+ # Create the path if it doesn't exist on the client.
60
+ @context.file.mkdir(@path) unless @context.file.exist?(@path)
61
+ # Write an empty configuration on initialization
62
+ write(EMPTY_SESSION)
63
+ end
64
+
65
+ def self.secret_key
66
+ Rails.application.secret_key_base
67
+ end
68
+ end
69
+ end