envlet 0.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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +28 -0
  3. data/lib/envlet/version.rb +3 -0
  4. data/lib/envlet.rb +202 -0
  5. metadata +49 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7d95d6b63c04a40b1c02fbd3982eeda60431a707577a7d76c736afc64ef4135d
4
+ data.tar.gz: 1094f02bf8515fd5887c3f4d8c767a7c80afc77bf338b1622098a228dcf1d615
5
+ SHA512:
6
+ metadata.gz: aeed2b588d60817631047dac3c4299018fcca8eca07b4970daa6c8b68aa835b1202c3603193cd7587083774db9b2b3a272a2e6b292b5a200a39cd7a5dde37084
7
+ data.tar.gz: 7f3415e50ec21f23430a6ade7809af34df29b7d09bd4531dce38d32b4bba5ca829f4b1d2b7c2e08284a5dfd18b273174f46c2204755f72fd30286bd500493be6
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # envlet
2
+
3
+ Pull your Envlet environment at runtime. The host platform holds one secret,
4
+ an Envlet token, and your app loads everything else at boot.
5
+
6
+ ```ruby
7
+ require "envlet"
8
+
9
+ Envlet.inject
10
+ ```
11
+
12
+ `Envlet.inject` fetches every value the token's identity may read and sets
13
+ `ENV`. Values already set by the host are never overwritten. Pass
14
+ `override: true` to force Envlet's values.
15
+
16
+ ```ruby
17
+ values = Envlet.load
18
+ stripe_key = Envlet.get("STRIPE_KEY")
19
+ ```
20
+
21
+ `Envlet.load` returns a hash of names and values. `Envlet.get(name)` fetches
22
+ one value and raises `Envlet::NotFoundError` when the identity cannot read it.
23
+
24
+ Configuration comes from `ENVLET_TOKEN` or the `token:` option. Set
25
+ `ENVLET_API_URL` or pass `api_url:` to use another API endpoint. Requests retry
26
+ briefly on network errors and 5xx responses unless `retry: false` is set.
27
+ Authentication failures raise `Envlet::AuthError`; every Envlet error exposes
28
+ `code` and `status`. The SDK never returns stale or partial values.
@@ -0,0 +1,3 @@
1
+ module Envlet
2
+ VERSION = "0.1.0"
3
+ end
data/lib/envlet.rb ADDED
@@ -0,0 +1,202 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "socket"
5
+ require "timeout"
6
+ require "uri"
7
+ require_relative "envlet/version"
8
+
9
+ module Envlet
10
+ DEFAULT_API_URL = "https://api.envlet.dev"
11
+ RETRY_ATTEMPTS = 3
12
+ RETRY_DELAYS = [0.4, 1.6].freeze
13
+ OPEN_TIMEOUT = 2
14
+ READ_TIMEOUT = 2
15
+
16
+ class Error < StandardError
17
+ attr_reader :code, :status
18
+
19
+ def initialize(code, message, status = nil)
20
+ super(message)
21
+ @code = code
22
+ @status = status
23
+ end
24
+ end
25
+
26
+ class AuthError < Error
27
+ end
28
+
29
+ class NotFoundError < Error
30
+ end
31
+
32
+ class << self
33
+ def load(**options)
34
+ validate_options(options, :token, :api_url, :retry)
35
+ body = request(
36
+ "/v1/values",
37
+ token: options[:token],
38
+ api_url: options[:api_url],
39
+ retry_enabled: options.fetch(:retry, true)
40
+ )
41
+ values = body["values"] if body.is_a?(Hash)
42
+ unless values.is_a?(Hash) && values.all? { |name, value| name.is_a?(String) && value.is_a?(String) }
43
+ raise Error.new("invalid_response", "the values response was not in the expected shape")
44
+ end
45
+
46
+ values
47
+ end
48
+
49
+ def get(name, **options)
50
+ validate_options(options, :token, :api_url, :retry)
51
+ encoded_name = URI.encode_www_form_component(name.to_s).gsub("+", "%20")
52
+ body = request(
53
+ "/v1/values/#{encoded_name}",
54
+ token: options[:token],
55
+ api_url: options[:api_url],
56
+ retry_enabled: options.fetch(:retry, true)
57
+ )
58
+ value = body["value"] if body.is_a?(Hash)
59
+ unless value.is_a?(String)
60
+ raise Error.new("invalid_response", "the value response was not in the expected shape")
61
+ end
62
+
63
+ value
64
+ end
65
+
66
+ def inject(**options)
67
+ validate_options(options, :token, :api_url, :retry, :override)
68
+ values = load(
69
+ token: options[:token],
70
+ api_url: options[:api_url],
71
+ retry: options.fetch(:retry, true)
72
+ )
73
+ override = options.fetch(:override, false)
74
+ values.each do |name, value|
75
+ next if !override && ENV.key?(name)
76
+
77
+ ENV[name] = value
78
+ end
79
+ values
80
+ end
81
+
82
+ private
83
+
84
+ def validate_options(options, *allowed)
85
+ unknown = options.keys - allowed
86
+ return if unknown.empty?
87
+
88
+ raise ArgumentError, "unknown keyword: #{unknown.first}"
89
+ end
90
+
91
+ def request(path, token:, api_url:, retry_enabled:)
92
+ resolved_token = resolve_token(token)
93
+ base_url = resolve_api_url(api_url)
94
+ uri = build_uri(base_url, path)
95
+ attempts = retry_enabled == false ? 1 : RETRY_ATTEMPTS
96
+ last_error = nil
97
+
98
+ attempts.times do |attempt|
99
+ sleep(RETRY_DELAYS.fetch(attempt - 1, RETRY_DELAYS.last)) if attempt > 0
100
+
101
+ begin
102
+ response = perform_http_request(uri, resolved_token)
103
+ rescue Timeout::Error, EOFError, IOError, SocketError, SystemCallError,
104
+ OpenSSL::SSL::SSLError, Net::ProtocolError => error
105
+ last_error = Error.new(
106
+ "network_error",
107
+ "could not reach #{base_url}: #{error.message}"
108
+ )
109
+ next
110
+ end
111
+
112
+ return parse_success_response(response) if response.is_a?(Net::HTTPOK)
113
+
114
+ code, message = parse_error_response(response)
115
+ status = response.code.to_i
116
+
117
+ raise AuthError.new(code, message, status) if status == 401 || status == 403
118
+ raise NotFoundError.new(code, message, status) if status == 404
119
+
120
+ if status >= 500
121
+ last_error = Error.new(code, message, status)
122
+ next
123
+ end
124
+
125
+ raise Error.new(code, message, status)
126
+ end
127
+
128
+ raise(last_error || Error.new("request_failed", "request failed"))
129
+ end
130
+
131
+ def resolve_token(token)
132
+ token = ENV["ENVLET_TOKEN"] if token.nil?
133
+ unless token.nil? || token.is_a?(String)
134
+ raise Error.new("config_invalid", "the token must be a string")
135
+ end
136
+ if token.nil? || token.empty?
137
+ raise Error.new(
138
+ "config_missing",
139
+ "no token: pass token: or set ENVLET_TOKEN"
140
+ )
141
+ end
142
+
143
+ token
144
+ end
145
+
146
+ def resolve_api_url(api_url)
147
+ api_url = ENV["ENVLET_API_URL"] if api_url.nil?
148
+ api_url.nil? ? DEFAULT_API_URL : api_url
149
+ end
150
+
151
+ def build_uri(api_url, path)
152
+ unless api_url.is_a?(String) && !api_url.empty?
153
+ raise Error.new("config_invalid", "the API URL must be a non-empty string")
154
+ end
155
+
156
+ uri = URI.parse("#{api_url.sub(%r{/$}, "")}#{path}")
157
+ unless uri.is_a?(URI::HTTP) && uri.host
158
+ raise Error.new("config_invalid", "the API URL must use HTTP or HTTPS")
159
+ end
160
+
161
+ uri
162
+ rescue URI::InvalidURIError => error
163
+ raise Error.new("config_invalid", "the API URL is invalid: #{error.message}")
164
+ end
165
+
166
+ def perform_http_request(uri, token)
167
+ http = Net::HTTP.new(uri.host, uri.port)
168
+ http.use_ssl = uri.scheme == "https"
169
+ http.open_timeout = OPEN_TIMEOUT
170
+ http.read_timeout = READ_TIMEOUT
171
+ request = Net::HTTP::Get.new(uri.request_uri)
172
+ request["Authorization"] = "Bearer #{token}"
173
+ http.start { |connection| connection.request(request) }
174
+ end
175
+
176
+ def parse_success_response(response)
177
+ JSON.parse(response.body)
178
+ rescue JSON::ParserError
179
+ raise Error.new("invalid_response", "the response was not valid JSON", response.code.to_i)
180
+ end
181
+
182
+ def parse_error_response(response)
183
+ code = "request_failed"
184
+ message = "request failed with status #{response.code}"
185
+
186
+ begin
187
+ body = JSON.parse(response.body)
188
+ envelope = body["error"] if body.is_a?(Hash)
189
+ if envelope.is_a?(Hash)
190
+ code = envelope["code"] if envelope["code"].is_a?(String)
191
+ message = envelope["message"] if envelope["message"].is_a?(String)
192
+ end
193
+ rescue JSON::ParserError
194
+ end
195
+
196
+ [code, message]
197
+ end
198
+ end
199
+
200
+ private_constant :DEFAULT_API_URL, :RETRY_ATTEMPTS, :RETRY_DELAYS,
201
+ :OPEN_TIMEOUT, :READ_TIMEOUT
202
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: envlet
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SELCOR
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Load, get, or inject Envlet environment values with one token.
14
+ email:
15
+ - admin@selcor.ai
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/envlet.rb
22
+ - lib/envlet/version.rb
23
+ homepage: https://envlet.dev
24
+ licenses:
25
+ - MIT
26
+ metadata:
27
+ documentation_uri: https://docs.envlet.dev
28
+ homepage_uri: https://envlet.dev
29
+ rubygems_mfa_required: 'true'
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.6'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.5.22
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: Load Envlet environment values at runtime
49
+ test_files: []