opdotenv 1.0.3 → 1.0.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd66207d97fd6cf74fe9eaf05dcd22e43b05dd68af9995dcc5348be00fd465ae
4
- data.tar.gz: 78f719f95dcd42edbdde3e00ed0be0ca9be5ef470055e0a6e49623f3599fd566
3
+ metadata.gz: 9bbc6ae3316e89e48c9c52e3f3aa33f3a3572217eab08b88ddc98283989b7f04
4
+ data.tar.gz: e964c986cd6676f4a0f3d0c3c33f633b520c9f277b6030f79a407f0db3cd8fdd
5
5
  SHA512:
6
- metadata.gz: 4ba1fbc82986e6a20c8302c21bf27e0c61a3ba4703379dc0a9966d115d0101c8383497a7eea4a14cbce010eff8a94d273f98a6da1c5c01810773d2dc70c6400a
7
- data.tar.gz: f695a8cf1a6e7e1f3c2daba02609366618b82f66162c45a4f64912e50dae71313dfab2cc0cd276dfae8409f43ba6895707a3a85b9447eaea4af00653bebd82e6
6
+ metadata.gz: e1c8b865089d2a6a2463f7ebc5edffd60566a826fb9667c792ef02c61c113a309a892adf2fbb5ade5115868fcc52b58919a15b79ecb3e84d3017531a6aebb678
7
+ data.tar.gz: 2e308264ed8c9aadbd846daef1c9804f98f8571cffd902be3fece3613b586d5240edffa4d0d2dffa2008573a3280e6994f5089635628a4bc1a4a6af1d70be2ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.0.6 (2026-11-03)
4
+
5
+ - Keep exported secrets out of 1Password CLI command arguments.
6
+ - Upsert Secure Note exports on create and edit for both `op` CLI and Connect.
7
+ - Use dedicated configuration items for CLI exports; existing-item updates cannot preserve passkeys.
8
+ - Infer export formats correctly when no override is supplied; match `.env` basenames only.
9
+ - Accept successful Connect API creation responses and avoid repeating writes after timeouts.
10
+ - Filter Connect item lookups by title and omit server response values from error messages.
11
+ - Fetch item details once per read after the filtered list.
12
+ - Cancel release preparation when confirmation input is closed.
13
+
14
+ ## 1.0.5 (2026-07-06)
15
+
16
+ - Improve `OPDOTENV_DEBUG` field summary logging in Anyway Config integration to build messages only when Rails debug logging is active
17
+ - Skip field summary debug output when a 1Password item has no fields
18
+
19
+ ## 1.0.4 (2026-07-05)
20
+
21
+ - Move `railties` from runtime to development dependency in gemspec
22
+ - Non-Rails installs no longer pull in the Rails gem tree; Rails apps already provide `railties`
23
+
3
24
  ## 1.0.3 (2025-11-07)
4
25
 
5
26
  - Fix exit code handling in OpClient by using Open3.capture2e instead of IO.popen for reliable process status
data/README.md CHANGED
@@ -1,15 +1,9 @@
1
1
  # opdotenv
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/opdotenv.svg?v=1.0.3)](https://badge.fury.io/rb/opdotenv) [![Test Status](https://github.com/amkisko/opdotenv.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/opdotenv.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/opdotenv.rb/graph/badge.svg?token=U4FMVZGO8R)](https://codecov.io/gh/amkisko/opdotenv.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/opdotenv.svg?v=1.0.6)](https://badge.fury.io/rb/opdotenv) [![Test Status](https://github.com/amkisko/opdotenv.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/opdotenv.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/opdotenv.rb/graph/badge.svg?token=U4FMVZGO8R)](https://app.codecov.io/github/amkisko/opdotenv.rb) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=amkisko_opdotenv.rb&metric=alert_status)](https://sonarcloud.io/project/overview?id=amkisko_opdotenv.rb)
4
4
 
5
5
  Load environment variables from 1Password using the `op` CLI or 1Password Connect Server API. Supports dotenv, JSON, and YAML formats.
6
6
 
7
- Sponsored by [Kisko Labs](https://www.kiskolabs.com).
8
-
9
- <a href="https://www.kiskolabs.com">
10
- <img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
11
- </a>
12
-
13
7
  ## Installation
14
8
 
15
9
  Add to your Gemfile:
@@ -26,7 +20,7 @@ Choose one:
26
20
  - You can configure a custom path via `OP_CLI_PATH` or `OPDOTENV_CLI_PATH` environment variables, or via Rails config (see below)
27
21
  - **1Password Connect Server** - set `OP_CONNECT_URL` and `OP_CONNECT_TOKEN` environment variables
28
22
 
29
- Ruby 2.7+ supported.
23
+ Ruby 3.4+ required.
30
24
 
31
25
  ## Rails
32
26
 
@@ -42,16 +36,7 @@ Rails.application.configure do
42
36
  end
43
37
  ```
44
38
 
45
- Format is automatically inferred from item name or field name:
46
- - `.env.*` → dotenv format
47
- - `*.json` → JSON format
48
- - `*.yaml` or `*.yml` → YAML format
49
- - Other items → load all fields without parsing
50
-
51
- You can also specify the field name with extension in the path:
52
- - `op://Vault/Item Name/config.json` → uses field `config.json` as JSON
53
- - `op://Vault/Item Name/production.json` → uses field `production.json` as JSON
54
- - `op://Vault/Item Name/.env.development` → uses field `.env.development` as dotenv
39
+ Format is inferred from the item or field basename (see [Supported formats](#supported-formats)). Field names with extensions work in the path, for example `op://Vault/Item Name/config.json`.
55
40
 
56
41
  ### 1Password Connect
57
42
 
@@ -135,16 +120,15 @@ class DatabaseConfig < Anyway::Config
135
120
  end
136
121
  ```
137
122
 
138
- ### Conditional Loading (Recommended for Security)
123
+ ### Conditional loading
139
124
 
140
- For better security, only load from 1Password in development/test environments:
125
+ Load from 1Password only in local or development environments:
141
126
 
142
127
  ```ruby
143
128
  class TestConfig < Anyway::Config
144
129
  config_name :test
145
130
  attr_config :enabled, :sample
146
131
 
147
- # Only load from 1Password in local/development environments
148
132
  if Rails.env.local?
149
133
  loader_options opdotenv: {
150
134
  path: "op://Employee/.env.test"
@@ -153,7 +137,7 @@ class TestConfig < Anyway::Config
153
137
  end
154
138
  ```
155
139
 
156
- This ensures that production environments won't attempt to load secrets from 1Password, aligning with the [production recommendations](#environment-recommendation).
140
+ See [Environment recommendation](#environment-recommendation).
157
141
 
158
142
  ### Loading All Fields from an Item
159
143
 
@@ -204,6 +188,8 @@ Dotenv.load(".env", ".env.development")
204
188
 
205
189
  ## Export to 1Password
206
190
 
191
+ Use dedicated configuration items for exports through the `op` CLI. Export values are sent through standard input to keep them out of process arguments. Updating an existing item uses a JSON template and does not support items containing passkeys: 1Password warns that template updates overwrite passkeys. Do not export into a login item that contains a passkey. See the [1Password item edit documentation](https://www.1password.dev/cli/reference/management-commands/item#edit-an-item-using-a-template).
192
+
207
193
  ### CLI
208
194
 
209
195
  ```bash
@@ -235,50 +221,33 @@ Opdotenv::Exporter.export(
235
221
 
236
222
  ## Supported formats
237
223
 
238
- Format is automatically inferred from item name or field name:
239
- - `.env.*` → dotenv format (`KEY=VALUE`)
240
- - `*.json` → JSON format (nested structures flattened with underscores)
241
- - `*.yaml` or `*.yml` → YAML format (nested structures flattened with underscores)
242
- - Other items → load all fields without parsing
243
-
244
- Field names can be specified with extensions in the path:
245
- - `op://Vault/Item Name/config.json` → loads field `config.json` as JSON
246
- - `op://Vault/Item Name/production.json` → loads field `production.json` as JSON
247
- - `op://Vault/Item Name/.env.development` → loads field `.env.development` as dotenv
224
+ Format is inferred from the item or field basename:
225
+ - `.env` or `.env.*`: dotenv (`KEY=VALUE`)
226
+ - `*.json`: JSON (nested keys flattened with underscores)
227
+ - `*.yaml` or `*.yml`: YAML (nested keys flattened with underscores)
228
+ - Other items: load all fields without parsing
248
229
 
249
- For advanced usage, you can explicitly specify `field_name` and `field_type` in the API.
230
+ Field names with extensions work in the path, for example `op://Vault/Item Name/config.json`. Callers can also pass `field_name` and `field_type` to the API.
250
231
 
251
232
  ## Security
252
233
 
253
- ### Environment Recommendation
254
-
255
- **⚠️ This gem is recommended for development and test environments only.**
256
-
257
- For production environments, we recommend using dedicated secret management solutions that integrate with your infrastructure:
234
+ ### Environment recommendation
258
235
 
259
- - **Kubernetes**: Use [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) or [External Secrets Operator](https://external-secrets.io/) with providers like AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault
260
- - **AWS**: Use [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) or [AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html)
261
- - **Azure**: Use [Azure Key Vault](https://azure.microsoft.com/en-us/products/key-vault)
262
- - **GCP**: Use [Google Secret Manager](https://cloud.google.com/secret-manager)
236
+ Use this gem in development and test. For production, prefer the secret store that matches the host:
263
237
 
264
- Provision secrets through infrastructure-as-code tools:
265
- - **Helm** (Kubernetes): Use `helm secrets` or external secrets operator
266
- - **Terraform**: Use `aws_secretsmanager_secret`, `azurerm_key_vault_secret`, or `google_secret_manager_secret`
267
- - **Bicep** (Azure): Use `Microsoft.KeyVault/vaults` resources
238
+ - **Kubernetes**: [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) or [External Secrets Operator](https://external-secrets.io/) with AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault
239
+ - **AWS**: [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) or [AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html)
240
+ - **Azure**: [Azure Key Vault](https://azure.microsoft.com/en-us/products/key-vault)
241
+ - **GCP**: [Google Secret Manager](https://cloud.google.com/secret-manager)
268
242
 
269
- These solutions provide:
270
- - Better audit trails and access controls
271
- - Integration with IAM/RBAC systems
272
- - Automatic secret rotation
273
- - Compliance with security standards
274
- - No dependency on external CLI tools or API servers
243
+ Provision through infrastructure tools such as Helm, Terraform, or Bicep when that matches the host.
275
244
 
276
- ### Security Considerations
245
+ ### Security considerations
277
246
 
278
- - **CLI mode**: Secrets fetched via authenticated `op` CLI session.
279
- - **Connect API mode**: Secrets fetched via HTTPS. Ensure tokens are secure.
280
- - The library does not persist secrets in memory or on disk.
281
- - Always verify 1Password CLI or Connect server is up to date and authenticated.
247
+ - **CLI mode**: Secrets fetched via an authenticated `op` CLI session.
248
+ - **Connect API mode**: Secrets fetched via HTTPS. Keep tokens out of logs and process arguments.
249
+ - The library does not persist secrets in memory beyond the load, or on disk.
250
+ - Keep the 1Password CLI or Connect server current and authenticated.
282
251
 
283
252
  ### Code Locations for Security Review
284
253
 
@@ -289,7 +258,7 @@ For security-sensitive applications, developers should review where this gem rea
289
258
  - **`lib/opdotenv/op_client.rb`**:
290
259
  - `read(path)` - Executes `op read` command to fetch a single field value
291
260
  - `item_get(item, vault:)` - Executes `op item get` command to fetch all item data as JSON
292
- - `capture(args)` - Executes shell commands via `IO.popen` (array arguments, no shell interpretation)
261
+ - `capture(args)` - Runs the CLI via `Open3.capture2e` (array arguments, optional stdin)
293
262
 
294
263
  #### Reading Secrets (ConnectApiClient - API mode)
295
264
 
@@ -297,7 +266,7 @@ For security-sensitive applications, developers should review where this gem rea
297
266
  - `read(path)` - Makes HTTP GET request to fetch field or notesPlain content
298
267
  - `item_get(item_title, vault:)` - Searches and fetches item data via API
299
268
  - `get_item(vault_name, item_title)` - Fetches full item details including all fields
300
- - `item_by_title_in_vault(vault_id, item_title)` - Lists items and fetches by title
269
+ - `item_by_title_in_vault(vault_id, item_title)` - Filters items by title and fetches full details
301
270
  - `list_vaults()` - Lists all accessible vaults (uses `api_request(:get, "/v1/vaults")`)
302
271
  - `vault_name_to_id(vault_name)` - Resolves vault names to IDs (cached)
303
272
  - `api_request(method, path, body)` - All HTTP requests go through this method
@@ -330,14 +299,14 @@ For security-sensitive applications, developers should review where this gem rea
330
299
  - **`lib/opdotenv/parsers/json_parser.rb`** - Parses and flattens JSON structures
331
300
  - **`lib/opdotenv/parsers/yaml_parser.rb`** - Parses YAML (safe_load with aliases: false)
332
301
 
333
- #### Data Flow
302
+ #### Data flow
334
303
 
335
- 1. **Configuration** → Rails config or direct API calls
336
- 2. **Path Parsing** → `SourceParser.parse()` extracts vault/item/field from path
337
- 3. **Client Selection** → `ClientFactory.create()` chooses CLI or API client
338
- 4. **Secret Fetching** → Client reads from 1Password (CLI command or HTTP request)
339
- 5. **Parsing** → Format-specific parser converts to key-value pairs
340
- 6. **Environment Merge** → Secrets merged into `ENV` or provided hash
304
+ 1. **Configuration**: Rails config or direct API calls
305
+ 2. **Path parsing**: `SourceParser.parse()` extracts vault/item/field from path
306
+ 3. **Client selection**: `ClientFactory.create()` chooses CLI or API client
307
+ 4. **Secret fetching**: Client reads from 1Password (CLI command or HTTP request)
308
+ 5. **Parsing**: Format-specific parser converts to key-value pairs
309
+ 6. **Environment merge**: Secrets merged into `ENV` or provided hash
341
310
 
342
311
  All secret data flows through these code paths. No secrets are persisted to disk or logged (except explicit error messages).
343
312
 
@@ -345,11 +314,50 @@ All secret data flows through these code paths. No secrets are persisted to disk
345
314
 
346
315
  ```bash
347
316
  bundle install
348
- bundle exec rspec
317
+ bundle exec polyrun parallel-rspec --workers 5 --merge-failures
349
318
  bundle exec rbs validate
350
319
  bundle exec standardrb --fix
351
320
  ```
352
321
 
322
+ ## Contributing
323
+
324
+ Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/opdotenv.rb.
325
+
326
+ Contribution policy:
327
+ - New features are not necessarily added to the gem
328
+ - Pull request should have test coverage for affected parts
329
+ - Pull request should have changelog entry
330
+
331
+ Review policy:
332
+ - It might take up to 2 calendar weeks to review and merge critical fixes
333
+ - It might take up to 6 calendar months to review and merge pull request
334
+ - It might take up to 1 calendar year to review an issue
335
+
336
+ ## Security
337
+
338
+ If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
339
+
340
+ ## Links
341
+
342
+ - [GitHub](https://github.com/amkisko/opdotenv.rb)
343
+ - [GitLab](https://gitlab.com/amkisko/opdotenv.rb)
344
+ - [RubyGems](https://rubygems.org/gems/opdotenv)
345
+ - [Versions Atom](https://rubygems.org/gems/opdotenv/versions.atom) (feed id `9869611634128`)
346
+ - [libraries.io](https://libraries.io/rubygems/opdotenv)
347
+ - [Deps.dev](https://deps.dev/rubygems/opdotenv)
348
+ - [SonarCloud](https://sonarcloud.io/project/overview?id=amkisko_opdotenv.rb)
349
+ - [Snyk](https://snyk.io/test/github/amkisko/opdotenv.rb)
350
+ - [Codecov](https://app.codecov.io/github/amkisko/opdotenv.rb)
351
+ - [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/amkisko/opdotenv.rb)
352
+
353
353
  ## License
354
354
 
355
- MIT
355
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
356
+
357
+ ## Sponsors
358
+
359
+ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
360
+
361
+ <a href="https://www.kiskolabs.com">
362
+ <img src="kisko.svg" width="200" alt="Sponsored by Kisko Labs" />
363
+ </a>
data/bin/opdotenv CHANGED
@@ -27,7 +27,7 @@ when "export"
27
27
  end.parse!(ARGV)
28
28
  abort("--path and --file required") unless path && file
29
29
  # Infer format from file extension if not provided
30
- field_type ||= infer_format_from_file(file)
30
+ field_type ||= Opdotenv::FormatInferrer.infer_from_name(file) || :dotenv
31
31
  text = File.read(file)
32
32
  data = Opdotenv::Loader.parse_by_format(text, field_type)
33
33
  Opdotenv::Exporter.export(path: path, data: data, field_type: field_type)
@@ -35,11 +35,3 @@ else
35
35
  warn "Usage: opdotenv (read|export) ..."
36
36
  exit 1
37
37
  end
38
-
39
- def infer_format_from_file(file)
40
- case File.extname(file)
41
- when ".json" then :json
42
- when ".yaml", ".yml" then :yaml
43
- else :dotenv
44
- end
45
- end
@@ -79,26 +79,38 @@ module Opdotenv
79
79
  def log_available_fields(original_data, env_prefix, matched_data)
80
80
  return unless ENV["OPDOTENV_DEBUG"] == "true"
81
81
  return unless defined?(Rails) && Rails.logger
82
+ return if original_data.keys.empty?
82
83
 
83
84
  prefix_upper = env_prefix.upcase
84
85
  prefix_with_underscore = "#{prefix_upper}_"
85
-
86
86
  available_fields = original_data.keys.map(&:to_s)
87
87
  matched_fields = matched_data.keys.map(&:to_s)
88
+ unmatched = unmatched_fields(available_fields, prefix_upper, prefix_with_underscore)
89
+
90
+ log_field_summary(available_fields, matched_fields, env_prefix, prefix_with_underscore)
91
+ log_unmatched_fields(unmatched, prefix_with_underscore) if unmatched.any?
92
+ end
88
93
 
89
- # Find fields that were available but didn't match the prefix (case-insensitive)
90
- unmatched = available_fields.reject do |field|
94
+ def unmatched_fields(available_fields, prefix_upper, prefix_with_underscore)
95
+ available_fields.reject do |field|
91
96
  field_upper = field.to_s.upcase
92
97
  field_upper.start_with?(prefix_with_underscore) || field_upper == prefix_upper
93
98
  end
99
+ end
94
100
 
95
- if available_fields.any?
96
- Rails.logger.debug("[opdotenv] Available fields from 1Password: #{available_fields.join(", ")}")
97
- Rails.logger.debug("[opdotenv] Matched fields for #{env_prefix} (prefixed with #{prefix_with_underscore}, case-insensitive): #{matched_fields.join(", ")}")
98
- if unmatched.any?
99
- Rails.logger.debug("[opdotenv] Unmatched fields (must be prefixed with #{prefix_with_underscore}, case-insensitive): #{unmatched.join(", ")}")
100
- Rails.logger.debug("[opdotenv] To use these fields, rename them in 1Password to include the #{prefix_with_underscore} prefix")
101
- end
101
+ def log_field_summary(available_fields, matched_fields, env_prefix, prefix_with_underscore)
102
+ Rails.logger.debug { "[opdotenv] Available fields from 1Password: #{available_fields.join(", ")}" }
103
+ Rails.logger.debug do
104
+ "[opdotenv] Matched fields for #{env_prefix} (prefixed with #{prefix_with_underscore}, case-insensitive): #{matched_fields.join(", ")}"
105
+ end
106
+ end
107
+
108
+ def log_unmatched_fields(unmatched, prefix_with_underscore)
109
+ Rails.logger.debug do
110
+ "[opdotenv] Unmatched fields (must be prefixed with #{prefix_with_underscore}, case-insensitive): #{unmatched.join(", ")}"
111
+ end
112
+ Rails.logger.debug do
113
+ "[opdotenv] To use these fields, rename them in 1Password to include the #{prefix_with_underscore} prefix"
102
114
  end
103
115
  end
104
116
  end
@@ -18,27 +18,19 @@ module Opdotenv
18
18
  @env = env
19
19
  end
20
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
21
  def read(path)
26
22
  parsed = parse_path(path)
27
23
  item = get_item(parsed[:vault], parsed[:item])
28
-
29
24
  if parsed[:field]
30
- # Read specific field
31
25
  field = find_field(item, parsed[:field])
32
26
  field ? field["value"] : ""
33
27
  else
34
- # Read notesPlain for secure notes
35
28
  notes_field = item["fields"]&.find { |f| f["purpose"] == NOTES_PURPOSE || f["label"] == NOTES_PLAIN_FIELD }
36
29
  notes_field ? notes_field["value"] : ""
37
30
  end
38
31
  end
39
32
 
40
33
  def item_get(item_title, vault: nil)
41
- # If vault not provided, search all vaults (requires listing vaults)
42
34
  if vault.nil?
43
35
  item = find_item_in_all_vaults(item_title)
44
36
  raise ConnectApiError, "Item '#{item_title}' not found" unless item
@@ -62,86 +54,61 @@ module Opdotenv
62
54
 
63
55
  def item_create_note(vault:, title:, notes:)
64
56
  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)
57
+ existing = item_by_title_in_vault(vault_id, title)
58
+ response = if existing
59
+ api_request(:patch, "/v1/vaults/#{vault_id}/items/#{existing["id"]}", notes_patch_ops(existing, notes))
60
+ else
61
+ api_request(:post, "/v1/vaults/#{vault_id}/items", {
62
+ "vault" => {"id" => vault_id},
63
+ "title" => title,
64
+ "category" => SECURE_NOTE_CATEGORY,
65
+ "fields" => [{"purpose" => NOTES_PURPOSE, "value" => notes}]
66
+ })
67
+ end
79
68
  JSON.pretty_generate(response)
80
69
  end
81
70
 
82
71
  def item_create_or_update_fields(vault:, item:, fields: {})
83
72
  vault_id = vault_name_to_id(vault)
84
-
85
- # Try to find existing item by title
86
73
  existing = item_by_title_in_vault(vault_id, item)
87
-
88
74
  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)
75
+ api_request(:patch, "/v1/vaults/#{vault_id}/items/#{existing["id"]}", build_patch_fields(existing, fields))
112
76
  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 = {
77
+ api_request(:post, "/v1/vaults/#{vault_id}/items", {
123
78
  "vault" => {"id" => vault_id},
124
79
  "title" => item,
125
80
  "category" => LOGIN_CATEGORY,
126
- "fields" => fields_array
127
- }
128
-
129
- api_request(:post, "/v1/vaults/#{vault_id}/items", payload)
81
+ "fields" => fields.map { |k, v| {"type" => "CONCEALED", "label" => k.to_s, "value" => v.to_s} }
82
+ })
130
83
  end
131
84
  end
132
85
 
133
86
  private
134
87
 
88
+ def notes_patch_ops(existing, notes)
89
+ field = existing["fields"]&.find { |entry| entry["purpose"] == NOTES_PURPOSE || entry["label"] == NOTES_PLAIN_FIELD }
90
+ if field
91
+ [{"op" => "replace", "path" => "/fields/#{field["id"]}/value", "value" => notes}]
92
+ else
93
+ [{"op" => "add", "path" => "/fields", "value" => {"purpose" => NOTES_PURPOSE, "value" => notes}}]
94
+ end
95
+ end
96
+
97
+ def build_patch_fields(existing, fields)
98
+ fields.map do |key, value|
99
+ existing_field = existing["fields"]&.find { |field| field["label"] == key.to_s }
100
+ if existing_field
101
+ {"op" => "replace", "path" => "/fields/#{existing_field["id"]}/value", "value" => value.to_s}
102
+ else
103
+ {"op" => "add", "path" => "/fields", "value" => {"type" => "CONCEALED", "label" => key.to_s, "value" => value.to_s}}
104
+ end
105
+ end
106
+ end
107
+
135
108
  def parse_path(path)
136
- # op://Vault/Item or connect://Vault/Item
137
109
  match = path.match(/\A(?:op|connect):\/\/([^\/]+)\/([^\/]+)(?:\/(.+))?\z/)
138
110
  raise ConnectApiError, "Invalid path format: #{path}" unless match
139
-
140
- {
141
- vault: match[1],
142
- item: match[2],
143
- field: match[3]
144
- }
111
+ {vault: match[1], item: match[2], field: match[3]}
145
112
  end
146
113
 
147
114
  def vault_name_to_id(vault_name)
@@ -164,20 +131,23 @@ module Opdotenv
164
131
  item = item_by_title_in_vault(vault_id, item_title)
165
132
  raise ConnectApiError, "Item '#{item_title}' not found in vault '#{vault_name}'" unless item
166
133
 
167
- # Fetch full item details including fields
168
- api_request(:get, "/v1/vaults/#{vault_id}/items/#{item["id"]}")
134
+ item
169
135
  end
170
136
 
171
137
  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
138
+ filter = URI.encode_www_form_component(%(title eq "#{item_title.to_s.gsub('"', '\\"')}"))
139
+ items = api_request(:get, "/v1/vaults/#{vault_id}/items?filter=#{filter}")
140
+ item = Array(items).find { |entry| entry["title"] == item_title || entry["id"] == item_title }
141
+ return item_by_uuid(vault_id, item_title) if item.nil?
142
+ item["id"] ? api_request(:get, "/v1/vaults/#{vault_id}/items/#{item["id"]}") : item
143
+ end
144
+
145
+ def item_by_uuid(vault_id, item_id)
146
+ return nil unless item_id.to_s.match?(/\A[0-9a-f-]{36}\z/i)
147
+
148
+ api_request(:get, "/v1/vaults/#{vault_id}/items/#{item_id}")
149
+ rescue ConnectApiError
150
+ nil
181
151
  end
182
152
 
183
153
  def find_field(item, field_name)
@@ -199,12 +169,8 @@ module Opdotenv
199
169
  end
200
170
 
201
171
  def build_uri(path)
202
- # Validate path to prevent path traversal attacks
203
- # API paths legitimately start with "/", so only check for ".."
204
172
  raise ConnectApiError, "Invalid path: #{path}" if path.include?("..")
205
173
 
206
- # Use URI.join which handles path normalization safely
207
- # URI.join expects base URL without trailing slash for proper joining
208
174
  base_uri = @base_url.end_with?("/") ? @base_url[0..-2] : @base_url
209
175
  normalized_path = path.start_with?("/") ? path : "/#{path}"
210
176
  URI.join(base_uri, normalized_path)
@@ -241,7 +207,7 @@ module Opdotenv
241
207
  attempts += 1
242
208
  http.request(request)
243
209
  rescue Timeout::Error, Errno::ECONNRESET
244
- raise if attempts >= 2
210
+ raise if attempts >= 2 || !request.is_a?(Net::HTTP::Get)
245
211
  sleep 0.2
246
212
  retry
247
213
  end
@@ -251,9 +217,13 @@ module Opdotenv
251
217
  code = response.code.to_i
252
218
 
253
219
  case code
254
- when 200, 204
255
- return {} if response.body.empty? || code == 204
256
- JSON.parse(response.body)
220
+ when 200..299
221
+ return {} if response.body.to_s.empty? || code == 204
222
+ begin
223
+ JSON.parse(response.body)
224
+ rescue JSON::ParserError
225
+ raise ConnectApiError, "API error (#{code}): Invalid JSON response"
226
+ end
257
227
  when 401
258
228
  raise ConnectApiError, "Unauthorized: Invalid or missing access token"
259
229
  when 403
@@ -263,24 +233,10 @@ module Opdotenv
263
233
  when 500..599
264
234
  raise ConnectApiError, "API error (#{code}): Server error"
265
235
  else
266
- # Extract safe error message without leaking response body
267
- safe_message = extract_safe_error_message(response)
268
- raise ConnectApiError, "API error (#{code}): #{safe_message}"
236
+ raise ConnectApiError, "API error (#{code}): Request failed"
269
237
  end
270
238
  end
271
239
 
272
- def extract_safe_error_message(response)
273
- # Only extract structured error messages from JSON responses
274
- # Never include raw response body to avoid leaking secrets
275
-
276
- parsed = JSON.parse(response.body)
277
- # Only return known safe fields that are typically error messages
278
- parsed["message"] || parsed["error"] || "Request failed"
279
- rescue JSON::ParserError
280
- # For non-JSON responses, return generic message to avoid leaking body
281
- "Request failed"
282
- end
283
-
284
240
  def validate_url(url)
285
241
  uri = URI.parse(url)
286
242
  unless ["http", "https"].include?(uri.scheme)
@@ -18,7 +18,6 @@ module Opdotenv
18
18
 
19
19
  # Check if path matches format patterns (Secure Note) or regular item (fields)
20
20
  if FormatInferrer.matches_format_pattern?(item_name)
21
- # Create Secure Note
22
21
  field_type ||= FormatInferrer.infer_from_name(item_name) || :dotenv
23
22
  content = serialize_by_format(data, field_type)
24
23
  client.item_create_note(vault: vault, title: item_name, notes: content)
@@ -3,18 +3,25 @@ module Opdotenv
3
3
  module FormatInferrer
4
4
  module_function
5
5
 
6
- DOTENV_PATTERN = /\.env\.?/
7
6
  JSON_EXTENSIONS = [".json"].freeze
8
7
  YAML_EXTENSIONS = [".yaml", ".yml"].freeze
9
8
 
10
9
  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) }
10
+ base = File.basename(name.to_s)
11
+ return :dotenv if dotenv_basename?(base)
12
+ return :json if JSON_EXTENSIONS.any? { |ext| base.end_with?(ext) }
13
+ return :yaml if YAML_EXTENSIONS.any? { |ext| base.end_with?(ext) }
14
14
 
15
15
  nil
16
16
  end
17
17
 
18
+ def dotenv_basename?(base)
19
+ base == ".env" ||
20
+ base.start_with?(".env.") ||
21
+ base.include?(" .env.") ||
22
+ base.end_with?(" .env")
23
+ end
24
+
18
25
  def matches_format_pattern?(name)
19
26
  !infer_from_name(name).nil?
20
27
  end
@@ -25,24 +25,25 @@ module Opdotenv
25
25
  raw_json = client.item_get(item, vault: vault)
26
26
  item_hash = parse_json_safe(raw_json)
27
27
 
28
- # Only process fields from the 1Password item, filter out empty values
29
28
  item_hash["fields"]&.each_with_object({}) do |field, env_data|
30
- # Only include actual fields from the item
31
- next unless field.is_a?(Hash)
29
+ entry = field_env_entry(field)
30
+ next unless entry
32
31
 
33
- # Strip whitespace from label and treat empty strings as nil
34
- label = (field["label"] || field["id"])&.strip
35
- next if label.nil? || label.empty?
32
+ env_data[entry[:key]] = entry[:value]
33
+ end || {}
34
+ end
36
35
 
37
- # Skip notesPlain when fetching all fields
38
- next if field["purpose"] == NOTES_PURPOSE
36
+ def self.field_env_entry(field)
37
+ return nil unless field.is_a?(Hash)
39
38
 
40
- # Get the value and filter out empty values
41
- value = field["value"]
42
- next if value.nil? || (value.is_a?(String) && value.strip.empty?)
39
+ label = (field["label"] || field["id"])&.strip
40
+ return nil if label.nil? || label.empty?
41
+ return nil if field["purpose"] == NOTES_PURPOSE
43
42
 
44
- env_data[label.to_s] = value.to_s
45
- end || {}
43
+ value = field["value"]
44
+ return nil if value.nil? || (value.is_a?(String) && value.strip.empty?)
45
+
46
+ {key: label.to_s, value: value.to_s}
46
47
  end
47
48
 
48
49
  def self.build_field_path(path, field_name)
@@ -27,33 +27,48 @@ module Opdotenv
27
27
  end
28
28
 
29
29
  def item_create_note(vault:, title:, notes:)
30
- # Create a Secure Note with given title and notesPlain
31
- # Use shell escaping to prevent injection
32
- args = [
33
- @cli_path, "item", "create",
34
- "--category", SECURE_NOTE_CATEGORY,
35
- "--title", title,
36
- "--vault", vault,
37
- "#{NOTES_PLAIN_FIELD}=#{notes}"
38
- ]
39
- capture(args)
30
+ notes_field = {"id" => NOTES_PLAIN_FIELD, "type" => "STRING", "purpose" => "NOTES", "value" => notes}
31
+ if item_exists?(title, vault: vault)
32
+ payload = JSON.parse(item_get(title, vault: vault))
33
+ existing = payload["fields"]&.find { |field|
34
+ field["id"] == NOTES_PLAIN_FIELD || field["purpose"] == "NOTES" || field["label"] == NOTES_PLAIN_FIELD
35
+ }
36
+ if existing
37
+ existing["value"] = notes
38
+ else
39
+ payload["fields"] ||= []
40
+ payload["fields"] << notes_field
41
+ end
42
+ capture([@cli_path, "item", "edit", title, "--vault", vault], stdin_data: JSON.generate(payload))
43
+ else
44
+ args = [
45
+ @cli_path, "item", "create",
46
+ "--category", SECURE_NOTE_CATEGORY,
47
+ "--title", title,
48
+ "--vault", vault,
49
+ "-"
50
+ ]
51
+ capture(args, stdin_data: JSON.generate({"fields" => [notes_field]}))
52
+ end
40
53
  end
41
54
 
42
55
  def item_create_or_update_fields(vault:, item:, fields: {})
43
56
  exists = item_exists?(item, vault: vault)
44
- if exists
45
- fields.each do |k, v|
46
- # Use shell escaping to prevent injection
47
- field_arg = "#{k}=#{v}"
48
- capture([@cli_path, "item", "edit", item, "--vault", vault, "--set", field_arg])
57
+ payload = exists ? JSON.parse(item_get(item, vault: vault)) : {"category" => LOGIN_CATEGORY, "fields" => []}
58
+ fields.each do |key, value|
59
+ field = payload["fields"].find { |entry| entry["label"] == key.to_s || entry["id"] == key.to_s }
60
+ if field
61
+ field["value"] = value.to_s
62
+ else
63
+ payload["fields"] << {"type" => "CONCEALED", "label" => key.to_s, "value" => value.to_s}
49
64
  end
65
+ end
66
+ args = if exists
67
+ [@cli_path, "item", "edit", item, "--vault", vault]
50
68
  else
51
- args = [@cli_path, "item", "create", "--title", item, "--vault", vault]
52
- fields.each do |k, v|
53
- args += ["--set", "#{k}=#{v}"]
54
- end
55
- capture(args)
69
+ [@cli_path, "item", "create", "--title", item, "--vault", vault, "-"]
56
70
  end
71
+ capture(args, stdin_data: JSON.generate(payload))
57
72
  end
58
73
 
59
74
  private
@@ -70,10 +85,10 @@ module Opdotenv
70
85
  raise ArgumentError, "Invalid path format: #{path.inspect}. Must start with 'op://'"
71
86
  end
72
87
 
73
- def capture(args)
88
+ def capture(args, **options)
74
89
  # Use exec-style array to prevent shell injection
75
90
  # Open3.capture2e captures both stdout and stderr, and properly returns exit status
76
- out, status = Open3.capture2e(*args)
91
+ out, status = Open3.capture2e(*args, **options)
77
92
 
78
93
  # For JSON output, try to parse even if exit code is non-zero
79
94
  # Some op commands may return non-zero but still output valid JSON
@@ -2,26 +2,33 @@ module Opdotenv
2
2
  module Parsers
3
3
  class DotenvParser
4
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
5
+ text.to_s.each_line.with_object({}) do |line, env|
6
+ entry = parse_line(line)
7
+ next unless entry
8
+
9
+ env[entry[:key]] = entry[:value]
10
+ end
11
+ end
12
+
13
+ def self.parse_line(line)
14
+ line = line.strip
15
+ return nil if line.empty? || line.start_with?("#")
16
+
17
+ line = line.sub(/^export\s+/, "")
18
+ match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\z/)
19
+ return nil unless match
20
+
21
+ {key: match[1], value: unquote_value(match[2])}
22
+ end
23
+
24
+ def self.unquote_value(raw)
25
+ if raw.start_with?("\"") && raw.end_with?("\"")
26
+ raw[1..-2].gsub('\\"', '"')
27
+ elsif raw.start_with?("'") && raw.end_with?("'")
28
+ raw[1..-2]
29
+ else
30
+ raw
23
31
  end
24
- env
25
32
  end
26
33
  end
27
34
  end
@@ -19,18 +19,17 @@ module Opdotenv
19
19
 
20
20
  path = source.to_s
21
21
  _, item = parse_op_path(path)
22
-
23
- # Extract item name and potential field name
24
- # Handle paths like "op://Vault/Item" or "op://Vault/Item Name/field"
25
22
  item_parts = item.split("/")
26
23
  item_name = item_parts.first
27
24
  field_name = (item_parts.length > 1) ? item_parts[1] : nil
28
25
 
29
- # First check if field name is provided with extension in the path
26
+ build_parsed_source(path, item_name, field_name)
27
+ end
28
+
29
+ def build_parsed_source(path, item_name, field_name)
30
30
  if field_name
31
31
  field_type = FormatInferrer.infer_from_name(field_name)
32
32
  {path: path, field_name: field_name, field_type: field_type}
33
- # If no field name in path, infer from item name patterns
34
33
  elsif (field_type = FormatInferrer.infer_from_name(item_name))
35
34
  {path: path, field_name: NOTES_PLAIN_FIELD, field_type: field_type}
36
35
  else
@@ -66,6 +65,6 @@ module Opdotenv
66
65
  }
67
66
  end
68
67
 
69
- module_function :normalize_hash
68
+ module_function :normalize_hash, :build_parsed_source
70
69
  end
71
70
  end
@@ -1,3 +1,3 @@
1
1
  module Opdotenv
2
- VERSION = "1.0.3"
2
+ VERSION = "1.0.6"
3
3
  end
data/lib/opdotenv.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  require "json"
2
2
  require "yaml"
3
- require "shellwords"
4
3
 
5
4
  require_relative "opdotenv/version"
6
5
  require_relative "opdotenv/format_inferrer"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opdotenv
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -15,17 +15,17 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '7.0'
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '9.0'
22
- type: :runtime
22
+ type: :development
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: '6.0'
28
+ version: '7.0'
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9.0'
@@ -58,75 +58,131 @@ dependencies:
58
58
  - !ruby/object:Gem::Version
59
59
  version: '3'
60
60
  - !ruby/object:Gem::Dependency
61
- name: simplecov
61
+ name: polyrun
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 2.2.0
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 2.2.0
74
+ - !ruby/object:Gem::Dependency
75
+ name: standard
62
76
  requirement: !ruby/object:Gem::Requirement
63
77
  requirements:
64
78
  - - "~>"
65
79
  - !ruby/object:Gem::Version
66
- version: '0.22'
80
+ version: '1.52'
67
81
  type: :development
68
82
  prerelease: false
69
83
  version_requirements: !ruby/object:Gem::Requirement
70
84
  requirements:
71
85
  - - "~>"
72
86
  - !ruby/object:Gem::Version
73
- version: '0.22'
87
+ version: '1.52'
74
88
  - !ruby/object:Gem::Dependency
75
- name: rspec_junit_formatter
89
+ name: standard-custom
76
90
  requirement: !ruby/object:Gem::Requirement
77
91
  requirements:
78
92
  - - "~>"
79
93
  - !ruby/object:Gem::Version
80
- version: '0.6'
94
+ version: '1.0'
81
95
  type: :development
82
96
  prerelease: false
83
97
  version_requirements: !ruby/object:Gem::Requirement
84
98
  requirements:
85
99
  - - "~>"
86
100
  - !ruby/object:Gem::Version
87
- version: '0.6'
101
+ version: '1.0'
88
102
  - !ruby/object:Gem::Dependency
89
- name: simplecov-cobertura
103
+ name: standard-performance
90
104
  requirement: !ruby/object:Gem::Requirement
91
105
  requirements:
92
106
  - - "~>"
93
107
  - !ruby/object:Gem::Version
94
- version: '3'
108
+ version: '1.8'
95
109
  type: :development
96
110
  prerelease: false
97
111
  version_requirements: !ruby/object:Gem::Requirement
98
112
  requirements:
99
113
  - - "~>"
100
114
  - !ruby/object:Gem::Version
101
- version: '3'
115
+ version: '1.8'
102
116
  - !ruby/object:Gem::Dependency
103
- name: standard
117
+ name: standard-rails
104
118
  requirement: !ruby/object:Gem::Requirement
105
119
  requirements:
106
120
  - - "~>"
107
121
  - !ruby/object:Gem::Version
108
- version: '1'
122
+ version: '1.5'
109
123
  type: :development
110
124
  prerelease: false
111
125
  version_requirements: !ruby/object:Gem::Requirement
112
126
  requirements:
113
127
  - - "~>"
114
128
  - !ruby/object:Gem::Version
115
- version: '1'
129
+ version: '1.5'
116
130
  - !ruby/object:Gem::Dependency
117
- name: standard-performance
131
+ name: standard-rspec
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '0.3'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '0.3'
144
+ - !ruby/object:Gem::Dependency
145
+ name: rubocop-rails
146
+ requirement: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: '2.33'
151
+ type: :development
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: '2.33'
158
+ - !ruby/object:Gem::Dependency
159
+ name: rubocop-rspec
160
+ requirement: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - "~>"
163
+ - !ruby/object:Gem::Version
164
+ version: '3.8'
165
+ type: :development
166
+ prerelease: false
167
+ version_requirements: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - "~>"
170
+ - !ruby/object:Gem::Version
171
+ version: '3.8'
172
+ - !ruby/object:Gem::Dependency
173
+ name: rubocop-thread_safety
118
174
  requirement: !ruby/object:Gem::Requirement
119
175
  requirements:
120
176
  - - "~>"
121
177
  - !ruby/object:Gem::Version
122
- version: '1'
178
+ version: '0.7'
123
179
  type: :development
124
180
  prerelease: false
125
181
  version_requirements: !ruby/object:Gem::Requirement
126
182
  requirements:
127
183
  - - "~>"
128
184
  - !ruby/object:Gem::Version
129
- version: '1'
185
+ version: '0.7'
130
186
  - !ruby/object:Gem::Dependency
131
187
  name: appraisal
132
188
  requirement: !ruby/object:Gem::Requirement
@@ -239,14 +295,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
239
295
  requirements:
240
296
  - - ">="
241
297
  - !ruby/object:Gem::Version
242
- version: '2.7'
298
+ version: '3.4'
243
299
  required_rubygems_version: !ruby/object:Gem::Requirement
244
300
  requirements:
245
301
  - - ">="
246
302
  - !ruby/object:Gem::Version
247
303
  version: '0'
248
304
  requirements: []
249
- rubygems_version: 3.6.9
305
+ rubygems_version: 4.0.6
250
306
  specification_version: 4
251
307
  summary: Load and export environment variables with 1Password CLI or Connect API
252
308
  test_files: []