terminalwire 0.1.0 → 0.1.2

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