opdotenv 1.0.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.
@@ -0,0 +1,290 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module Opdotenv
5
+ class ConnectApiClient
6
+ class ConnectApiError < StandardError; end
7
+
8
+ NOTES_PLAIN_FIELD = "notesPlain"
9
+ NOTES_PURPOSE = "NOTES"
10
+ SECURE_NOTE_CATEGORY = "SECURE_NOTE"
11
+ LOGIN_CATEGORY = "LOGIN"
12
+
13
+ def initialize(base_url:, access_token:, env: ENV)
14
+ validate_url(base_url)
15
+ validate_token(access_token)
16
+ @base_url = base_url.chomp("/")
17
+ @access_token = access_token
18
+ @env = env
19
+ end
20
+
21
+ # Parse op:// style path or connect:// style path
22
+ # op://Vault/Item -> {vault: "Vault", item: "Item", field: nil}
23
+ # op://Vault/Item/notesPlain -> {vault: "Vault", item: "Item", field: "notesPlain"}
24
+ # op://Vault/Item/Section/Field -> {vault: "Vault", item: "Item", field: "Field"}
25
+ def read(path)
26
+ parsed = parse_path(path)
27
+ item = get_item(parsed[:vault], parsed[:item])
28
+
29
+ if parsed[:field]
30
+ # Read specific field
31
+ field = find_field(item, parsed[:field])
32
+ field ? field["value"] : ""
33
+ else
34
+ # Read notesPlain for secure notes
35
+ notes_field = item["fields"]&.find { |f| f["purpose"] == NOTES_PURPOSE || f["label"] == NOTES_PLAIN_FIELD }
36
+ notes_field ? notes_field["value"] : ""
37
+ end
38
+ end
39
+
40
+ def item_get(item_title, vault: nil)
41
+ # If vault not provided, search all vaults (requires listing vaults)
42
+ if vault.nil?
43
+ item = find_item_in_all_vaults(item_title)
44
+ raise ConnectApiError, "Item '#{item_title}' not found" unless item
45
+ else
46
+ vault_id = vault_name_to_id(vault)
47
+ item = item_by_title_in_vault(vault_id, item_title)
48
+ raise ConnectApiError, "Item '#{item_title}' not found in vault '#{vault}'" unless item
49
+ end
50
+
51
+ JSON.pretty_generate(item)
52
+ end
53
+
54
+ def find_item_in_all_vaults(item_title)
55
+ vaults = list_vaults
56
+ vaults.each do |v|
57
+ item = item_by_title_in_vault(v["id"], item_title)
58
+ return item if item
59
+ end
60
+ nil
61
+ end
62
+
63
+ def item_create_note(vault:, title:, notes:)
64
+ vault_id = vault_name_to_id(vault)
65
+
66
+ payload = {
67
+ "vault" => {"id" => vault_id},
68
+ "title" => title,
69
+ "category" => SECURE_NOTE_CATEGORY,
70
+ "fields" => [
71
+ {
72
+ "purpose" => NOTES_PURPOSE,
73
+ "value" => notes
74
+ }
75
+ ]
76
+ }
77
+
78
+ response = api_request(:post, "/v1/vaults/#{vault_id}/items", payload)
79
+ JSON.pretty_generate(response)
80
+ end
81
+
82
+ def item_create_or_update_fields(vault:, item:, fields: {})
83
+ vault_id = vault_name_to_id(vault)
84
+
85
+ # Try to find existing item by title
86
+ existing = item_by_title_in_vault(vault_id, item)
87
+
88
+ if existing
89
+ # Update using PATCH
90
+ fields_array = fields.map do |k, v|
91
+ existing_field = existing["fields"]&.find { |f| f["label"] == k.to_s }
92
+ if existing_field
93
+ {
94
+ "op" => "replace",
95
+ "path" => "/fields/#{existing_field["id"]}/value",
96
+ "value" => v.to_s
97
+ }
98
+ else
99
+ {
100
+ "op" => "add",
101
+ "path" => "/fields",
102
+ "value" => {
103
+ "type" => "CONCEALED",
104
+ "label" => k.to_s,
105
+ "value" => v.to_s
106
+ }
107
+ }
108
+ end
109
+ end
110
+
111
+ api_request(:patch, "/v1/vaults/#{vault_id}/items/#{existing["id"]}", fields_array)
112
+ else
113
+ # Create new item
114
+ fields_array = fields.map do |k, v|
115
+ {
116
+ "type" => "CONCEALED",
117
+ "label" => k.to_s,
118
+ "value" => v.to_s
119
+ }
120
+ end
121
+
122
+ payload = {
123
+ "vault" => {"id" => vault_id},
124
+ "title" => item,
125
+ "category" => LOGIN_CATEGORY,
126
+ "fields" => fields_array
127
+ }
128
+
129
+ api_request(:post, "/v1/vaults/#{vault_id}/items", payload)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def parse_path(path)
136
+ # op://Vault/Item or connect://Vault/Item
137
+ match = path.match(/\A(?:op|connect):\/\/([^\/]+)\/([^\/]+)(?:\/(.+))?\z/)
138
+ raise ConnectApiError, "Invalid path format: #{path}" unless match
139
+
140
+ {
141
+ vault: match[1],
142
+ item: match[2],
143
+ field: match[3]
144
+ }
145
+ end
146
+
147
+ def vault_name_to_id(vault_name)
148
+ @vault_cache ||= {}
149
+ return @vault_cache[vault_name] if @vault_cache.key?(vault_name)
150
+
151
+ vaults = list_vaults
152
+ vault = vaults.find { |v| v["name"] == vault_name || v["id"] == vault_name }
153
+ raise ConnectApiError, "Vault '#{vault_name}' not found" unless vault
154
+
155
+ @vault_cache[vault_name] = vault["id"]
156
+ end
157
+
158
+ def list_vaults
159
+ api_request(:get, "/v1/vaults")
160
+ end
161
+
162
+ def get_item(vault_name, item_title)
163
+ vault_id = vault_name_to_id(vault_name)
164
+ item = item_by_title_in_vault(vault_id, item_title)
165
+ raise ConnectApiError, "Item '#{item_title}' not found in vault '#{vault_name}'" unless item
166
+
167
+ # Fetch full item details including fields
168
+ api_request(:get, "/v1/vaults/#{vault_id}/items/#{item["id"]}")
169
+ end
170
+
171
+ def item_by_title_in_vault(vault_id, item_title)
172
+ # List items and find by title
173
+ items = api_request(:get, "/v1/vaults/#{vault_id}/items")
174
+ item = items.find { |i| i["title"] == item_title || i["id"] == item_title }
175
+ # If found by listing, fetch full details to get fields
176
+ if item && item["id"]
177
+ api_request(:get, "/v1/vaults/#{vault_id}/items/#{item["id"]}")
178
+ else
179
+ item
180
+ end
181
+ end
182
+
183
+ def find_field(item, field_name)
184
+ item["fields"]&.find do |f|
185
+ f["label"] == field_name ||
186
+ f["id"] == field_name ||
187
+ (field_name == NOTES_PLAIN_FIELD && f["purpose"] == NOTES_PURPOSE)
188
+ end
189
+ end
190
+
191
+ def api_request(method, path, body = nil)
192
+ uri = build_uri(path)
193
+ http = build_http_client(uri)
194
+
195
+ request = build_request(method, uri, body)
196
+ response = execute_request_with_retry(http, request)
197
+
198
+ handle_response(response, path)
199
+ end
200
+
201
+ def build_uri(path)
202
+ # Validate path to prevent path traversal attacks
203
+ # API paths legitimately start with "/", so only check for ".."
204
+ raise ConnectApiError, "Invalid path: #{path}" if path.include?("..")
205
+
206
+ # Use URI.join which handles path normalization safely
207
+ # URI.join expects base URL without trailing slash for proper joining
208
+ base_uri = @base_url.end_with?("/") ? @base_url[0..-2] : @base_url
209
+ normalized_path = path.start_with?("/") ? path : "/#{path}"
210
+ URI.join(base_uri, normalized_path)
211
+ end
212
+
213
+ def build_http_client(uri)
214
+ http = Net::HTTP.new(uri.host, uri.port)
215
+ http.use_ssl = uri.scheme == "https"
216
+ http.open_timeout = (@env["OPDOTENV_HTTP_OPEN_TIMEOUT"] || 5).to_i
217
+ http.read_timeout = (@env["OPDOTENV_HTTP_READ_TIMEOUT"] || 10).to_i
218
+ http
219
+ end
220
+
221
+ def build_request(method, uri, body)
222
+ request_class = case method
223
+ when :get then Net::HTTP::Get
224
+ when :post then Net::HTTP::Post
225
+ when :put then Net::HTTP::Put
226
+ when :patch then Net::HTTP::Patch
227
+ when :delete then Net::HTTP::Delete
228
+ else raise ConnectApiError, "Unsupported HTTP method: #{method}"
229
+ end
230
+
231
+ request = request_class.new(uri.request_uri)
232
+ request["Authorization"] = "Bearer #{@access_token}"
233
+ request["Content-Type"] = "application/json"
234
+ request.body = JSON.generate(body) if body
235
+ request
236
+ end
237
+
238
+ def execute_request_with_retry(http, request)
239
+ attempts = 0
240
+ begin
241
+ attempts += 1
242
+ http.request(request)
243
+ rescue Timeout::Error, Errno::ECONNRESET
244
+ raise if attempts >= 2
245
+ sleep 0.2
246
+ retry
247
+ end
248
+ end
249
+
250
+ def handle_response(response, path)
251
+ code = response.code.to_i
252
+
253
+ case code
254
+ when 200, 204
255
+ return {} if response.body.empty? || code == 204
256
+ JSON.parse(response.body)
257
+ when 401
258
+ raise ConnectApiError, "Unauthorized: Invalid or missing access token"
259
+ when 403
260
+ raise ConnectApiError, "Forbidden: Access denied"
261
+ when 404
262
+ raise ConnectApiError, "Not found: #{path}"
263
+ when 500..599
264
+ raise ConnectApiError, "API error (#{code}): #{extract_error_message(response)}"
265
+ else
266
+ raise ConnectApiError, "API error (#{code}): #{extract_error_message(response)}"
267
+ end
268
+ end
269
+
270
+ def extract_error_message(response)
271
+ parsed = JSON.parse(response.body)
272
+ parsed["message"] || parsed["error"] || response.body
273
+ rescue JSON::ParserError
274
+ response.body
275
+ end
276
+
277
+ def validate_url(url)
278
+ uri = URI.parse(url)
279
+ unless ["http", "https"].include?(uri.scheme)
280
+ raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Must be http or https"
281
+ end
282
+ rescue URI::InvalidURIError => e
283
+ raise ArgumentError, "Invalid URL: #{url} - #{e.message}"
284
+ end
285
+
286
+ def validate_token(token)
287
+ raise ArgumentError, "Access token cannot be empty" if token.nil? || token.empty?
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,59 @@
1
+ module Opdotenv
2
+ class Exporter
3
+ # Export data to 1Password
4
+ # Paths like "op://Vault/.env.development" or "op://Vault/config.json" create Secure Notes (format inferred from item name extension)
5
+ # Paths like "op://Vault/App" create/update item fields
6
+ # Format is inferred from item name extension: .env.*, *.json, *.yaml, *.yml
7
+ # @param path [String] Path like "op://Vault/.env.development", "op://Vault/production.json", or "op://Vault/App"
8
+ # @param data [Hash] Data to export
9
+ # @param field_type [Symbol] Format override (:dotenv, :json, :yaml). Default: inferred from path
10
+ def self.export(path:, data:, field_type: nil, client: nil, env: ENV)
11
+ client ||= ClientFactory.create(env: env)
12
+ vault, item = Loader.parse_op_path(path)
13
+
14
+ # Extract item name and potential field name
15
+ # Handle paths like "op://Vault/Item" or "op://Vault/Item Name/field"
16
+ item_parts = item.split("/")
17
+ item_name = item_parts.first
18
+
19
+ # Check if path matches format patterns (Secure Note) or regular item (fields)
20
+ if FormatInferrer.matches_format_pattern?(item_name)
21
+ # Create Secure Note
22
+ field_type ||= FormatInferrer.infer_from_name(item_name) || :dotenv
23
+ content = serialize_by_format(data, field_type)
24
+ client.item_create_note(vault: vault, title: item_name, notes: content)
25
+ else
26
+ # Create/update item with fields
27
+ flat = data.transform_values(&:to_s)
28
+ client.item_create_or_update_fields(vault: vault, item: item_name, fields: flat)
29
+ end
30
+ end
31
+
32
+ def self.infer_format_from_item(item)
33
+ FormatInferrer.infer_from_name(item) || :dotenv
34
+ end
35
+
36
+ def self.serialize_by_format(data, format)
37
+ case format
38
+ when :dotenv
39
+ data.map { |k, v| "#{k}=#{escape_env(v)}" }.join("\n") + "\n"
40
+ when :json
41
+ JSON.pretty_generate(data)
42
+ when :yaml, :yml
43
+ YAML.dump(data)
44
+ else
45
+ raise ArgumentError, "Unsupported format: #{format}. Supported: :dotenv, :json, :yaml"
46
+ end
47
+ end
48
+
49
+ def self.escape_env(value)
50
+ s = value.to_s
51
+ return '""' if s.empty?
52
+ if s.match?(/\s|["'#]/)
53
+ '"' + s.gsub('"', '\\"') + '"'
54
+ else
55
+ s
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ module Opdotenv
2
+ # Shared module for format inference from item/field names
3
+ module FormatInferrer
4
+ module_function
5
+
6
+ DOTENV_PATTERN = /\.env\.?/
7
+ JSON_EXTENSIONS = [".json"].freeze
8
+ YAML_EXTENSIONS = [".yaml", ".yml"].freeze
9
+
10
+ def infer_from_name(name)
11
+ return :dotenv if name.match?(DOTENV_PATTERN)
12
+ return :json if JSON_EXTENSIONS.any? { |ext| name.end_with?(ext) }
13
+ return :yaml if YAML_EXTENSIONS.any? { |ext| name.end_with?(ext) }
14
+
15
+ nil
16
+ end
17
+
18
+ def matches_format_pattern?(name)
19
+ !infer_from_name(name).nil?
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,72 @@
1
+ module Opdotenv
2
+ class Loader
3
+ NOTES_PURPOSE = "NOTES"
4
+
5
+ # Unified loading API
6
+ # If field_name is set, fetches a single field and parses it with field_type (default: :dotenv)
7
+ # If field_name is not set, fetches all fields without parsing
8
+ def self.load(path, field_name: nil, field_type: :dotenv, env: ENV, client: nil, overwrite: true)
9
+ client ||= ClientFactory.create(env: env)
10
+
11
+ data = field_name ? load_field(client, path, field_name, field_type) : load_all_fields(client, path)
12
+
13
+ merge_into_env(env, data, overwrite: overwrite)
14
+ data
15
+ end
16
+
17
+ def self.load_field(client, path, field_name, field_type)
18
+ field_path = build_field_path(path, field_name)
19
+ text = client.read(field_path)
20
+ parse_by_format(text, field_type)
21
+ end
22
+
23
+ def self.load_all_fields(client, path)
24
+ vault, item = parse_op_path(path)
25
+ raw_json = client.item_get(item, vault: vault)
26
+ item_hash = parse_json_safe(raw_json)
27
+
28
+ item_hash["fields"]&.each_with_object({}) do |field, env_data|
29
+ label = field["label"] || field["id"]
30
+ next unless label
31
+ next if field["purpose"] == NOTES_PURPOSE # skip notesPlain when fetching all
32
+ env_data[label.to_s] = (field["value"] || "").to_s
33
+ end || {}
34
+ end
35
+
36
+ def self.build_field_path(path, field_name)
37
+ # op CLI requires vault/item/field format
38
+ # Avoid duplication if field name already in path
39
+ path.end_with?("/#{field_name}") ? path : "#{path}/#{field_name}"
40
+ end
41
+
42
+ def self.parse_json_safe(json_string)
43
+ JSON.parse(json_string)
44
+ rescue JSON::ParserError
45
+ {}
46
+ end
47
+
48
+ def self.parse_by_format(text, format)
49
+ case format
50
+ when :dotenv then Parsers::DotenvParser.parse(text)
51
+ when :json then Parsers::JsonParser.parse(text)
52
+ when :yaml, :yml then Parsers::YamlParser.parse(text)
53
+ else
54
+ raise ArgumentError, "Unsupported format: #{format}. Supported: :dotenv, :json, :yaml"
55
+ end
56
+ end
57
+
58
+ def self.parse_op_path(path)
59
+ m = path.match(/\Aop:\/\/([^\/]*)\/([^\/]*)/)
60
+ raise ArgumentError, "Invalid op path: #{path}" unless m
61
+ [m[1], m[2]]
62
+ end
63
+
64
+ def self.merge_into_env(env, hash, overwrite: true)
65
+ hash.each do |k, v|
66
+ key = k.to_s
67
+ next if !overwrite && env.key?(key)
68
+ env[key] = v.to_s
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,94 @@
1
+ require "json"
2
+
3
+ module Opdotenv
4
+ class OpClient
5
+ class OpError < StandardError; end
6
+
7
+ NOTES_PLAIN_FIELD = "notesPlain"
8
+ SECURE_NOTE_CATEGORY = "secure-note"
9
+ LOGIN_CATEGORY = "LOGIN"
10
+
11
+ def initialize(env: ENV)
12
+ @env = env
13
+ end
14
+
15
+ def read(path)
16
+ validate_path(path)
17
+ out = capture(["op", "read", path])
18
+ out.strip
19
+ end
20
+
21
+ def item_get(item, vault: nil)
22
+ args = ["op", "item", "get", item, "--format", "json"]
23
+ args += ["--vault", vault] if vault
24
+ capture(args)
25
+ end
26
+
27
+ def item_create_note(vault:, title:, notes:)
28
+ # Create a Secure Note with given title and notesPlain
29
+ # Use shell escaping to prevent injection
30
+ args = [
31
+ "op", "item", "create",
32
+ "--category", SECURE_NOTE_CATEGORY,
33
+ "--title", title,
34
+ "--vault", vault,
35
+ "#{NOTES_PLAIN_FIELD}=#{notes}"
36
+ ]
37
+ capture(args)
38
+ end
39
+
40
+ def item_create_or_update_fields(vault:, item:, fields: {})
41
+ exists = item_exists?(item, vault: vault)
42
+ if exists
43
+ fields.each do |k, v|
44
+ # Use shell escaping to prevent injection
45
+ field_arg = "#{k}=#{v}"
46
+ capture(["op", "item", "edit", item, "--vault", vault, "--set", field_arg])
47
+ end
48
+ else
49
+ args = ["op", "item", "create", "--title", item, "--vault", vault]
50
+ fields.each do |k, v|
51
+ args += ["--set", "#{k}=#{v}"]
52
+ end
53
+ capture(args)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def item_exists?(item, vault: nil)
60
+ args = ["op", "item", "get", item]
61
+ args += ["--vault", vault] if vault
62
+ system(*args, out: File::NULL, err: File::NULL)
63
+ end
64
+
65
+ def validate_path(path)
66
+ return if path.is_a?(String) && path.start_with?("op://")
67
+
68
+ raise ArgumentError, "Invalid path format: #{path.inspect}. Must start with 'op://'"
69
+ end
70
+
71
+ def capture(args)
72
+ # Use exec-style array to prevent shell injection
73
+ # IO.popen with array arguments avoids shell interpretation
74
+ out = IO.popen(args, err: [:child, :out]) do |io|
75
+ io.read
76
+ end
77
+ status = $CHILD_STATUS
78
+
79
+ # For JSON output, try to parse even if exit code is non-zero
80
+ # Some op commands may return non-zero but still output valid JSON
81
+ if args.include?("--format") && args.include?("json")
82
+ begin
83
+ JSON.parse(out)
84
+ return out # Valid JSON, return it even if exit code is non-zero
85
+ rescue JSON::ParserError
86
+ # Not valid JSON, fall through to error handling
87
+ end
88
+ end
89
+
90
+ raise OpError, out if status.nil? || !status.success?
91
+ out
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,28 @@
1
+ module Opdotenv
2
+ module Parsers
3
+ class DotenvParser
4
+ def self.parse(text)
5
+ env = {}
6
+ text.to_s.each_line do |line|
7
+ line = line.strip
8
+ next if line.empty? || line.start_with?("#")
9
+ # Support KEY=VALUE and KEY="VALUE"; ignore export prefix
10
+ line = line.sub(/^export\s+/, "")
11
+ if (m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\z/))
12
+ key = m[1]
13
+ raw = m[2]
14
+ value = if raw.start_with?("\"") && raw.end_with?("\"")
15
+ raw[1..-2].gsub('\\"', '"')
16
+ elsif raw.start_with?("'") && raw.end_with?("'")
17
+ raw[1..-2]
18
+ else
19
+ raw
20
+ end
21
+ env[key] = value
22
+ end
23
+ end
24
+ env
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ module Opdotenv
2
+ module Parsers
3
+ class JsonParser
4
+ def self.parse(text)
5
+ data = JSON.parse(text.to_s)
6
+ flatten_to_string_map(data)
7
+ end
8
+
9
+ def self.flatten_to_string_map(obj, prefix = nil, out = {})
10
+ case obj
11
+ when Hash
12
+ obj.each do |k, v|
13
+ key = prefix ? "#{prefix}_#{k}" : k.to_s
14
+ flatten_to_string_map(v, key, out)
15
+ end
16
+ when Array
17
+ obj.each_with_index do |v, i|
18
+ key = prefix ? "#{prefix}_#{i}" : i.to_s
19
+ flatten_to_string_map(v, key, out)
20
+ end
21
+ else
22
+ out[prefix.to_s] = obj.to_s
23
+ end
24
+ out
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ require "yaml"
2
+
3
+ module Opdotenv
4
+ module Parsers
5
+ class YamlParser
6
+ # Safe YAML parsing without aliases (aliases can cause DoS attacks)
7
+ PERMITTED_CLASSES = [Date, Time, Symbol].freeze
8
+
9
+ def self.parse(text)
10
+ data = YAML.safe_load(text.to_s, permitted_classes: PERMITTED_CLASSES, aliases: false)
11
+ JsonParser.flatten_to_string_map(data)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ require "opdotenv"
2
+
3
+ module Opdotenv
4
+ class Railtie < ::Rails::Railtie
5
+ # This Railtie ensures opdotenv is automatically required when Rails loads
6
+ # and provides configuration via Rails.configuration.opdotenv
7
+
8
+ config.opdotenv = ActiveSupport::OrderedOptions.new
9
+ config.opdotenv.sources = []
10
+ # Optional 1Password Connect settings (alternatively set via ENV)
11
+ config.opdotenv.connect_url = nil
12
+ config.opdotenv.connect_token = nil
13
+ config.opdotenv.overwrite = true
14
+ config.opdotenv.auto_load = true
15
+
16
+ # Hook into Rails initialization to load from 1Password
17
+ initializer "opdotenv.load", before: :load_environment_config do |app|
18
+ config = app.config.opdotenv
19
+
20
+ next unless config.auto_load
21
+
22
+ # Prefer Connect API settings from Rails configuration if provided
23
+ if config.connect_url && config.connect_token
24
+ ENV["OP_CONNECT_URL"] = config.connect_url
25
+ ENV["OP_CONNECT_TOKEN"] = config.connect_token
26
+ end
27
+
28
+ # Load from configured sources
29
+ # Sources can be strings (simplified format) or hashes (backward compatibility)
30
+ (config.sources || []).each do |source|
31
+ parsed = SourceParser.parse(source)
32
+ next unless parsed[:path]
33
+
34
+ overwrite = if source.is_a?(Hash) && (source.key?(:overwrite) || source.key?("overwrite"))
35
+ parsed[:overwrite]
36
+ else
37
+ config.overwrite
38
+ end
39
+
40
+ begin
41
+ Loader.load(
42
+ parsed[:path],
43
+ field_name: parsed[:field_name],
44
+ field_type: parsed[:field_type],
45
+ env: ENV,
46
+ overwrite: overwrite
47
+ )
48
+ rescue => e
49
+ # Only log errors, not warnings, to avoid noise in production
50
+ Rails.logger&.error("Opdotenv: Failed to load #{parsed[:path]}: #{e.message}")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end