keyenv 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b674e0b30a5f7d531fab1e1029ee40fc1706633030c44227e8fbcbd684334b39
4
+ data.tar.gz: 62d26d5f098f32e81341929bb86204bafb7543d7df22efc883f666b01b3ab182
5
+ SHA512:
6
+ metadata.gz: b7d130375c1a7b6d275c8dfa9d8cdc82a88e0f593db5ec6253e6d2a0e7040ddfb7a24d516881b795a6ce6accd58d046a43219d1b3680a30ed89d127b8e1015c3
7
+ data.tar.gz: 6cb4ab4ba2e5b1f2b962c899cbb868b738dab39c80c26ff2796a1527dd1fa59972536179ec1406d14cf50e6f38867645ef4c169fc5dba560413adb2184ec4d62
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0](https://github.com/keyenv/ruby-sdk/compare/v1.1.0...v1.2.0) (2026-01-26)
9
+
10
+
11
+ ### Features
12
+
13
+ * add integration tests for live API testing ([c4f47c2](https://github.com/keyenv/ruby-sdk/commit/c4f47c2aa94492975a9f50ebd78287f00c2069d7))
14
+ * add tag-based release workflow for RubyGems ([736a6aa](https://github.com/keyenv/ruby-sdk/commit/736a6aa9ce190b0494e1c39c78820feba0e813a6))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * correct error message formatting ([dbe213e](https://github.com/keyenv/ruby-sdk/commit/dbe213e2b59788a6189720e6dbb17bde03b13939))
20
+
21
+ ## [1.0.0] - 2025-01-23
22
+
23
+ ### Added
24
+
25
+ - Initial release
26
+ - `KeyEnv.new(token:)` and `KeyEnv.create(token)` client creation
27
+ - Authentication: `get_current_user`, `validate_token`
28
+ - Projects: `list_projects`, `get_project`, `create_project`, `delete_project`
29
+ - Environments: `list_environments`, `create_environment`, `delete_environment`
30
+ - Secrets: `list_secrets`, `export_secrets`, `export_secrets_as_hash`, `get_secret`
31
+ - Secret management: `create_secret`, `update_secret`, `set_secret`, `delete_secret`
32
+ - Bulk operations: `bulk_import`, `get_secret_history`
33
+ - Utilities: `load_env`, `generate_env_file`, `clear_cache`
34
+ - Permissions: `list_permissions`, `set_permission`, `delete_permission`, `bulk_set_permissions`
35
+ - Permission queries: `get_my_permissions`, `get_project_defaults`, `set_project_defaults`
36
+ - Built-in caching with configurable TTL for serverless environments
37
+ - Typed data classes for all API responses
38
+ - Specific error classes: `AuthenticationError`, `NotFoundError`, `ValidationError`, `RateLimitError`
39
+ - Full RSpec test suite with WebMock
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 KeyEnv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # KeyEnv Ruby SDK
2
+
3
+ Official Ruby SDK for [KeyEnv](https://keyenv.dev) - Secure secrets management for development teams.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'keyenv'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install it yourself:
20
+
21
+ ```bash
22
+ gem install keyenv
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```ruby
28
+ require 'keyenv'
29
+
30
+ client = KeyEnv.new(token: ENV['KEYENV_TOKEN'])
31
+
32
+ # Load secrets into ENV
33
+ client.load_env(project_id: 'your-project-id', environment: 'production')
34
+ puts ENV['DATABASE_URL']
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Initialize the Client
40
+
41
+ ```ruby
42
+ require 'keyenv'
43
+
44
+ # Using keyword argument
45
+ client = KeyEnv.new(token: 'your-service-token')
46
+
47
+ # Alternative syntax
48
+ client = KeyEnv.create('your-service-token')
49
+
50
+ # With custom timeout and caching
51
+ client = KeyEnv.new(token: 'your-token', timeout: 60, cache_ttl: 300)
52
+ ```
53
+
54
+ ### Export Secrets
55
+
56
+ ```ruby
57
+ # Get all secrets as a list
58
+ secrets = client.export_secrets(project_id: 'proj_123', environment: 'production')
59
+ secrets.each do |secret|
60
+ puts "#{secret.key}=#{secret.value}"
61
+ end
62
+
63
+ # Get secrets as a hash
64
+ env = client.export_secrets_as_hash(project_id: 'proj_123', environment: 'production')
65
+ puts env['DATABASE_URL']
66
+
67
+ # Load directly into ENV
68
+ count = client.load_env(project_id: 'proj_123', environment: 'production')
69
+ puts "Loaded #{count} secrets"
70
+ ```
71
+
72
+ ### Manage Secrets
73
+
74
+ ```ruby
75
+ # Get a single secret
76
+ secret = client.get_secret(project_id: 'proj_123', environment: 'production', key: 'DATABASE_URL')
77
+ puts secret.value
78
+
79
+ # Set a secret (creates or updates)
80
+ client.set_secret(
81
+ project_id: 'proj_123',
82
+ environment: 'production',
83
+ key: 'API_KEY',
84
+ value: 'sk_live_...'
85
+ )
86
+
87
+ # Delete a secret
88
+ client.delete_secret(project_id: 'proj_123', environment: 'production', key: 'OLD_KEY')
89
+ ```
90
+
91
+ ### Bulk Import
92
+
93
+ ```ruby
94
+ result = client.bulk_import(
95
+ project_id: 'proj_123',
96
+ environment: 'development',
97
+ secrets: [
98
+ KeyEnv::BulkSecretItem.new(key: 'DATABASE_URL', value: 'postgres://localhost/mydb'),
99
+ KeyEnv::BulkSecretItem.new(key: 'REDIS_URL', value: 'redis://localhost:6379'),
100
+ { 'key' => 'API_KEY', 'value' => 'sk_test_...' } # Also accepts hashes
101
+ ],
102
+ overwrite: true
103
+ )
104
+ puts "Created: #{result.created}, Updated: #{result.updated}"
105
+ ```
106
+
107
+ ### Generate .env File
108
+
109
+ ```ruby
110
+ env_content = client.generate_env_file(project_id: 'proj_123', environment: 'production')
111
+ File.write('.env', env_content)
112
+ ```
113
+
114
+ ### List Projects and Environments
115
+
116
+ ```ruby
117
+ # List all projects
118
+ projects = client.list_projects
119
+ projects.each do |project|
120
+ puts "#{project.name} (#{project.id})"
121
+ end
122
+
123
+ # Get project with environments
124
+ project = client.get_project(project_id: 'proj_123')
125
+ project.environments.each do |env|
126
+ puts " - #{env.name}"
127
+ end
128
+ ```
129
+
130
+ ### Service Token Info
131
+
132
+ ```ruby
133
+ # Get current user or service token info
134
+ user = client.get_current_user
135
+
136
+ if user.auth_type == 'service_token'
137
+ # Service tokens can access multiple projects
138
+ puts "Projects: #{user.project_ids}"
139
+ puts "Scopes: #{user.scopes}"
140
+ end
141
+ ```
142
+
143
+ ## Error Handling
144
+
145
+ ```ruby
146
+ require 'keyenv'
147
+
148
+ begin
149
+ secret = client.get_secret(project_id: 'proj_123', environment: 'production', key: 'MISSING_KEY')
150
+ rescue KeyEnv::NotFoundError => e
151
+ puts "Secret not found: #{e.message}"
152
+ rescue KeyEnv::AuthenticationError => e
153
+ puts "Authentication failed: #{e.message}"
154
+ rescue KeyEnv::Error => e
155
+ puts "Error #{e.status}: #{e.message}"
156
+ end
157
+ ```
158
+
159
+ ### Error Types
160
+
161
+ | Error | Description |
162
+ |-------|-------------|
163
+ | `KeyEnv::Error` | Base error class |
164
+ | `KeyEnv::AuthenticationError` | Authentication failed (401) |
165
+ | `KeyEnv::NotFoundError` | Resource not found (404) |
166
+ | `KeyEnv::ValidationError` | Invalid request (422) |
167
+ | `KeyEnv::RateLimitError` | Rate limit exceeded (429) |
168
+ | `KeyEnv::ConnectionError` | Network/connection error |
169
+ | `KeyEnv::TimeoutError` | Request timeout |
170
+
171
+ ## Caching
172
+
173
+ For serverless environments or high-traffic applications, enable caching to reduce API calls:
174
+
175
+ ```ruby
176
+ # Cache secrets for 5 minutes
177
+ client = KeyEnv.new(token: 'your-token', cache_ttl: 300)
178
+
179
+ # Or use environment variable
180
+ ENV['KEYENV_CACHE_TTL'] = '300'
181
+ client = KeyEnv.new(token: 'your-token')
182
+
183
+ # Manually clear cache
184
+ client.clear_cache # Clear all
185
+ client.clear_cache(project_id: 'proj_123') # Clear project
186
+ client.clear_cache(project_id: 'proj_123', environment: 'production') # Clear specific
187
+ ```
188
+
189
+ ## API Reference
190
+
191
+ ### `KeyEnv.new(token:, timeout:, cache_ttl:)`
192
+
193
+ Create a new KeyEnv client.
194
+
195
+ | Parameter | Type | Required | Default | Description |
196
+ |-----------|------|----------|---------|-------------|
197
+ | `token` | `String` | Yes | - | Service token |
198
+ | `timeout` | `Integer` | No | `30` | Request timeout (seconds) |
199
+ | `cache_ttl` | `Integer` | No | `0` | Cache TTL (seconds, 0 = disabled) |
200
+
201
+ ### Methods
202
+
203
+ | Method | Description |
204
+ |--------|-------------|
205
+ | `get_current_user` | Get current user/token info |
206
+ | `list_projects` | List all accessible projects |
207
+ | `get_project(project_id:)` | Get project with environments |
208
+ | `list_environments(project_id:)` | List environments in a project |
209
+ | `list_secrets(project_id:, environment:)` | List secret keys (no values) |
210
+ | `export_secrets(project_id:, environment:)` | Export secrets with values |
211
+ | `export_secrets_as_hash(project_id:, environment:)` | Export as hash |
212
+ | `get_secret(project_id:, environment:, key:)` | Get single secret |
213
+ | `set_secret(project_id:, environment:, key:, value:)` | Create or update secret |
214
+ | `delete_secret(project_id:, environment:, key:)` | Delete secret |
215
+ | `bulk_import(project_id:, environment:, secrets:)` | Bulk import secrets |
216
+ | `load_env(project_id:, environment:)` | Load secrets into ENV |
217
+ | `generate_env_file(project_id:, environment:)` | Generate .env file content |
218
+ | `list_permissions(project_id:, environment:)` | List permissions |
219
+ | `set_permission(project_id:, environment:, user_id:, role:)` | Set permission |
220
+ | `delete_permission(project_id:, environment:, user_id:)` | Delete permission |
221
+ | `get_my_permissions(project_id:)` | Get current user's permissions |
222
+
223
+ ## Requirements
224
+
225
+ - Ruby 3.0+
226
+
227
+ ## Development
228
+
229
+ ```bash
230
+ # Install dependencies
231
+ bundle install
232
+
233
+ # Run tests
234
+ bundle exec rspec
235
+
236
+ # Run linter
237
+ bundle exec rubocop
238
+
239
+ # Generate documentation
240
+ bundle exec rake yard
241
+ ```
242
+
243
+ ## License
244
+
245
+ MIT
@@ -0,0 +1,492 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "time"
7
+
8
+ module KeyEnv
9
+ # KeyEnv API client for managing secrets.
10
+ #
11
+ # @example Basic usage
12
+ # client = KeyEnv::Client.new(token: "your-service-token")
13
+ # secrets = client.export_secrets(project_id: "proj_123", environment: "production")
14
+ #
15
+ # @example With caching for serverless environments
16
+ # client = KeyEnv::Client.new(token: "your-token", cache_ttl: 300) # 5 minutes
17
+ #
18
+ class Client
19
+ BASE_URL = "https://api.keyenv.dev"
20
+ DEFAULT_TIMEOUT = 30
21
+
22
+ # @param token [String] Service token for authentication
23
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
24
+ # @param cache_ttl [Integer] Cache TTL in seconds (default: 0 = disabled)
25
+ # @param base_url [String, nil] Custom API base URL (default: https://api.keyenv.dev)
26
+ def initialize(token:, timeout: DEFAULT_TIMEOUT, cache_ttl: 0, base_url: nil)
27
+ raise ArgumentError, "KeyEnv token is required" if token.nil? || token.empty?
28
+
29
+ @token = token
30
+ @timeout = timeout
31
+ @cache_ttl = cache_ttl.positive? ? cache_ttl : ENV.fetch("KEYENV_CACHE_TTL", "0").to_i
32
+ @base_url = base_url || ENV.fetch("KEYENV_API_URL", BASE_URL)
33
+ @base_uri = URI.parse(@base_url)
34
+ @secrets_cache = {}
35
+ end
36
+
37
+ # =========================================================================
38
+ # Authentication
39
+ # =========================================================================
40
+
41
+ # Get the current user or service token info.
42
+ #
43
+ # @return [User] Current user/token info
44
+ def get_current_user
45
+ data = request(:get, "/api/v1/users/me")
46
+ User.new(data)
47
+ end
48
+
49
+ # Validate the token and return user info.
50
+ #
51
+ # @return [User] Current user/token info
52
+ def validate_token
53
+ get_current_user
54
+ end
55
+
56
+ # =========================================================================
57
+ # Projects
58
+ # =========================================================================
59
+
60
+ # List all accessible projects.
61
+ #
62
+ # @return [Array<Project>] List of projects
63
+ def list_projects
64
+ data = request(:get, "/api/v1/projects")
65
+ (data["projects"] || []).map { |p| Project.new(p) }
66
+ end
67
+
68
+ # Get a project by ID.
69
+ #
70
+ # @param project_id [String] Project ID
71
+ # @return [ProjectWithEnvironments] Project with environments
72
+ def get_project(project_id:)
73
+ data = request(:get, "/api/v1/projects/#{project_id}")
74
+ ProjectWithEnvironments.new(data)
75
+ end
76
+
77
+ # Create a new project.
78
+ #
79
+ # @param team_id [String] Team ID
80
+ # @param name [String] Project name
81
+ # @return [Project] Created project
82
+ def create_project(team_id:, name:)
83
+ data = request(:post, "/api/v1/projects", { team_id: team_id, name: name })
84
+ Project.new(data)
85
+ end
86
+
87
+ # Delete a project.
88
+ #
89
+ # @param project_id [String] Project ID
90
+ def delete_project(project_id:)
91
+ request(:delete, "/api/v1/projects/#{project_id}")
92
+ nil
93
+ end
94
+
95
+ # =========================================================================
96
+ # Environments
97
+ # =========================================================================
98
+
99
+ # List environments in a project.
100
+ #
101
+ # @param project_id [String] Project ID
102
+ # @return [Array<Environment>] List of environments
103
+ def list_environments(project_id:)
104
+ data = request(:get, "/api/v1/projects/#{project_id}/environments")
105
+ (data["environments"] || []).map { |e| Environment.new(e) }
106
+ end
107
+
108
+ # Create a new environment.
109
+ #
110
+ # @param project_id [String] Project ID
111
+ # @param name [String] Environment name
112
+ # @param inherits_from [String, nil] Parent environment to inherit from
113
+ # @return [Environment] Created environment
114
+ def create_environment(project_id:, name:, inherits_from: nil)
115
+ payload = { name: name }
116
+ payload[:inherits_from] = inherits_from if inherits_from
117
+ data = request(:post, "/api/v1/projects/#{project_id}/environments", payload)
118
+ Environment.new(data)
119
+ end
120
+
121
+ # Delete an environment.
122
+ #
123
+ # @param project_id [String] Project ID
124
+ # @param environment [String] Environment name
125
+ def delete_environment(project_id:, environment:)
126
+ request(:delete, "/api/v1/projects/#{project_id}/environments/#{environment}")
127
+ nil
128
+ end
129
+
130
+ # =========================================================================
131
+ # Secrets
132
+ # =========================================================================
133
+
134
+ # List secrets in an environment (keys and metadata only).
135
+ #
136
+ # @param project_id [String] Project ID
137
+ # @param environment [String] Environment name
138
+ # @return [Array<Secret>] List of secrets
139
+ def list_secrets(project_id:, environment:)
140
+ data = request(:get, "/api/v1/projects/#{project_id}/environments/#{environment}/secrets")
141
+ (data["secrets"] || []).map { |s| Secret.new(s) }
142
+ end
143
+
144
+ # Export all secrets with their decrypted values.
145
+ # Results are cached when cache_ttl > 0.
146
+ #
147
+ # @param project_id [String] Project ID
148
+ # @param environment [String] Environment name
149
+ # @return [Array<SecretWithValue>] List of secrets with values
150
+ def export_secrets(project_id:, environment:)
151
+ cache_key = "#{project_id}:#{environment}"
152
+
153
+ # Check cache if TTL > 0
154
+ if @cache_ttl.positive?
155
+ cached = @secrets_cache[cache_key]
156
+ if cached
157
+ secrets, expires_at = cached
158
+ return secrets if Time.now.to_f < expires_at
159
+ end
160
+ end
161
+
162
+ data = request(:get, "/api/v1/projects/#{project_id}/environments/#{environment}/secrets/export")
163
+ secrets = (data["secrets"] || []).map { |s| SecretWithValue.new(s) }
164
+
165
+ # Store in cache if TTL > 0
166
+ if @cache_ttl.positive?
167
+ @secrets_cache[cache_key] = [secrets, Time.now.to_f + @cache_ttl]
168
+ end
169
+
170
+ secrets
171
+ end
172
+
173
+ # Export secrets as a key-value hash.
174
+ #
175
+ # @param project_id [String] Project ID
176
+ # @param environment [String] Environment name
177
+ # @return [Hash<String, String>] Secrets as key-value pairs
178
+ def export_secrets_as_hash(project_id:, environment:)
179
+ secrets = export_secrets(project_id: project_id, environment: environment)
180
+ secrets.each_with_object({}) { |s, h| h[s.key] = s.value }
181
+ end
182
+
183
+ # Get a single secret with its value.
184
+ #
185
+ # @param project_id [String] Project ID
186
+ # @param environment [String] Environment name
187
+ # @param key [String] Secret key
188
+ # @return [SecretWithValue] Secret with value
189
+ def get_secret(project_id:, environment:, key:)
190
+ data = request(:get, "/api/v1/projects/#{project_id}/environments/#{environment}/secrets/#{key}")
191
+ SecretWithValue.new(data["secret"] || data)
192
+ end
193
+
194
+ # Create a new secret.
195
+ #
196
+ # @param project_id [String] Project ID
197
+ # @param environment [String] Environment name
198
+ # @param key [String] Secret key
199
+ # @param value [String] Secret value
200
+ # @param description [String, nil] Optional description
201
+ # @return [Secret] Created secret
202
+ def create_secret(project_id:, environment:, key:, value:, description: nil)
203
+ payload = { key: key, value: value }
204
+ payload[:description] = description if description
205
+ data = request(:post, "/api/v1/projects/#{project_id}/environments/#{environment}/secrets", payload)
206
+ clear_cache(project_id: project_id, environment: environment)
207
+ Secret.new(data["secret"] || data)
208
+ end
209
+
210
+ # Update a secret's value.
211
+ #
212
+ # @param project_id [String] Project ID
213
+ # @param environment [String] Environment name
214
+ # @param key [String] Secret key
215
+ # @param value [String] New secret value
216
+ # @param description [String, nil] Optional new description
217
+ # @return [Secret] Updated secret
218
+ def update_secret(project_id:, environment:, key:, value:, description: nil)
219
+ payload = { value: value }
220
+ payload[:description] = description unless description.nil?
221
+ data = request(:put, "/api/v1/projects/#{project_id}/environments/#{environment}/secrets/#{key}", payload)
222
+ clear_cache(project_id: project_id, environment: environment)
223
+ Secret.new(data["secret"] || data)
224
+ end
225
+
226
+ # Set a secret (create or update).
227
+ #
228
+ # @param project_id [String] Project ID
229
+ # @param environment [String] Environment name
230
+ # @param key [String] Secret key
231
+ # @param value [String] Secret value
232
+ # @param description [String, nil] Optional description
233
+ # @return [Secret] Created or updated secret
234
+ def set_secret(project_id:, environment:, key:, value:, description: nil)
235
+ update_secret(project_id: project_id, environment: environment, key: key, value: value, description: description)
236
+ rescue NotFoundError
237
+ create_secret(project_id: project_id, environment: environment, key: key, value: value, description: description)
238
+ end
239
+
240
+ # Delete a secret.
241
+ #
242
+ # @param project_id [String] Project ID
243
+ # @param environment [String] Environment name
244
+ # @param key [String] Secret key
245
+ def delete_secret(project_id:, environment:, key:)
246
+ request(:delete, "/api/v1/projects/#{project_id}/environments/#{environment}/secrets/#{key}")
247
+ clear_cache(project_id: project_id, environment: environment)
248
+ nil
249
+ end
250
+
251
+ # Get secret version history.
252
+ #
253
+ # @param project_id [String] Project ID
254
+ # @param environment [String] Environment name
255
+ # @param key [String] Secret key
256
+ # @return [Array<SecretHistory>] Secret version history
257
+ def get_secret_history(project_id:, environment:, key:)
258
+ data = request(:get, "/api/v1/projects/#{project_id}/environments/#{environment}/secrets/#{key}/history")
259
+ (data["history"] || []).map { |h| SecretHistory.new(h) }
260
+ end
261
+
262
+ # Bulk import secrets.
263
+ #
264
+ # @param project_id [String] Project ID
265
+ # @param environment [String] Environment name
266
+ # @param secrets [Array<BulkSecretItem, Hash>] List of secrets to import
267
+ # @param overwrite [Boolean] Whether to overwrite existing secrets
268
+ # @return [BulkImportResult] Import result
269
+ def bulk_import(project_id:, environment:, secrets:, overwrite: false)
270
+ secret_list = secrets.map { |s| s.is_a?(BulkSecretItem) ? s.to_h : s }
271
+ data = request(
272
+ :post,
273
+ "/api/v1/projects/#{project_id}/environments/#{environment}/secrets/bulk",
274
+ { secrets: secret_list, overwrite: overwrite }
275
+ )
276
+ clear_cache(project_id: project_id, environment: environment)
277
+ BulkImportResult.new(data)
278
+ end
279
+
280
+ # =========================================================================
281
+ # Utilities
282
+ # =========================================================================
283
+
284
+ # Load secrets into ENV.
285
+ #
286
+ # @param project_id [String] Project ID
287
+ # @param environment [String] Environment name
288
+ # @return [Integer] Number of secrets loaded
289
+ def load_env(project_id:, environment:)
290
+ secrets = export_secrets(project_id: project_id, environment: environment)
291
+ secrets.each { |s| ENV[s.key] = s.value }
292
+ secrets.size
293
+ end
294
+
295
+ # Generate .env file content from secrets.
296
+ #
297
+ # @param project_id [String] Project ID
298
+ # @param environment [String] Environment name
299
+ # @return [String] .env file content
300
+ def generate_env_file(project_id:, environment:)
301
+ secrets = export_secrets(project_id: project_id, environment: environment)
302
+ lines = [
303
+ "# Generated by KeyEnv",
304
+ "# Environment: #{environment}",
305
+ "# Generated at: #{Time.now.utc.iso8601}",
306
+ ""
307
+ ]
308
+
309
+ secrets.each do |secret|
310
+ value = secret.value
311
+ if value.include?("\n") || value.include?('"') || value.include?("'") || value.include?(" ") || value.include?("$")
312
+ escaped = value.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n").gsub("$", "\\$")
313
+ lines << %(#{secret.key}="#{escaped}")
314
+ else
315
+ lines << "#{secret.key}=#{value}"
316
+ end
317
+ end
318
+
319
+ lines.join("\n") + "\n"
320
+ end
321
+
322
+ # Clear the secrets cache.
323
+ #
324
+ # @param project_id [String, nil] Clear cache for specific project (optional)
325
+ # @param environment [String, nil] Clear cache for specific environment (requires project_id)
326
+ def clear_cache(project_id: nil, environment: nil)
327
+ if project_id && environment
328
+ cache_key = "#{project_id}:#{environment}"
329
+ @secrets_cache.delete(cache_key)
330
+ elsif project_id
331
+ @secrets_cache.delete_if { |k, _| k.start_with?("#{project_id}:") }
332
+ else
333
+ @secrets_cache.clear
334
+ end
335
+ end
336
+
337
+ # =========================================================================
338
+ # Environment Permissions
339
+ # =========================================================================
340
+
341
+ # List permissions for an environment.
342
+ #
343
+ # @param project_id [String] Project ID
344
+ # @param environment [String] Environment name
345
+ # @return [Array<EnvironmentPermission>] List of permissions
346
+ def list_permissions(project_id:, environment:)
347
+ data = request(:get, "/api/v1/projects/#{project_id}/environments/#{environment}/permissions")
348
+ (data["permissions"] || []).map { |p| EnvironmentPermission.new(p) }
349
+ end
350
+
351
+ # Set a user's permission for an environment.
352
+ #
353
+ # @param project_id [String] Project ID
354
+ # @param environment [String] Environment name
355
+ # @param user_id [String] User ID
356
+ # @param role [String] Role ("none", "read", "write", or "admin")
357
+ # @return [EnvironmentPermission] Created or updated permission
358
+ def set_permission(project_id:, environment:, user_id:, role:)
359
+ data = request(
360
+ :put,
361
+ "/api/v1/projects/#{project_id}/environments/#{environment}/permissions/#{user_id}",
362
+ { role: role }
363
+ )
364
+ EnvironmentPermission.new(data)
365
+ end
366
+
367
+ # Delete a user's permission for an environment.
368
+ #
369
+ # @param project_id [String] Project ID
370
+ # @param environment [String] Environment name
371
+ # @param user_id [String] User ID
372
+ def delete_permission(project_id:, environment:, user_id:)
373
+ request(:delete, "/api/v1/projects/#{project_id}/environments/#{environment}/permissions/#{user_id}")
374
+ nil
375
+ end
376
+
377
+ # Bulk set permissions for an environment.
378
+ #
379
+ # @param project_id [String] Project ID
380
+ # @param environment [String] Environment name
381
+ # @param permissions [Array<Hash>] List of permission hashes with "user_id" and "role" keys
382
+ # @return [Array<EnvironmentPermission>] Created or updated permissions
383
+ def bulk_set_permissions(project_id:, environment:, permissions:)
384
+ data = request(
385
+ :put,
386
+ "/api/v1/projects/#{project_id}/environments/#{environment}/permissions",
387
+ { permissions: permissions }
388
+ )
389
+ (data["permissions"] || []).map { |p| EnvironmentPermission.new(p) }
390
+ end
391
+
392
+ # Get current user's permissions for all environments in a project.
393
+ #
394
+ # @param project_id [String] Project ID
395
+ # @return [Array<(Array<MyPermission>, Boolean)>] Tuple of (permissions, is_team_admin)
396
+ def get_my_permissions(project_id:)
397
+ data = request(:get, "/api/v1/projects/#{project_id}/my-permissions")
398
+ permissions = (data["permissions"] || []).map { |p| MyPermission.new(p) }
399
+ is_team_admin = data["is_team_admin"] || false
400
+ [permissions, is_team_admin]
401
+ end
402
+
403
+ # Get default permissions for a project.
404
+ #
405
+ # @param project_id [String] Project ID
406
+ # @return [Array<ProjectDefault>] List of project defaults
407
+ def get_project_defaults(project_id:)
408
+ data = request(:get, "/api/v1/projects/#{project_id}/permissions/defaults")
409
+ (data["defaults"] || []).map { |d| ProjectDefault.new(d) }
410
+ end
411
+
412
+ # Set default permissions for a project.
413
+ #
414
+ # @param project_id [String] Project ID
415
+ # @param defaults [Array<Hash>] List of default hashes with "environment_name" and "default_role" keys
416
+ # @return [Array<ProjectDefault>] Updated project defaults
417
+ def set_project_defaults(project_id:, defaults:)
418
+ data = request(
419
+ :put,
420
+ "/api/v1/projects/#{project_id}/permissions/defaults",
421
+ { defaults: defaults }
422
+ )
423
+ (data["defaults"] || []).map { |d| ProjectDefault.new(d) }
424
+ end
425
+
426
+ private
427
+
428
+ def request(method, path, body = nil)
429
+ uri = URI.join(@base_url, path)
430
+
431
+ http = Net::HTTP.new(uri.host, uri.port)
432
+ http.use_ssl = uri.scheme == "https"
433
+ http.open_timeout = @timeout
434
+ http.read_timeout = @timeout
435
+
436
+ request = build_request(method, uri, body)
437
+ response = http.request(request)
438
+
439
+ handle_response(response)
440
+ rescue Net::OpenTimeout, Net::ReadTimeout
441
+ raise TimeoutError.new("Request timeout", status: 408)
442
+ rescue SocketError, Errno::ECONNREFUSED => e
443
+ raise ConnectionError.new(e.message, status: 0)
444
+ end
445
+
446
+ def build_request(method, uri, body)
447
+ request_class = case method
448
+ when :get then Net::HTTP::Get
449
+ when :post then Net::HTTP::Post
450
+ when :put then Net::HTTP::Put
451
+ when :delete then Net::HTTP::Delete
452
+ else raise ArgumentError, "Unknown HTTP method: #{method}"
453
+ end
454
+
455
+ request = request_class.new(uri)
456
+ request["Authorization"] = "Bearer #{@token}"
457
+ request["Content-Type"] = "application/json"
458
+ request["User-Agent"] = "keyenv-ruby/#{VERSION}"
459
+ request.body = JSON.generate(body) if body
460
+
461
+ request
462
+ end
463
+
464
+ def handle_response(response)
465
+ return nil if response.code == "204"
466
+
467
+ body = response.body
468
+ data = body && !body.empty? ? JSON.parse(body) : {}
469
+
470
+ unless response.is_a?(Net::HTTPSuccess)
471
+ status = response.code.to_i
472
+ message = data["error"] || "Unknown error"
473
+ code = data["code"]
474
+ details = data["details"]
475
+
476
+ error_class = case status
477
+ when 401 then AuthenticationError
478
+ when 404 then NotFoundError
479
+ when 422 then ValidationError
480
+ when 429 then RateLimitError
481
+ else Error
482
+ end
483
+
484
+ raise error_class.new(message, status: status, code: code, details: details)
485
+ end
486
+
487
+ data
488
+ rescue JSON::ParserError
489
+ raise Error.new(response.body || "Unknown error", status: response.code.to_i)
490
+ end
491
+ end
492
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeyEnv
4
+ # Base error class for KeyEnv API errors.
5
+ class Error < StandardError
6
+ # @return [Integer] HTTP status code
7
+ attr_reader :status
8
+
9
+ # @return [String, nil] Error code from API
10
+ attr_reader :code
11
+
12
+ # @return [Hash, nil] Additional error details
13
+ attr_reader :details
14
+
15
+ # @return [String] Original error message
16
+ attr_reader :original_message
17
+
18
+ # @param message [String] Error message
19
+ # @param status [Integer] HTTP status code (default: 0)
20
+ # @param code [String, nil] Error code from API
21
+ # @param details [Hash, nil] Additional error details
22
+ def initialize(message, status: 0, code: nil, details: nil)
23
+ @original_message = message
24
+ @status = status
25
+ @code = code
26
+ @details = details || {}
27
+ super(message)
28
+ end
29
+
30
+ def to_s
31
+ if status.positive?
32
+ "KeyEnvError(#{status}): #{@original_message}"
33
+ else
34
+ "KeyEnvError: #{@original_message}"
35
+ end
36
+ end
37
+ end
38
+
39
+ # Raised when authentication fails.
40
+ class AuthenticationError < Error; end
41
+
42
+ # Raised when a resource is not found.
43
+ class NotFoundError < Error; end
44
+
45
+ # Raised when the request is invalid.
46
+ class ValidationError < Error; end
47
+
48
+ # Raised when rate limited.
49
+ class RateLimitError < Error; end
50
+
51
+ # Raised on network/connection errors.
52
+ class ConnectionError < Error; end
53
+
54
+ # Raised on request timeout.
55
+ class TimeoutError < Error; end
56
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeyEnv
4
+ # User or service token info.
5
+ class User
6
+ attr_reader :id, :email, :name, :clerk_id, :avatar_url, :auth_type,
7
+ :team_id, :project_ids, :scopes, :created_at
8
+
9
+ def initialize(data)
10
+ @id = data["id"]
11
+ @email = data["email"]
12
+ @name = data["name"]
13
+ @clerk_id = data["clerk_id"]
14
+ @avatar_url = data["avatar_url"]
15
+ @auth_type = data["auth_type"]
16
+ @team_id = data["team_id"]
17
+ @project_ids = data["project_ids"]
18
+ @scopes = data["scopes"]
19
+ @created_at = data["created_at"]
20
+ end
21
+ end
22
+
23
+ # Project.
24
+ class Project
25
+ attr_reader :id, :team_id, :name, :slug, :description, :created_at
26
+
27
+ def initialize(data)
28
+ @id = data["id"]
29
+ @team_id = data["team_id"]
30
+ @name = data["name"]
31
+ @slug = data["slug"]
32
+ @description = data["description"]
33
+ @created_at = data["created_at"]
34
+ end
35
+ end
36
+
37
+ # Environment.
38
+ class Environment
39
+ attr_reader :id, :project_id, :name, :inherits_from, :created_at
40
+
41
+ def initialize(data)
42
+ @id = data["id"]
43
+ @project_id = data["project_id"]
44
+ @name = data["name"]
45
+ @inherits_from = data["inherits_from"]
46
+ @created_at = data["created_at"]
47
+ end
48
+ end
49
+
50
+ # Project with environments.
51
+ class ProjectWithEnvironments < Project
52
+ attr_reader :environments
53
+
54
+ def initialize(data)
55
+ super(data)
56
+ envs = data["environments"] || []
57
+ @environments = envs.map { |e| Environment.new(e) }
58
+ end
59
+ end
60
+
61
+ # Secret (without value).
62
+ class Secret
63
+ attr_reader :id, :environment_id, :key, :type, :version,
64
+ :description, :created_at, :updated_at
65
+
66
+ def initialize(data)
67
+ @id = data["id"]
68
+ @environment_id = data["environment_id"]
69
+ @key = data["key"]
70
+ @type = data["type"] || "string"
71
+ @version = data["version"]
72
+ @description = data["description"]
73
+ @created_at = data["created_at"]
74
+ @updated_at = data["updated_at"]
75
+ end
76
+ end
77
+
78
+ # Secret with decrypted value.
79
+ class SecretWithValue < Secret
80
+ attr_reader :value, :inherited_from
81
+
82
+ def initialize(data)
83
+ super(data)
84
+ @value = data["value"] || ""
85
+ @inherited_from = data["inherited_from"]
86
+ end
87
+ end
88
+
89
+ # Secret history entry.
90
+ class SecretHistory
91
+ attr_reader :id, :secret_id, :value, :version, :changed_by, :changed_at
92
+
93
+ def initialize(data)
94
+ @id = data["id"]
95
+ @secret_id = data["secret_id"]
96
+ @value = data["value"]
97
+ @version = data["version"]
98
+ @changed_by = data["changed_by"]
99
+ @changed_at = data["changed_at"]
100
+ end
101
+ end
102
+
103
+ # Bulk import request item.
104
+ class BulkSecretItem
105
+ attr_reader :key, :value, :description
106
+
107
+ def initialize(key:, value:, description: nil)
108
+ @key = key
109
+ @value = value
110
+ @description = description
111
+ end
112
+
113
+ def to_h
114
+ h = { "key" => key, "value" => value }
115
+ h["description"] = description if description
116
+ h
117
+ end
118
+ end
119
+
120
+ # Bulk import result.
121
+ class BulkImportResult
122
+ attr_reader :created, :updated, :skipped
123
+
124
+ def initialize(data)
125
+ @created = data["created"] || 0
126
+ @updated = data["updated"] || 0
127
+ @skipped = data["skipped"] || 0
128
+ end
129
+ end
130
+
131
+ # Environment permission for a user.
132
+ class EnvironmentPermission
133
+ attr_reader :id, :environment_id, :user_id, :role, :user_email,
134
+ :user_name, :granted_by, :created_at, :updated_at
135
+
136
+ def initialize(data)
137
+ @id = data["id"]
138
+ @environment_id = data["environment_id"]
139
+ @user_id = data["user_id"]
140
+ @role = data["role"]
141
+ @user_email = data["user_email"]
142
+ @user_name = data["user_name"]
143
+ @granted_by = data["granted_by"]
144
+ @created_at = data["created_at"] || ""
145
+ @updated_at = data["updated_at"] || ""
146
+ end
147
+ end
148
+
149
+ # User's own permission for an environment.
150
+ class MyPermission
151
+ attr_reader :environment_id, :environment_name, :role,
152
+ :can_read, :can_write, :can_admin
153
+
154
+ def initialize(data)
155
+ @environment_id = data["environment_id"]
156
+ @environment_name = data["environment_name"]
157
+ @role = data["role"]
158
+ @can_read = data["can_read"]
159
+ @can_write = data["can_write"]
160
+ @can_admin = data["can_admin"]
161
+ end
162
+ end
163
+
164
+ # Default permission for an environment in a project.
165
+ class ProjectDefault
166
+ attr_reader :id, :project_id, :environment_name, :default_role, :created_at
167
+
168
+ def initialize(data)
169
+ @id = data["id"]
170
+ @project_id = data["project_id"]
171
+ @environment_name = data["environment_name"]
172
+ @default_role = data["default_role"]
173
+ @created_at = data["created_at"] || ""
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeyEnv
4
+ VERSION = "1.1.0"
5
+ end
data/lib/keyenv.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # KeyEnv Ruby SDK - Secure secrets management for development teams.
4
+
5
+ require_relative "keyenv/version"
6
+ require_relative "keyenv/error"
7
+ require_relative "keyenv/types"
8
+ require_relative "keyenv/client"
9
+
10
+ module KeyEnv
11
+ class << self
12
+ # Create a new KeyEnv client.
13
+ #
14
+ # @param token [String] Service token for authentication
15
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
16
+ # @param cache_ttl [Integer] Cache TTL in seconds (default: 0 = disabled)
17
+ # @param base_url [String, nil] Custom API base URL (default: https://api.keyenv.dev)
18
+ # @return [Client] KeyEnv client instance
19
+ #
20
+ # @example
21
+ # client = KeyEnv.new(token: "your-service-token")
22
+ # secrets = client.export_secrets(project_id: "proj_123", environment: "production")
23
+ #
24
+ def new(token:, timeout: 30, cache_ttl: 0, base_url: nil)
25
+ Client.new(token: token, timeout: timeout, cache_ttl: cache_ttl, base_url: base_url)
26
+ end
27
+
28
+ # Create a new KeyEnv client (alternative syntax).
29
+ #
30
+ # @param token [String] Service token for authentication
31
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
32
+ # @param cache_ttl [Integer] Cache TTL in seconds (default: 0 = disabled)
33
+ # @param base_url [String, nil] Custom API base URL (default: https://api.keyenv.dev)
34
+ # @return [Client] KeyEnv client instance
35
+ #
36
+ # @example
37
+ # client = KeyEnv.create("your-service-token")
38
+ # secret = client.get_secret(project_id: "proj_123", environment: "production", key: "API_KEY")
39
+ #
40
+ def create(token, timeout: 30, cache_ttl: 0, base_url: nil)
41
+ Client.new(token: token, timeout: timeout, cache_ttl: cache_ttl, base_url: base_url)
42
+ end
43
+ end
44
+ end
metadata ADDED
@@ -0,0 +1,155 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: keyenv
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - KeyEnv
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.19'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.19'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.57'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.57'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.25'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.25'
97
+ - !ruby/object:Gem::Dependency
98
+ name: yard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.9'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.9'
111
+ description: KeyEnv Ruby SDK provides a simple interface for managing secrets in your
112
+ Ruby applications. Fetch, create, and manage environment variables securely.
113
+ email:
114
+ - support@keyenv.dev
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - CHANGELOG.md
120
+ - LICENSE
121
+ - README.md
122
+ - lib/keyenv.rb
123
+ - lib/keyenv/client.rb
124
+ - lib/keyenv/error.rb
125
+ - lib/keyenv/types.rb
126
+ - lib/keyenv/version.rb
127
+ homepage: https://keyenv.dev
128
+ licenses:
129
+ - MIT
130
+ metadata:
131
+ homepage_uri: https://keyenv.dev
132
+ source_code_uri: https://github.com/keyenv/ruby-sdk
133
+ changelog_uri: https://github.com/keyenv/ruby-sdk/blob/main/CHANGELOG.md
134
+ documentation_uri: https://keyenv.dev/docs/sdks/ruby
135
+ rubygems_mfa_required: 'true'
136
+ post_install_message:
137
+ rdoc_options: []
138
+ require_paths:
139
+ - lib
140
+ required_ruby_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: 3.0.0
145
+ required_rubygems_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ requirements: []
151
+ rubygems_version: 3.0.3.1
152
+ signing_key:
153
+ specification_version: 4
154
+ summary: Official Ruby SDK for KeyEnv - Secure secrets management
155
+ test_files: []