opdotenv 1.0.5 → 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: 681b84ce04a08dea4ef5bb4645ecf47071922f63288f5d6b26fcc558d2b181fd
4
- data.tar.gz: d6706fb654448e9d4efa404d373d54d5f9ef5b5f5553a73c5335c28b6a7f9508
3
+ metadata.gz: 9bbc6ae3316e89e48c9c52e3f3aa33f3a3572217eab08b88ddc98283989b7f04
4
+ data.tar.gz: e964c986cd6676f4a0f3d0c3c33f633b520c9f277b6030f79a407f0db3cd8fdd
5
5
  SHA512:
6
- metadata.gz: 43741125e5322870b2f11d57d6b7c2a1d6ea695d69209b93938a546b5ac44f785064b21cdaf2ca6ead5a232b5e36d6d0035f90a4fa9e2f668af0b53aa1c34f8d
7
- data.tar.gz: 0b1855b8e5804b98dd8bf4936a218a69bfb415073e38b78f25102744b979674f9afda9baec9570c4dbf67deeee46e226be78073ab9a51105f9e989e9a2f8a522
6
+ metadata.gz: e1c8b865089d2a6a2463f7ebc5edffd60566a826fb9667c792ef02c61c113a309a892adf2fbb5ade5115868fcc52b58919a15b79ecb3e84d3017531a6aebb678
7
+ data.tar.gz: 2e308264ed8c9aadbd846daef1c9804f98f8571cffd902be3fece3613b586d5240edffa4d0d2dffa2008573a3280e6994f5089635628a4bc1a4a6af1d70be2ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 1.0.5 (2026-07-06)
4
15
 
5
16
  - Improve `OPDOTENV_DEBUG` field summary logging in Anyway Config integration to build messages only when Rails debug logging is active
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) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=amkisko_opdotenv.rb&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=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
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
243
229
 
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
248
-
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.**
234
+ ### Environment recommendation
256
235
 
257
- For production environments, we recommend using dedicated secret management solutions that integrate with your infrastructure:
236
+ Use this gem in development and test. For production, prefer the secret store that matches the host:
258
237
 
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)
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)
263
242
 
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
243
+ Provision through infrastructure tools such as Helm, Terraform, or Bicep when that matches the host.
268
244
 
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
245
+ ### Security considerations
275
246
 
276
- ### Security Considerations
277
-
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,7 +314,7 @@ 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
  ```
@@ -368,6 +337,27 @@ Review policy:
368
337
 
369
338
  If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
370
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
+
371
353
  ## License
372
354
 
373
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
@@ -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,88 +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
- fields_array = build_patch_fields(existing, fields)
90
- 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))
91
76
  else
92
- # Create new item
93
- fields_array = fields.map do |k, v|
94
- {
95
- "type" => "CONCEALED",
96
- "label" => k.to_s,
97
- "value" => v.to_s
98
- }
99
- end
100
-
101
- payload = {
77
+ api_request(:post, "/v1/vaults/#{vault_id}/items", {
102
78
  "vault" => {"id" => vault_id},
103
79
  "title" => item,
104
80
  "category" => LOGIN_CATEGORY,
105
- "fields" => fields_array
106
- }
107
-
108
- 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
+ })
109
83
  end
110
84
  end
111
85
 
112
86
  private
113
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
+
114
97
  def build_patch_fields(existing, fields)
115
98
  fields.map do |key, value|
116
99
  existing_field = existing["fields"]&.find { |field| field["label"] == key.to_s }
117
100
  if existing_field
118
- {
119
- "op" => "replace",
120
- "path" => "/fields/#{existing_field["id"]}/value",
121
- "value" => value.to_s
122
- }
101
+ {"op" => "replace", "path" => "/fields/#{existing_field["id"]}/value", "value" => value.to_s}
123
102
  else
124
- {
125
- "op" => "add",
126
- "path" => "/fields",
127
- "value" => {
128
- "type" => "CONCEALED",
129
- "label" => key.to_s,
130
- "value" => value.to_s
131
- }
132
- }
103
+ {"op" => "add", "path" => "/fields", "value" => {"type" => "CONCEALED", "label" => key.to_s, "value" => value.to_s}}
133
104
  end
134
105
  end
135
106
  end
136
107
 
137
108
  def parse_path(path)
138
- # op://Vault/Item or connect://Vault/Item
139
109
  match = path.match(/\A(?:op|connect):\/\/([^\/]+)\/([^\/]+)(?:\/(.+))?\z/)
140
110
  raise ConnectApiError, "Invalid path format: #{path}" unless match
141
-
142
- {
143
- vault: match[1],
144
- item: match[2],
145
- field: match[3]
146
- }
111
+ {vault: match[1], item: match[2], field: match[3]}
147
112
  end
148
113
 
149
114
  def vault_name_to_id(vault_name)
@@ -166,20 +131,23 @@ module Opdotenv
166
131
  item = item_by_title_in_vault(vault_id, item_title)
167
132
  raise ConnectApiError, "Item '#{item_title}' not found in vault '#{vault_name}'" unless item
168
133
 
169
- # Fetch full item details including fields
170
- api_request(:get, "/v1/vaults/#{vault_id}/items/#{item["id"]}")
134
+ item
171
135
  end
172
136
 
173
137
  def item_by_title_in_vault(vault_id, item_title)
174
- # List items and find by title
175
- items = api_request(:get, "/v1/vaults/#{vault_id}/items")
176
- item = items.find { |i| i["title"] == item_title || i["id"] == item_title }
177
- # If found by listing, fetch full details to get fields
178
- if item && item["id"]
179
- api_request(:get, "/v1/vaults/#{vault_id}/items/#{item["id"]}")
180
- else
181
- item
182
- 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
183
151
  end
184
152
 
185
153
  def find_field(item, field_name)
@@ -201,12 +169,8 @@ module Opdotenv
201
169
  end
202
170
 
203
171
  def build_uri(path)
204
- # Validate path to prevent path traversal attacks
205
- # API paths legitimately start with "/", so only check for ".."
206
172
  raise ConnectApiError, "Invalid path: #{path}" if path.include?("..")
207
173
 
208
- # Use URI.join which handles path normalization safely
209
- # URI.join expects base URL without trailing slash for proper joining
210
174
  base_uri = @base_url.end_with?("/") ? @base_url[0..-2] : @base_url
211
175
  normalized_path = path.start_with?("/") ? path : "/#{path}"
212
176
  URI.join(base_uri, normalized_path)
@@ -243,7 +207,7 @@ module Opdotenv
243
207
  attempts += 1
244
208
  http.request(request)
245
209
  rescue Timeout::Error, Errno::ECONNRESET
246
- raise if attempts >= 2
210
+ raise if attempts >= 2 || !request.is_a?(Net::HTTP::Get)
247
211
  sleep 0.2
248
212
  retry
249
213
  end
@@ -253,9 +217,13 @@ module Opdotenv
253
217
  code = response.code.to_i
254
218
 
255
219
  case code
256
- when 200, 204
257
- return {} if response.body.empty? || code == 204
258
- 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
259
227
  when 401
260
228
  raise ConnectApiError, "Unauthorized: Invalid or missing access token"
261
229
  when 403
@@ -265,24 +233,10 @@ module Opdotenv
265
233
  when 500..599
266
234
  raise ConnectApiError, "API error (#{code}): Server error"
267
235
  else
268
- # Extract safe error message without leaking response body
269
- safe_message = extract_safe_error_message(response)
270
- raise ConnectApiError, "API error (#{code}): #{safe_message}"
236
+ raise ConnectApiError, "API error (#{code}): Request failed"
271
237
  end
272
238
  end
273
239
 
274
- def extract_safe_error_message(response)
275
- # Only extract structured error messages from JSON responses
276
- # Never include raw response body to avoid leaking secrets
277
-
278
- parsed = JSON.parse(response.body)
279
- # Only return known safe fields that are typically error messages
280
- parsed["message"] || parsed["error"] || "Request failed"
281
- rescue JSON::ParserError
282
- # For non-JSON responses, return generic message to avoid leaking body
283
- "Request failed"
284
- end
285
-
286
240
  def validate_url(url)
287
241
  uri = URI.parse(url)
288
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Opdotenv
2
- VERSION = "1.0.5"
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.5
4
+ version: 1.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -15,7 +15,7 @@ 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'
@@ -25,7 +25,7 @@ dependencies:
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'
@@ -61,58 +61,16 @@ dependencies:
61
61
  name: polyrun
62
62
  requirement: !ruby/object:Gem::Requirement
63
63
  requirements:
64
- - - "~>"
65
- - !ruby/object:Gem::Version
66
- version: 1.5.0
67
- type: :development
68
- prerelease: false
69
- version_requirements: !ruby/object:Gem::Requirement
70
- requirements:
71
- - - "~>"
72
- - !ruby/object:Gem::Version
73
- version: 1.5.0
74
- - !ruby/object:Gem::Dependency
75
- name: simplecov
76
- requirement: !ruby/object:Gem::Requirement
77
- requirements:
78
- - - "~>"
79
- - !ruby/object:Gem::Version
80
- version: '0.22'
81
- type: :development
82
- prerelease: false
83
- version_requirements: !ruby/object:Gem::Requirement
84
- requirements:
85
- - - "~>"
86
- - !ruby/object:Gem::Version
87
- version: '0.22'
88
- - !ruby/object:Gem::Dependency
89
- name: rspec_junit_formatter
90
- requirement: !ruby/object:Gem::Requirement
91
- requirements:
92
- - - "~>"
93
- - !ruby/object:Gem::Version
94
- version: '0.6'
95
- type: :development
96
- prerelease: false
97
- version_requirements: !ruby/object:Gem::Requirement
98
- requirements:
99
- - - "~>"
100
- - !ruby/object:Gem::Version
101
- version: '0.6'
102
- - !ruby/object:Gem::Dependency
103
- name: simplecov-cobertura
104
- requirement: !ruby/object:Gem::Requirement
105
- requirements:
106
- - - "~>"
64
+ - - ">="
107
65
  - !ruby/object:Gem::Version
108
- version: '3'
66
+ version: 2.2.0
109
67
  type: :development
110
68
  prerelease: false
111
69
  version_requirements: !ruby/object:Gem::Requirement
112
70
  requirements:
113
- - - "~>"
71
+ - - ">="
114
72
  - !ruby/object:Gem::Version
115
- version: '3'
73
+ version: 2.2.0
116
74
  - !ruby/object:Gem::Dependency
117
75
  name: standard
118
76
  requirement: !ruby/object:Gem::Requirement
@@ -337,14 +295,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
337
295
  requirements:
338
296
  - - ">="
339
297
  - !ruby/object:Gem::Version
340
- version: '2.7'
298
+ version: '3.4'
341
299
  required_rubygems_version: !ruby/object:Gem::Requirement
342
300
  requirements:
343
301
  - - ">="
344
302
  - !ruby/object:Gem::Version
345
303
  version: '0'
346
304
  requirements: []
347
- rubygems_version: 3.6.9
305
+ rubygems_version: 4.0.6
348
306
  specification_version: 4
349
307
  summary: Load and export environment variables with 1Password CLI or Connect API
350
308
  test_files: []