calvery 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/LICENSE +21 -0
  3. data/README.md +82 -0
  4. data/lib/calvery.rb +196 -0
  5. metadata +50 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 60248493396e75a137456f80208ff08591e633fa186c9a5181ae1c6e637ba708
4
+ data.tar.gz: da185eba5dafb7927e0c8c0f1a3e9f2334e07befbbf6652dc64af479c144e78c
5
+ SHA512:
6
+ metadata.gz: 8c5a294c0996b79fbc4f30201cdc5d7fd2bb547aeac7c42caeafff1dbc977c127bcef89f62c0e4c405f43b558e30a4cf48a85c37938b1bca87a979ba36dfb039
7
+ data.tar.gz: 7b31062ec3b9d5657b548585b1ac8c0737eaeaaceee22b8c99859ab3f740f8f752e8e51812f7012b903be04edcb86e80b4fded15844385362319b3d545e66cf2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Renzy Armstrong / Calvery
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,82 @@
1
+ # Calvery SDK — Ruby
2
+
3
+ Official Ruby SDK for [Calvery Vault](https://calvery.xyz) secret manager. Ruby 3.0+. Zero runtime deps — stdlib only.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ gem install calvery
9
+ ```
10
+
11
+ Or in `Gemfile`:
12
+ ```ruby
13
+ gem "calvery", "~> 0.1"
14
+ ```
15
+
16
+ ## Quickstart
17
+
18
+ ```ruby
19
+ require "calvery"
20
+
21
+ client = Calvery::Client.new(ENV["CVSM_TOKEN"], "acme-corp")
22
+
23
+ # Single secret
24
+ db_url = client.get("DATABASE_URL")
25
+
26
+ # All secrets
27
+ all = client.get_all
28
+ puts all.keys
29
+
30
+ # Inject into ENV (skip ones already set)
31
+ injected = client.inject!(overwrite: false)
32
+ puts "Injected #{injected.size} vars"
33
+ ```
34
+
35
+ ## Config
36
+
37
+ ```ruby
38
+ client = Calvery::Client.new(
39
+ ENV["CVSM_TOKEN"],
40
+ "acme-corp",
41
+ base_url: "https://vault.your-company.internal",
42
+ environment: "staging",
43
+ cache_ttl: 60,
44
+ max_retries: 5,
45
+ timeout: 30,
46
+ )
47
+ ```
48
+
49
+ ## Error handling
50
+
51
+ ```ruby
52
+ begin
53
+ val = client.get("DATABASE_URL")
54
+ rescue Calvery::NotFound => e
55
+ warn "secret missing: #{e.message}"
56
+ rescue Calvery::AuthError => e
57
+ warn "token invalid: #{e.message}"
58
+ rescue Calvery::NetworkError => e
59
+ warn "network: #{e.message}"
60
+ rescue Calvery::ServerError => e
61
+ warn "HTTP #{e.status}: #{e.message}"
62
+ end
63
+ ```
64
+
65
+ ## Rails
66
+
67
+ `config/initializers/calvery.rb`:
68
+
69
+ ```ruby
70
+ Rails.application.config.calvery = Calvery::Client.new(
71
+ ENV.fetch("CVSM_TOKEN"),
72
+ ENV.fetch("CVSM_TEAM"),
73
+ environment: Rails.env,
74
+ )
75
+
76
+ # Load ke ENV saat boot
77
+ Rails.application.config.calvery.inject!(overwrite: false)
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT — support support@calvery.xyz, issues [github.com/RenzyArmstrong/Calvery-Vault](https://github.com/RenzyArmstrong/Calvery-Vault/issues)
data/lib/calvery.rb ADDED
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ # Calvery Vault Ruby SDK.
8
+ #
9
+ # client = Calvery::Client.new(ENV["CVSM_TOKEN"], "acme-corp")
10
+ # client.get("DATABASE_URL")
11
+ # client.get_all
12
+ # client.inject!(overwrite: false) # populate ENV
13
+ module Calvery
14
+ VERSION = "0.1.0"
15
+
16
+ DEFAULT_BASE_URL = "https://api.calvery.xyz"
17
+ DEFAULT_ENVIRONMENT = "production"
18
+ DEFAULT_CACHE_TTL = 30 # seconds
19
+ DEFAULT_MAX_RETRIES = 3
20
+ DEFAULT_TIMEOUT = 10 # seconds
21
+
22
+ UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
23
+
24
+ class Error < StandardError; end
25
+ class ConfigError < Error; end
26
+ class AuthError < Error; end
27
+ class NotFound < Error; end
28
+ class NetworkError < Error; end
29
+ class DecodeError < Error; end
30
+ class ServerError < Error
31
+ attr_reader :status
32
+ def initialize(msg, status)
33
+ super(msg)
34
+ @status = status
35
+ end
36
+ end
37
+
38
+ class Client
39
+ # @param token [String] Personal access token (cvsm_...)
40
+ # @param team [String] Team slug or UUID
41
+ # @param options [Hash] :base_url, :environment, :cache_ttl, :max_retries, :timeout
42
+ def initialize(token, team, **options)
43
+ raise ConfigError, "token wajib" if token.nil? || token.empty?
44
+ raise ConfigError, "team wajib (slug atau UUID)" if team.nil? || team.empty?
45
+
46
+ @token = token
47
+ @team_input = team
48
+ @base_url = (options[:base_url] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
49
+ @default_env = options[:environment] || DEFAULT_ENVIRONMENT
50
+ @cache_ttl = options[:cache_ttl] || DEFAULT_CACHE_TTL
51
+ @max_retries = options[:max_retries] || DEFAULT_MAX_RETRIES
52
+ @timeout = options[:timeout] || DEFAULT_TIMEOUT
53
+
54
+ @resolved_team_id = nil
55
+ @cache = {}
56
+ @mutex = Mutex.new
57
+ end
58
+
59
+ # Ambil satu secret by name (default env).
60
+ def get(name, environment: nil)
61
+ env = environment || @default_env
62
+ all = get_all(environment: env)
63
+ raise NotFound, %(secret "#{name}" tidak ditemukan di environment "#{env}") unless all.key?(name)
64
+ all[name]
65
+ end
66
+
67
+ # Ambil semua secret untuk environment tertentu sebagai Hash.
68
+ def get_all(environment: nil)
69
+ env = environment || @default_env
70
+ @mutex.synchronize do
71
+ entry = @cache[env]
72
+ return entry[:data].dup if entry && entry[:expires_at] > Time.now.to_i
73
+ end
74
+
75
+ team_id = resolve_team_id
76
+ query = URI.encode_www_form(format: "json", environment: env)
77
+ url = "#{@base_url}/api/v1/teams/#{team_id}/secrets/export?#{query}"
78
+ body = do_with_retry(:get, url)
79
+ data = parse_json(body)
80
+ raise DecodeError, "response bukan object JSON" unless data.is_a?(Hash)
81
+
82
+ if @cache_ttl.positive?
83
+ @mutex.synchronize do
84
+ @cache[env] = { data: data.dup, expires_at: Time.now.to_i + @cache_ttl }
85
+ end
86
+ end
87
+ data
88
+ end
89
+
90
+ # Populate ENV dari semua secret. Return array nama yang di-inject.
91
+ def inject!(overwrite: false, environment: nil)
92
+ secrets = get_all(environment: environment)
93
+ injected = []
94
+ secrets.each do |k, v|
95
+ if !overwrite && !ENV[k].to_s.empty?
96
+ next
97
+ end
98
+ ENV[k] = v
99
+ injected << k
100
+ end
101
+ injected
102
+ end
103
+
104
+ def clear_cache
105
+ @mutex.synchronize { @cache.clear }
106
+ end
107
+
108
+ private
109
+
110
+ def resolve_team_id
111
+ @mutex.synchronize { return @resolved_team_id if @resolved_team_id }
112
+
113
+ if @team_input.match?(UUID_RE)
114
+ @mutex.synchronize { @resolved_team_id = @team_input }
115
+ return @team_input
116
+ end
117
+
118
+ body = do_with_retry(:get, "#{@base_url}/api/v1/teams")
119
+ data = parse_json(body)
120
+ teams = data.is_a?(Hash) ? data["teams"] : nil
121
+ raise DecodeError, "gagal decode list teams" unless teams.is_a?(Array)
122
+
123
+ teams.each do |t|
124
+ if t.is_a?(Hash) && t["slug"] == @team_input
125
+ @mutex.synchronize { @resolved_team_id = t["id"] }
126
+ return t["id"]
127
+ end
128
+ end
129
+ raise NotFound, %(team dengan slug "#{@team_input}" tidak ditemukan di akun ini)
130
+ end
131
+
132
+ def do_with_retry(method, url_str)
133
+ uri = URI(url_str)
134
+ last = nil
135
+ 0.upto(@max_retries) do |attempt|
136
+ begin
137
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
138
+ open_timeout: @timeout, read_timeout: @timeout) do |http|
139
+ req = case method
140
+ when :get then Net::HTTP::Get.new(uri.request_uri)
141
+ else raise ArgumentError, "method #{method} tidak didukung"
142
+ end
143
+ req["Authorization"] = "Bearer #{@token}"
144
+ req["User-Agent"] = "calvery-ruby/#{VERSION}"
145
+ req["Accept"] = "application/json"
146
+
147
+ res = http.request(req)
148
+ status = res.code.to_i
149
+ case status
150
+ when 401, 403
151
+ raise AuthError, read_error_msg(res.body, status)
152
+ when 500..599
153
+ if attempt < @max_retries
154
+ sleep(backoff(attempt))
155
+ next
156
+ end
157
+ raise ServerError.new("HTTP #{status}: #{read_error_msg(res.body, status)}", status)
158
+ when 400..499
159
+ raise ServerError.new("HTTP #{status}: #{read_error_msg(res.body, status)}", status)
160
+ else
161
+ return res.body.to_s
162
+ end
163
+ end
164
+ rescue Timeout::Error, Errno::ECONNREFUSED, SocketError, IOError => e
165
+ last = e
166
+ if attempt < @max_retries
167
+ sleep(backoff(attempt))
168
+ next
169
+ end
170
+ raise NetworkError, "gagal konek ke #{@base_url}: #{e.message}"
171
+ end
172
+ end
173
+ raise NetworkError, "unreachable: #{last&.message}"
174
+ end
175
+
176
+ def backoff(attempt)
177
+ base_ms = [100 * (2**attempt), 2000].min
178
+ (base_ms + rand(100)) / 1000.0
179
+ end
180
+
181
+ def read_error_msg(body, status)
182
+ data = parse_json(body)
183
+ return data["error"] if data.is_a?(Hash) && data["error"].is_a?(String)
184
+ "HTTP #{status}"
185
+ rescue
186
+ "HTTP #{status}"
187
+ end
188
+
189
+ def parse_json(body)
190
+ return nil if body.nil? || body.empty?
191
+ JSON.parse(body)
192
+ rescue JSON::ParserError
193
+ nil
194
+ end
195
+ end
196
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: calvery
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Calvery
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Get, list, and inject secrets from Calvery Vault into your Ruby app (Rails,
14
+ Sidekiq, Rake).
15
+ email:
16
+ - support@calvery.xyz
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - lib/calvery.rb
24
+ homepage: https://calvery.xyz
25
+ licenses:
26
+ - MIT
27
+ metadata:
28
+ source_code_uri: https://github.com/RenzyArmstrong/calvery-sdks
29
+ bug_tracker_uri: https://github.com/RenzyArmstrong/Calvery-Vault/issues
30
+ documentation_uri: https://docs.calvery.xyz/sdk/ruby/
31
+ post_install_message:
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.3.5
47
+ signing_key:
48
+ specification_version: 4
49
+ summary: Official Ruby SDK for Calvery Vault secret manager
50
+ test_files: []