terminalwire-client 0.3.0.alpha1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 60d8965eb9d76fbfe435e72747553489817c77e2a82fa5cdba7933d6c7798c21
4
+ data.tar.gz: 611a958e01b7216ebb23dd311db799f84bc76cfcda5b7c6dcecaaaf71959ed0e
5
+ SHA512:
6
+ metadata.gz: 1f9c42e92bc39458fd0ba4c3ea09fa5a240444f3a2e368ebe1aa62819b85dbe20037cadb3331d628737ed40e5e2300cbde5260a201571046b00a7cbf87e445b6
7
+ data.tar.gz: 5277aa2111746a0c19e25b4dcef9a1e51cb941e0efbd6ce408957e5166257da051cebc13920f3cb92039d2db43bf361c3529f98110ac5f74945c8a046debe8c8
@@ -0,0 +1,26 @@
1
+ module Terminalwire::Client::Entitlement
2
+ # ENV vars that the server can access on the client.
3
+ class EnvironmentVariables
4
+ include Enumerable
5
+
6
+ def initialize
7
+ @permitted = Set.new
8
+ end
9
+
10
+ def each(&)
11
+ @permitted.each(&)
12
+ end
13
+
14
+ def permit(variable)
15
+ @permitted << variable.to_s
16
+ end
17
+
18
+ def permitted?(key)
19
+ include? key.to_s
20
+ end
21
+
22
+ def serialize
23
+ map { |name| { name: } }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,97 @@
1
+ module Terminalwire::Client::Entitlement
2
+ # A list of paths and permissions that server has to write on the client workstation.
3
+ class Paths
4
+ class Permit
5
+ attr_reader :path, :mode
6
+ # Ensure the default file mode is read/write for owner only. This ensures
7
+ # that if the server tries uploading an executable file, it won't be when it
8
+ # lands on the client.
9
+ #
10
+ # Eventually we'll move this into entitlements so the client can set maximum
11
+ # permissions for files and directories.
12
+ MODE = 0o600 # rw-------
13
+
14
+ # Constants for permission bit masks
15
+ OWNER_PERMISSIONS = 0o700 # rwx------
16
+ GROUP_PERMISSIONS = 0o070 # ---rwx---
17
+ OTHERS_PERMISSIONS = 0o007 # ------rwx
18
+
19
+ # We'll validate that modes are within this range.
20
+ MODE_RANGE = 0o000..0o777
21
+
22
+ def initialize(path:, mode: MODE)
23
+ @path = Pathname.new(path)
24
+ @mode = convert(mode)
25
+ end
26
+
27
+ def permitted_path?(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 @path.to_s, path.to_s, File::FNM_PATHNAME
31
+ end
32
+
33
+ def permitted_mode?(value)
34
+ # Ensure the mode is at least as permissive as the permitted mode.
35
+ mode = convert(value)
36
+
37
+ # Extract permission bits for owner, group, and others
38
+ owner_bits = mode & OWNER_PERMISSIONS
39
+ group_bits = mode & GROUP_PERMISSIONS
40
+ others_bits = mode & OTHERS_PERMISSIONS
41
+
42
+ # Ensure that the mode doesn't grant more permissions than @mode in any class (owner, group, others)
43
+ (owner_bits <= @mode & OWNER_PERMISSIONS) &&
44
+ (group_bits <= @mode & GROUP_PERMISSIONS) &&
45
+ (others_bits <= @mode & OTHERS_PERMISSIONS)
46
+ end
47
+
48
+ def permitted?(path:, mode: @mode)
49
+ permitted_path?(path) && permitted_mode?(mode)
50
+ end
51
+
52
+ def serialize
53
+ {
54
+ location: @path.to_s,
55
+ mode: @mode
56
+ }
57
+ end
58
+
59
+ protected
60
+ def convert(value)
61
+ mode = Integer(value)
62
+ raise ArgumentError, "The mode #{format_octet value} must be an octet value between #{format_octet MODE_RANGE.first} and #{format_octet MODE_RANGE.last}" unless MODE_RANGE.cover?(mode)
63
+ mode
64
+ end
65
+
66
+ def format_octet(value)
67
+ format("0o%03o", value)
68
+ end
69
+ end
70
+
71
+ include Enumerable
72
+
73
+ def initialize
74
+ @permitted = []
75
+ end
76
+
77
+ def each(&)
78
+ @permitted.each(&)
79
+ end
80
+
81
+ def permit(path, **)
82
+ @permitted.append Permit.new(path:, **)
83
+ end
84
+
85
+ def permitted?(path, mode: nil)
86
+ if mode
87
+ find { |it| it.permitted_path?(path) and it.permitted_mode?(mode) }
88
+ else
89
+ find { |it| it.permitted_path?(path) }
90
+ end
91
+ end
92
+
93
+ def serialize
94
+ map(&:serialize)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,117 @@
1
+ module Terminalwire::Client::Entitlement
2
+ module Policy
3
+ # A policy has the authority, paths, and schemes that the server is allowed to access.
4
+ class Base
5
+ attr_reader :paths, :authority, :schemes, :environment_variables
6
+
7
+ def initialize(authority:)
8
+ @authority = authority
9
+ @paths = Paths.new
10
+
11
+ # Permit the domain directory. This is necessary for basic operation of the client.
12
+ @paths.permit storage_path
13
+ @paths.permit storage_pattern
14
+
15
+ @schemes = Schemes.new
16
+ # Permit http & https by default.
17
+ @schemes.permit "http"
18
+ @schemes.permit "https"
19
+
20
+ @environment_variables = EnvironmentVariables.new
21
+ # Permit the HOME and TERMINALWIRE_HOME environment variables.
22
+ @environment_variables.permit "TERMINALWIRE_HOME"
23
+ end
24
+
25
+ def root_path
26
+ # TODO: This needs to be passed into the Policy so that it can be set by the client.
27
+ Terminalwire::Client.root_path
28
+ end
29
+
30
+ def authority_path
31
+ root_path.join("authorities/#{authority}")
32
+ end
33
+
34
+ def storage_path
35
+ authority_path.join("storage")
36
+ end
37
+
38
+ def storage_pattern
39
+ storage_path.join("**/*")
40
+ end
41
+
42
+ def serialize
43
+ {
44
+ authority: @authority,
45
+ schemes: @schemes.serialize,
46
+ paths: @paths.serialize,
47
+ environment_variables: @environment_variables.serialize
48
+ }
49
+ end
50
+ end
51
+
52
+ class Root < Base
53
+ AUTHORITY = "terminalwire.com".freeze
54
+
55
+ # Terminalwire checks these to install the binary stubs path.
56
+ SHELL_INITIALIZATION_FILE_PATHS = %w[
57
+ ~/.bash_profile
58
+ ~/.bashrc
59
+ ~/.zprofile
60
+ ~/.zshrc
61
+ ~/.profile
62
+ ~/.config/fish/config.fish
63
+ ~/.bash_login
64
+ ~/.cshrc
65
+ ~/.tcshrc
66
+ ].freeze
67
+
68
+ # Ensure the binary stubs are executable. This increases the
69
+ # file mode entitlement so that stubs created in ./bin are executable.
70
+ BINARY_PATH_FILE_MODE = 0o755
71
+
72
+ def initialize(*, **, &)
73
+ # Make damn sure the authority is set to Terminalwire.
74
+ super(*, authority: AUTHORITY, **, &)
75
+
76
+ # Now setup special permitted paths.
77
+ @paths.permit root_path
78
+ @paths.permit root_pattern
79
+ # Permit the dotfiles so terminalwire can install the binary stubs.
80
+ SHELL_INITIALIZATION_FILE_PATHS.each do |path|
81
+ @paths.permit path
82
+ end
83
+
84
+ # Permit terminalwire to grant execute permissions to the binary stubs.
85
+ @paths.permit binary_pattern, mode: BINARY_PATH_FILE_MODE
86
+
87
+ # Used to check if terminalwire is setup in the user's PATH environment variable.
88
+ @environment_variables.permit "PATH"
89
+ end
90
+
91
+ # Grant access to the `~/.terminalwire/**/*` path so users can install
92
+ # terminalwire apps via `terminalwire install svbtle`, etc.
93
+ def root_pattern
94
+ root_path.join("**/*").freeze
95
+ end
96
+
97
+ # Path where the terminalwire binary stubs are stored.
98
+ def binary_path
99
+ root_path.join("bin").freeze
100
+ end
101
+
102
+ # Pattern for the binary path.
103
+ def binary_pattern
104
+ binary_path.join("*").freeze
105
+ end
106
+ end
107
+
108
+ def self.resolve(*, authority:, **, &)
109
+ case authority
110
+ when Policy::Root::AUTHORITY
111
+ Root.new(*, **, &)
112
+ else
113
+ Base.new *, authority:, **, &
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,26 @@
1
+ module Terminalwire::Client::Entitlement
2
+ # URLs the server can open on the client.
3
+ class Schemes
4
+ include Enumerable
5
+
6
+ def initialize
7
+ @permitted = Set.new
8
+ end
9
+
10
+ def each(&)
11
+ @permitted.each(&)
12
+ end
13
+
14
+ def permit(scheme)
15
+ @permitted << scheme.to_s
16
+ end
17
+
18
+ def permitted?(url)
19
+ include? URI(url).scheme
20
+ end
21
+
22
+ def serialize
23
+ map { |scheme| { scheme: } }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ require "pathname"
2
+
3
+ module Terminalwire::Client
4
+ # Entitlements are the security boundary between the server and the client that lives on the client.
5
+ # The server might request a file or directory from the client, and the client will check the entitlements
6
+ # to see if the server is authorized to access the requested resource.
7
+ module Entitlement
8
+ end
9
+ end
@@ -0,0 +1,44 @@
1
+ require "pathname"
2
+ require "yaml"
3
+ require "uri"
4
+
5
+ module Terminalwire::Client
6
+ # Called by the `terminalwire-exec` shebang in scripts. This makes it easy for people
7
+ # to create their own scripts that use Terminalwire that look like this:
8
+ #
9
+ # ```sh
10
+ # #!/usr/bin/env terminalwire-exec
11
+ # url: "https://terminalwire.com/terminal"
12
+ # ```
13
+ #
14
+ # These files are saved, then `chmod + x` is run on them and they become executable.
15
+ class Exec
16
+ attr_reader :arguments, :path, :configuration, :url
17
+
18
+ def initialize(path:, arguments:)
19
+ @arguments = arguments
20
+ @path = Pathname.new(path)
21
+ @configuration = YAML.safe_load_file(@path)
22
+ @url = URI(@configuration.fetch("url"))
23
+ rescue Errno::ENOENT => e
24
+ raise Terminalwire::Error, "File not found: #{@path}"
25
+ rescue URI::InvalidURIError => e
26
+ raise Terminalwire::Error, "Invalid URI: #{@url}"
27
+ rescue KeyError => e
28
+ raise Terminalwire::Error, "Missing key in configuration: #{e}"
29
+ end
30
+
31
+ def start
32
+ Terminalwire::Client.websocket(url:, arguments:)
33
+ end
34
+
35
+ def self.start
36
+ case ARGV
37
+ in path, *arguments
38
+ new(path:, arguments:).start
39
+ end
40
+ rescue NoMatchingPatternError => e
41
+ raise Terminalwire::Error, "Launched with incorrect arguments: #{ARGV}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,67 @@
1
+ module Terminalwire::Client
2
+ # The handler is the main class that connects to the Terminalwire server and
3
+ # dispatches messages to the appropriate resources.
4
+ class Handler
5
+ VERSION = "0.1.0".freeze
6
+
7
+ include Terminalwire::Logging
8
+
9
+ attr_reader :adapter, :resources, :endpoint
10
+ attr_accessor :entitlement
11
+
12
+ def initialize(adapter, arguments: ARGV, program_name: $0, endpoint:)
13
+ @endpoint = endpoint
14
+ @adapter = adapter
15
+ @program_arguments = arguments
16
+ @program_name = program_name
17
+ @entitlement = Entitlement::Policy.resolve(authority: @endpoint.authority)
18
+
19
+ yield self if block_given?
20
+
21
+ @resources = Resource::Handler.new do |it|
22
+ it << Resource::STDOUT.new("stdout", @adapter, entitlement:)
23
+ it << Resource::STDIN.new("stdin", @adapter, entitlement:)
24
+ it << Resource::STDERR.new("stderr", @adapter, entitlement:)
25
+ it << Resource::Browser.new("browser", @adapter, entitlement:)
26
+ it << Resource::File.new("file", @adapter, entitlement:)
27
+ it << Resource::Directory.new("directory", @adapter, entitlement:)
28
+ it << Resource::EnvironmentVariable.new("environment_variable", @adapter, entitlement:)
29
+ end
30
+ end
31
+
32
+ def verify_license
33
+ # Connect to the Terminalwire license server to verify the URL endpoint
34
+ # and displays a message to the user, if any are present.
35
+ $stdout.print ServerLicenseVerification.new(url: @endpoint.to_url).message
36
+ rescue
37
+ $stderr.puts "Failed to verify server license."
38
+ end
39
+
40
+ def connect
41
+ verify_license
42
+
43
+ @adapter.write(
44
+ event: "initialization",
45
+ protocol: { version: VERSION },
46
+ entitlement: @entitlement.serialize,
47
+ program: {
48
+ name: @program_name,
49
+ arguments: @program_arguments
50
+ }
51
+ )
52
+
53
+ loop do
54
+ handle @adapter.read
55
+ end
56
+ end
57
+
58
+ def handle(message)
59
+ case message
60
+ in { event: "resource", action: "command", name:, parameters: }
61
+ @resources.dispatch(**message)
62
+ in { event: "exit", status: }
63
+ exit Integer(status)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,204 @@
1
+ require "fileutils"
2
+ require "io/console"
3
+
4
+ module Terminalwire::Client::Resource
5
+ # Dispatches messages from the Client::Handler to the appropriate resource.
6
+ class Handler
7
+ include Enumerable
8
+
9
+ def initialize
10
+ @resources = {}
11
+ yield self if block_given?
12
+ end
13
+
14
+ def each(&block)
15
+ @resources.values.each(&block)
16
+ end
17
+
18
+ def add(resource)
19
+ # Detect if the resource is already registered and throw an error
20
+ if @resources.key?(resource.name)
21
+ raise "Resource #{resource.name} already registered"
22
+ else
23
+ @resources[resource.name] = resource
24
+ end
25
+ end
26
+ alias :<< :add
27
+
28
+ def dispatch(**message)
29
+ case message
30
+ in { event:, action:, name:, command:, parameters: }
31
+ resource = @resources.fetch(name)
32
+ resource.command(command, **parameters)
33
+ end
34
+ end
35
+ end
36
+
37
+ # Dispatcher, security, and response macros for resources.
38
+ class Base < Terminalwire::Resource::Base
39
+ def initialize(*, entitlement:, **)
40
+ super(*, **)
41
+ @entitlement = entitlement
42
+ connect
43
+ end
44
+
45
+ def command(command, **parameters)
46
+ begin
47
+ if permit(command, **parameters)
48
+ succeed self.public_send(command, **parameters)
49
+ else
50
+ fail "Client denied #{command}", command:, parameters:
51
+ end
52
+ rescue => e
53
+ fail e.message, command:, parameters:
54
+ raise
55
+ end
56
+ end
57
+
58
+ protected
59
+
60
+ def permit(...)
61
+ false
62
+ end
63
+ end
64
+
65
+ class EnvironmentVariable < Base
66
+ # Accepts a list of environment variables to permit.
67
+ def read(name:)
68
+ ENV[name]
69
+ end
70
+
71
+ # def write(name:, value:)
72
+ # ENV[name] = value
73
+ # end
74
+
75
+ protected
76
+
77
+ def permit(command, name:, **)
78
+ @entitlement.environment_variables.permitted? name
79
+ end
80
+ end
81
+
82
+ class STDOUT < Base
83
+ def connect
84
+ @io = $stdout
85
+ end
86
+
87
+ def print(data:)
88
+ @io.print(data)
89
+ end
90
+
91
+ def print_line(data:)
92
+ @io.puts(data)
93
+ end
94
+
95
+ protected
96
+
97
+ def permit(...)
98
+ true
99
+ end
100
+ end
101
+
102
+ class STDERR < STDOUT
103
+ def connect
104
+ @io = $stderr
105
+ end
106
+ end
107
+
108
+ class STDIN < Base
109
+ def connect
110
+ @io = $stdin
111
+ end
112
+
113
+ def read_line
114
+ @io.gets
115
+ end
116
+
117
+ def read_password
118
+ @io.getpass
119
+ end
120
+
121
+ protected
122
+
123
+ def permit(...)
124
+ true
125
+ end
126
+ end
127
+
128
+ class File < Base
129
+ File = ::File
130
+
131
+ def read(path:)
132
+ File.read File.expand_path(path)
133
+ end
134
+
135
+ def write(path:, content:, mode: nil)
136
+ File.open(File.expand_path(path), "w", mode) { |f| f.write(content) }
137
+ end
138
+
139
+ def append(path:, content:, mode: nil)
140
+ File.open(File.expand_path(path), "a", mode) { |f| f.write(content) }
141
+ end
142
+
143
+ def delete(path:)
144
+ File.delete File.expand_path(path)
145
+ end
146
+
147
+ def exist(path:)
148
+ File.exist? File.expand_path(path)
149
+ end
150
+
151
+ def change_mode(path:, mode:)
152
+ File.chmod mode, File.expand_path(path)
153
+ end
154
+
155
+ protected
156
+
157
+ def permit(command, path:, mode: nil, **)
158
+ @entitlement.paths.permitted? path, mode:
159
+ end
160
+ end
161
+
162
+ class Directory < Base
163
+ File = ::File
164
+
165
+ def list(path:)
166
+ Dir.glob path
167
+ end
168
+
169
+ def create(path:)
170
+ FileUtils.mkdir_p File.expand_path(path)
171
+ rescue Errno::EEXIST
172
+ # Do nothing
173
+ end
174
+
175
+ def exist(path:)
176
+ Dir.exist? path
177
+ end
178
+
179
+ def delete(path:)
180
+ Dir.delete path
181
+ end
182
+
183
+ protected
184
+
185
+ def permit(command, path:, **)
186
+ @entitlement.paths.permitted? path
187
+ end
188
+ end
189
+
190
+ class Browser < Base
191
+ def launch(url:)
192
+ Launchy.open(URI(url))
193
+ # TODO: This is a hack to get the `respond` method to work.
194
+ # Maybe explicitly call a `suceed` and `fail` method?
195
+ nil
196
+ end
197
+
198
+ protected
199
+
200
+ def permit(command, url:, **)
201
+ @entitlement.schemes.permitted? url
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,74 @@
1
+ require "async/http/internet"
2
+ require "base64"
3
+ require "uri"
4
+ require "fileutils"
5
+
6
+ module Terminalwire::Client
7
+ # Checkes the server for a license verification at `https://terminalwire.com/licenses/verifications/`
8
+ # and displays the message to the user, if necessary.
9
+ class ServerLicenseVerification
10
+ include Terminalwire::Logging
11
+
12
+ def initialize(url:)
13
+ @url = URI(url)
14
+ @internet = Async::HTTP::Internet.new
15
+ @cache_store = Terminalwire::Cache::File::Store.new(path: Terminalwire::Client.root_path.join("cache/licenses/verifications"))
16
+ end
17
+
18
+ def key
19
+ Base64.urlsafe_encode64 @url
20
+ end
21
+
22
+ def cache = @cache_store.entry key
23
+
24
+ def payload
25
+ if cache.miss?
26
+ logger.debug "Stale verification. Requesting new verification."
27
+ request do |response|
28
+ # Set the expiry on the file cache for the header.
29
+ if max_age = response.headers["cache-control"].max_age
30
+ logger.debug "Caching for #{max_age}"
31
+ cache.expires = Time.now + max_age
32
+ end
33
+
34
+ # Process based on the response code.
35
+ case response.status
36
+ in 200
37
+ logger.debug "License for #{@url} found."
38
+ data = self.class.unpack response.read
39
+ cache.value = data
40
+ return data
41
+ in 404
42
+ logger.debug "License for #{@url} not found."
43
+ return self.class.unpack response.read
44
+ end
45
+ end
46
+ else
47
+ return cache.value
48
+ end
49
+ end
50
+
51
+ def message
52
+ payload.dig(:shell, :output)
53
+ end
54
+
55
+ protected
56
+
57
+ def verification_url
58
+ Terminalwire.url
59
+ .path("/licenses/verifications", key)
60
+ end
61
+
62
+ def request(&)
63
+ logger.debug "Requesting license verification from #{verification_url}."
64
+ response = @internet.get verification_url, {
65
+ "Accept" => "application/x-msgpack",
66
+ "User-Agent" => "Terminalwire/#{Terminalwire::VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})",
67
+ }, &
68
+ end
69
+
70
+ def self.unpack(pack)
71
+ MessagePack.unpack(pack, symbolize_keys: true)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,45 @@
1
+ require 'terminalwire'
2
+
3
+ require 'launchy'
4
+ require 'io/console'
5
+ require 'pathname'
6
+
7
+ require 'forwardable'
8
+ require 'uri'
9
+
10
+ require 'async'
11
+ require 'async/http/endpoint'
12
+ require 'async/websocket/client'
13
+ require 'async/websocket/adapters/rack'
14
+ require 'uri-builder'
15
+
16
+ require "zeitwerk"
17
+ Zeitwerk::Loader.for_gem_extension(Terminalwire).tap do |loader|
18
+ loader.setup
19
+ end
20
+
21
+ module Terminalwire
22
+ module Client
23
+ ROOT_PATH = "~/.terminalwire".freeze
24
+ def self.root_path = Pathname.new(ENV.fetch("TERMINALWIRE_HOME", ROOT_PATH))
25
+
26
+ def self.websocket(url:, arguments: ARGV, &configuration)
27
+ ENV["TERMINALWIRE_HOME"] ||= root_path.to_s
28
+
29
+ url = URI(url)
30
+
31
+ Async do |task|
32
+ endpoint = Async::HTTP::Endpoint.parse(
33
+ url,
34
+ alpn_protocols: Async::HTTP::Protocol::HTTP11.names
35
+ )
36
+
37
+ Async::WebSocket::Client.connect(endpoint) do |adapter|
38
+ transport = Terminalwire::Transport::WebSocket.new(adapter)
39
+ adapter = Terminalwire::Adapter::Socket.new(transport)
40
+ Terminalwire::Client::Handler.new(adapter, arguments:, endpoint:, &configuration).connect
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terminalwire/client'
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terminalwire-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0.alpha1
5
+ platform: ruby
6
+ authors:
7
+ - Brad Gessler
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-12-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: launchy
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: terminalwire-core
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 0.3.0.alpha1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 0.3.0.alpha1
41
+ description: Stream command-line apps from your server without a web API
42
+ email:
43
+ - brad@terminalwire.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - lib/terminalwire-client.rb
49
+ - lib/terminalwire/client.rb
50
+ - lib/terminalwire/client/entitlement.rb
51
+ - lib/terminalwire/client/entitlement/environment_variables.rb
52
+ - lib/terminalwire/client/entitlement/paths.rb
53
+ - lib/terminalwire/client/entitlement/policy.rb
54
+ - lib/terminalwire/client/entitlement/schemes.rb
55
+ - lib/terminalwire/client/exec.rb
56
+ - lib/terminalwire/client/handler.rb
57
+ - lib/terminalwire/client/resource.rb
58
+ - lib/terminalwire/client/server_license_verification.rb
59
+ homepage: https://terminalwire.com/ruby
60
+ licenses:
61
+ - Proprietary (https://terminalwire.com/license)
62
+ metadata:
63
+ allowed_push_host: https://rubygems.org/
64
+ homepage_uri: https://terminalwire.com/ruby
65
+ source_code_uri: https://github.com/terminalwire/ruby/tree/main/terminalwire-client
66
+ changelog_uri: https://github.com/terminalwire/ruby/tags
67
+ funding_uri: https://terminalwire.com/funding
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.0.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.5.9
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Ship a CLI for your web app. No API required.
87
+ test_files: []