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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.md +23 -0
- data/README.md +333 -0
- data/bin/opdotenv +44 -0
- data/lib/opdotenv/anyway_loader.rb +125 -0
- data/lib/opdotenv/client_factory.rb +17 -0
- data/lib/opdotenv/connect_api_client.rb +290 -0
- data/lib/opdotenv/exporter.rb +59 -0
- data/lib/opdotenv/format_inferrer.rb +22 -0
- data/lib/opdotenv/loader.rb +72 -0
- data/lib/opdotenv/op_client.rb +94 -0
- data/lib/opdotenv/parsers/dotenv_parser.rb +28 -0
- data/lib/opdotenv/parsers/json_parser.rb +28 -0
- data/lib/opdotenv/parsers/yaml_parser.rb +15 -0
- data/lib/opdotenv/railtie.rb +55 -0
- data/lib/opdotenv/source_parser.rb +71 -0
- data/lib/opdotenv/version.rb +3 -0
- data/lib/opdotenv.rb +19 -0
- metadata +224 -0
|
@@ -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
|