pipeops-rexec 1.0.0 → 1.1.0

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: daf648a63c80b783c7701b1543ba59903618e531b5560a484b6274d3dde81bb1
4
- data.tar.gz: d02a328b6ace511f14c478d3928f79fd5be132624c9b23ee649c9e77f2e3b0ef
3
+ metadata.gz: e7ba59743a1e821530bd403adb43dcba9aff15c995197741c9f2c309325b1599
4
+ data.tar.gz: 902155d9d6f68d257668356fb6854b8b3470271f2f23f9b1905cef0822d5c7cd
5
5
  SHA512:
6
- metadata.gz: d5e4289f26441de35cc62b9bbd10a354a4ed5b70ef541e90eab7fc9292402986a0e0e99fbc5f34220d4721a08fa1020f41d263f2c97b4b3bd338aac96b7ad91d
7
- data.tar.gz: 892d324fd210063705b9722cff702c7680d4b972f6e8529ccbe27c4a84ae9f66cb6ce5acf1e21bc7769ff19ac9adc46fea1d6f0892589ae631dd69a99b0a2410
6
+ metadata.gz: 5f22905b095e20c123f27b0b4958881f9020e25569856b9112c1d16725fb6bca8acf2b7b9c20a4b1389d17ea57d3a27f75122669df06ff2fd1e8cb7b091f1c56
7
+ data.tar.gz: 32d3ec942eac748f79adad09d21cc9d004ea85d0050846656b70b35981286e3884b8783f5ee4b7a56569e2f1bac3a090234dd3a9784c4621ff986cef1942bc70
data/README.md CHANGED
@@ -25,7 +25,7 @@ client = Rexec::Client.new("https://your-instance.com", "your-token")
25
25
 
26
26
  # Create a container
27
27
  container = client.containers.create(
28
- image: "ubuntu:24.04",
28
+ image: "ubuntu",
29
29
  name: "my-sandbox"
30
30
  )
31
31
  puts "Created container: #{container.id}"
@@ -74,7 +74,7 @@ container = client.containers.get("container-id")
74
74
 
75
75
  # Create a container
76
76
  container = client.containers.create(
77
- image: "ubuntu:24.04",
77
+ image: "ubuntu",
78
78
  name: "my-container",
79
79
  environment: { "MY_VAR" => "value" },
80
80
  labels: { "project" => "demo" }
@@ -176,7 +176,7 @@ def create_batch(client, count)
176
176
  futures = (0...count).map do |i|
177
177
  Concurrent::Future.execute do
178
178
  client.containers.create(
179
- image: "ubuntu:24.04",
179
+ image: "ubuntu",
180
180
  name: "worker-#{i}"
181
181
  )
182
182
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Rexec
7
+ # Main client for interacting with Rexec API.
8
+ #
9
+ # @example
10
+ # client = Rexec::Client.new("https://rexec.sh", "your-token")
11
+ # sandboxes = client.sandboxes.list
12
+ # # Legacy: client.containers is the same service
13
+ #
14
+ class Client
15
+ attr_reader :base_url, :sandboxes, :files, :terminal
16
+
17
+ # Deprecated alias for {#sandboxes}.
18
+ # @return [SandboxService]
19
+ def containers
20
+ sandboxes
21
+ end
22
+
23
+ # Initialize a new Rexec client.
24
+ #
25
+ # @param base_url [String] Base URL of your Rexec instance
26
+ # @param token [String] API token for authentication
27
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
28
+ def initialize(base_url, token, timeout: 30)
29
+ @base_url = base_url.chomp("/")
30
+ @token = token
31
+ @timeout = timeout
32
+
33
+ @http = Faraday.new(url: @base_url) do |f|
34
+ f.request :json
35
+ f.response :json, content_type: /\bjson$/
36
+ f.adapter Faraday.default_adapter
37
+ f.options.timeout = timeout
38
+ f.headers["Authorization"] = "Bearer #{token}"
39
+ f.headers["Accept"] = "application/json"
40
+ end
41
+
42
+ @sandboxes = SandboxService.new(self)
43
+ @files = FileService.new(self)
44
+ @terminal = TerminalService.new(self)
45
+ end
46
+
47
+ # Make an API request.
48
+ # @api private
49
+ def request(method, path, body: nil, params: nil)
50
+ response = @http.run_request(method, path, body, nil) do |req|
51
+ req.params = params if params
52
+ end
53
+
54
+ handle_response(response)
55
+ end
56
+
57
+ # Make a raw request and return bytes.
58
+ # @api private
59
+ def request_bytes(method, path)
60
+ raw_http = Faraday.new(url: @base_url) do |f|
61
+ f.adapter Faraday.default_adapter
62
+ f.options.timeout = @timeout
63
+ f.headers["Authorization"] = "Bearer #{@token}"
64
+ end
65
+
66
+ response = raw_http.run_request(method, path, nil, nil)
67
+
68
+ if response.status >= 400
69
+ raise APIError.new(response.status, "Request failed")
70
+ end
71
+
72
+ response.body
73
+ end
74
+
75
+ # Get WebSocket URL.
76
+ # @api private
77
+ def ws_url(path)
78
+ uri = URI.parse(@base_url)
79
+ ws_scheme = uri.scheme == "https" ? "wss" : "ws"
80
+ "#{ws_scheme}://#{uri.host}:#{uri.port || (uri.scheme == 'https' ? 443 : 80)}#{path}"
81
+ end
82
+
83
+ # Get the API token.
84
+ # @api private
85
+ attr_reader :token
86
+
87
+ private
88
+
89
+ def handle_response(response)
90
+ case response.status
91
+ when 200..299
92
+ response.body
93
+ when 401, 403
94
+ raise AuthError.new(extract_error_message(response))
95
+ when 404
96
+ raise APIError.new(404, extract_error_message(response))
97
+ else
98
+ raise APIError.new(response.status, extract_error_message(response), response.body)
99
+ end
100
+ end
101
+
102
+ def extract_error_message(response)
103
+ if response.body.is_a?(Hash)
104
+ response.body["error"] || response.body["message"] || "Unknown error"
105
+ else
106
+ "Unknown error"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rexec
4
+ # Represents a Rexec sandbox (isolated Linux environment).
5
+ class Sandbox
6
+ attr_reader :id, :name, :image, :status, :created_at, :started_at, :labels, :environment
7
+
8
+ def initialize(data)
9
+ @id = data["id"]
10
+ @name = data["name"]
11
+ @image = data["image"]
12
+ @status = data["status"]
13
+ @created_at = data["created_at"]
14
+ @started_at = data["started_at"]
15
+ @labels = data["labels"] || {}
16
+ @environment = data["environment"] || {}
17
+ end
18
+
19
+ def running?
20
+ status == "running"
21
+ end
22
+
23
+ def stopped?
24
+ status == "stopped"
25
+ end
26
+ end
27
+
28
+ # @deprecated Use {Sandbox}
29
+ Container = Sandbox
30
+
31
+ # Service for managing sandboxes. HTTP paths remain /api/containers.
32
+ class SandboxService
33
+ def initialize(client)
34
+ @client = client
35
+ end
36
+
37
+ # List all sandboxes.
38
+ #
39
+ # @return [Array<Sandbox>]
40
+ def list
41
+ data = @client.request(:get, "/api/containers")
42
+ # API returns { "containers" => [...], "count" => N, "limit" => M }
43
+ items = data.is_a?(Array) ? data : (data && data["containers"]) || []
44
+ items.map { |c| Sandbox.new(c) }
45
+ end
46
+
47
+ # Get a sandbox by ID.
48
+ #
49
+ # @param id [String] Sandbox ID
50
+ # @return [Sandbox]
51
+ def get(id)
52
+ data = @client.request(:get, "/api/containers/#{id}")
53
+ Sandbox.new(data)
54
+ end
55
+
56
+ # Create a new sandbox.
57
+ #
58
+ # @param image [String] Image alias (e.g. "ubuntu")
59
+ # @param name [String, nil] Optional sandbox name
60
+ # @param environment [Hash] Environment variables
61
+ # @param labels [Hash] Labels
62
+ # @return [Sandbox]
63
+ #
64
+ # @example
65
+ # sandbox = client.sandboxes.create(
66
+ # image: "ubuntu",
67
+ # name: "my-sandbox",
68
+ # environment: { "MY_VAR" => "value" }
69
+ # )
70
+ def create(image:, name: nil, environment: {}, labels: {})
71
+ body = { image: image }
72
+ body[:name] = name if name
73
+ body[:environment] = environment unless environment.empty?
74
+ body[:labels] = labels unless labels.empty?
75
+
76
+ data = @client.request(:post, "/api/containers", body: body)
77
+ Sandbox.new(data)
78
+ end
79
+
80
+ # Delete a sandbox.
81
+ #
82
+ # @param id [String] Sandbox ID
83
+ def delete(id)
84
+ @client.request(:delete, "/api/containers/#{id}")
85
+ nil
86
+ end
87
+
88
+ # Start a sandbox.
89
+ #
90
+ # @param id [String] Sandbox ID
91
+ def start(id)
92
+ @client.request(:post, "/api/containers/#{id}/start")
93
+ nil
94
+ end
95
+
96
+ # Stop a sandbox.
97
+ #
98
+ # @param id [String] Sandbox ID
99
+ def stop(id)
100
+ @client.request(:post, "/api/containers/#{id}/stop")
101
+ nil
102
+ end
103
+ end
104
+
105
+ # @deprecated Use {SandboxService}
106
+ ContainerService = SandboxService
107
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rexec
4
+ # Base error class for all Rexec errors.
5
+ class Error < StandardError; end
6
+
7
+ # API error with status code.
8
+ class APIError < Error
9
+ attr_reader :status_code, :response_body
10
+
11
+ def initialize(status_code, message, response_body = nil)
12
+ @status_code = status_code
13
+ @response_body = response_body
14
+ super("API error #{status_code}: #{message}")
15
+ end
16
+ end
17
+
18
+ # Authentication error (401/403).
19
+ class AuthError < APIError
20
+ def initialize(message = "Authentication failed")
21
+ super(401, message)
22
+ end
23
+ end
24
+
25
+ # Resource not found (404).
26
+ class NotFoundError < APIError
27
+ attr_reader :resource, :resource_id
28
+
29
+ def initialize(resource, resource_id)
30
+ @resource = resource
31
+ @resource_id = resource_id
32
+ super(404, "#{resource} '#{resource_id}' not found")
33
+ end
34
+ end
35
+
36
+ # Connection error.
37
+ class ConnectionError < Error; end
38
+
39
+ # Terminal connection closed.
40
+ class TerminalClosedError < Error
41
+ def initialize
42
+ super("Terminal connection closed")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Rexec
6
+ # Represents file metadata.
7
+ class FileInfo
8
+ attr_reader :name, :path, :size, :mode, :mod_time, :is_dir
9
+
10
+ def initialize(data)
11
+ @name = data["name"]
12
+ @path = data["path"]
13
+ @size = data["size"] || 0
14
+ @mode = data["mode"]
15
+ @mod_time = data["mod_time"]
16
+ @is_dir = data["is_dir"] || false
17
+ end
18
+
19
+ alias directory? is_dir
20
+
21
+ def file?
22
+ !is_dir
23
+ end
24
+ end
25
+
26
+ # Service for file operations in containers.
27
+ class FileService
28
+ def initialize(client)
29
+ @client = client
30
+ end
31
+
32
+ # List files in a directory.
33
+ #
34
+ # @param container_id [String] Container ID
35
+ # @param path [String] Directory path
36
+ # @return [Array<FileInfo>]
37
+ def list(container_id, path = "/")
38
+ encoded_path = URI.encode_www_form_component(path)
39
+ data = @client.request(:get, "/api/containers/#{container_id}/files/list?path=#{encoded_path}")
40
+ data.map { |f| FileInfo.new(f) }
41
+ end
42
+
43
+ # Download a file.
44
+ #
45
+ # @param container_id [String] Container ID
46
+ # @param path [String] File path
47
+ # @return [String] File contents
48
+ def download(container_id, path)
49
+ encoded_path = URI.encode_www_form_component(path)
50
+ @client.request_bytes(:get, "/api/containers/#{container_id}/files?path=#{encoded_path}")
51
+ end
52
+
53
+ # Create a directory.
54
+ #
55
+ # @param container_id [String] Container ID
56
+ # @param path [String] Directory path
57
+ def mkdir(container_id, path)
58
+ @client.request(:post, "/api/containers/#{container_id}/files/mkdir", body: { path: path })
59
+ nil
60
+ end
61
+
62
+ # Delete a file or directory.
63
+ #
64
+ # @param container_id [String] Container ID
65
+ # @param path [String] Path to delete
66
+ def delete(container_id, path)
67
+ encoded_path = URI.encode_www_form_component(path)
68
+ @client.request(:delete, "/api/containers/#{container_id}/files?path=#{encoded_path}")
69
+ nil
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rexec
6
+ # WebSocket terminal connection.
7
+ class Terminal
8
+ attr_reader :closed
9
+
10
+ def initialize(ws)
11
+ @ws = ws
12
+ @closed = false
13
+ @data_handlers = []
14
+ @close_handlers = []
15
+ @error_handlers = []
16
+
17
+ setup_handlers
18
+ end
19
+
20
+ # Send data to the terminal.
21
+ #
22
+ # @param data [String] Data to send
23
+ def write(data)
24
+ raise TerminalClosedError if @closed
25
+
26
+ @ws.send(data)
27
+ end
28
+
29
+ # Resize the terminal.
30
+ #
31
+ # @param cols [Integer] Number of columns
32
+ # @param rows [Integer] Number of rows
33
+ def resize(cols, rows)
34
+ raise TerminalClosedError if @closed
35
+
36
+ msg = { type: "resize", cols: cols, rows: rows }.to_json
37
+ @ws.send(msg)
38
+ end
39
+
40
+ # Register a handler for incoming data.
41
+ #
42
+ # @yield [data] Block called with each chunk of data
43
+ def on_data(&block)
44
+ @data_handlers << block
45
+ end
46
+
47
+ # Register a handler for connection close.
48
+ #
49
+ # @yield Block called when connection closes
50
+ def on_close(&block)
51
+ @close_handlers << block
52
+ end
53
+
54
+ # Register a handler for errors.
55
+ #
56
+ # @yield [error] Block called with error
57
+ def on_error(&block)
58
+ @error_handlers << block
59
+ end
60
+
61
+ # Close the terminal connection.
62
+ def close
63
+ return if @closed
64
+
65
+ @closed = true
66
+ @ws.close
67
+ end
68
+
69
+ alias closed? closed
70
+
71
+ private
72
+
73
+ def setup_handlers
74
+ @ws.on :message do |msg|
75
+ @data_handlers.each { |h| h.call(msg.data) }
76
+ end
77
+
78
+ @ws.on :close do
79
+ @closed = true
80
+ @close_handlers.each(&:call)
81
+ end
82
+
83
+ @ws.on :error do |e|
84
+ @error_handlers.each { |h| h.call(e) }
85
+ end
86
+ end
87
+ end
88
+
89
+ # Service for terminal WebSocket connections.
90
+ class TerminalService
91
+ def initialize(client)
92
+ @client = client
93
+ end
94
+
95
+ # Connect to a container's terminal.
96
+ #
97
+ # @param container_id [String] Container ID
98
+ # @param cols [Integer] Terminal width (default: 80)
99
+ # @param rows [Integer] Terminal height (default: 24)
100
+ # @return [Terminal]
101
+ #
102
+ # @example
103
+ # terminal = client.terminal.connect(container.id)
104
+ # terminal.on_data { |data| print data }
105
+ # terminal.write("ls -la\n")
106
+ def connect(container_id, cols: 80, rows: 24)
107
+ begin
108
+ require "websocket-client-simple"
109
+ rescue LoadError
110
+ raise LoadError, "websocket-client-simple is required for terminal connections (gem install websocket-client-simple)"
111
+ end
112
+ ws_url = @client.ws_url("/ws/terminal/#{container_id}")
113
+
114
+ ws = WebSocket::Client::Simple.connect(ws_url, headers: {
115
+ "Authorization" => "Bearer #{@client.token}"
116
+ })
117
+
118
+ terminal = Terminal.new(ws)
119
+
120
+ # Wait for connection
121
+ sleep 0.1 until ws.open?
122
+
123
+ # Set initial size
124
+ terminal.resize(cols, rows)
125
+
126
+ terminal
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rexec
4
+ VERSION = "1.1.0"
5
+ end
data/lib/rexec.rb CHANGED
@@ -7,19 +7,20 @@ require_relative "rexec/container"
7
7
  require_relative "rexec/file_service"
8
8
  require_relative "rexec/terminal"
9
9
 
10
- # Rexec Ruby SDK - Official SDK for Rexec Terminal as a Service.
10
+ # Rexec Ruby SDK official client for AI-native sandboxes.
11
11
  #
12
12
  # @example Basic usage
13
13
  # client = Rexec::Client.new("https://your-instance.com", "your-token")
14
- #
15
- # container = client.containers.create(image: "ubuntu:24.04")
16
- # puts "Created: #{container.id}"
17
- #
18
- # terminal = client.terminal.connect(container.id)
14
+ #
15
+ # sandbox = client.sandboxes.create(image: "ubuntu")
16
+ # puts "Created: #{sandbox.id}"
17
+ #
18
+ # terminal = client.terminal.connect(sandbox.id)
19
19
  # terminal.write("echo hello\n")
20
20
  # terminal.on_data { |data| puts data }
21
- #
22
- # client.containers.delete(container.id)
21
+ #
22
+ # client.sandboxes.delete(sandbox.id)
23
+ # # Legacy: client.containers is the same service
23
24
  #
24
25
  module Rexec
25
26
  class << self
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pipeops-rexec
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - PipeOpsHQ
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-01 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -105,6 +105,12 @@ files:
105
105
  - LICENSE
106
106
  - README.md
107
107
  - lib/rexec.rb
108
+ - lib/rexec/client.rb
109
+ - lib/rexec/container.rb
110
+ - lib/rexec/error.rb
111
+ - lib/rexec/file_service.rb
112
+ - lib/rexec/terminal.rb
113
+ - lib/rexec/version.rb
108
114
  homepage: https://github.com/PipeOpsHQ/rexec
109
115
  licenses:
110
116
  - MIT