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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +57 -67
- data/bin/opdotenv +1 -9
- data/lib/opdotenv/connect_api_client.rb +50 -96
- data/lib/opdotenv/exporter.rb +0 -1
- data/lib/opdotenv/format_inferrer.rb +11 -4
- data/lib/opdotenv/op_client.rb +37 -22
- data/lib/opdotenv/version.rb +1 -1
- data/lib/opdotenv.rb +0 -1
- metadata +9 -51
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bbc6ae3316e89e48c9c52e3f3aa33f3a3572217eab08b88ddc98283989b7f04
|
|
4
|
+
data.tar.gz: e964c986cd6676f4a0f3d0c3c33f633b520c9f277b6030f79a407f0db3cd8fdd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://badge.fury.io/rb/opdotenv) [](https://github.com/amkisko/opdotenv.rb/actions/workflows/test.yml) [](https://app.codecov.io/github/amkisko/opdotenv.rb) [](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
|
|
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
|
|
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
|
|
123
|
+
### Conditional loading
|
|
139
124
|
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
- `.env
|
|
240
|
-
- `*.json
|
|
241
|
-
- `*.yaml` or `*.yml
|
|
242
|
-
- Other items
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
**⚠️ This gem is recommended for development and test environments only.**
|
|
234
|
+
### Environment recommendation
|
|
256
235
|
|
|
257
|
-
For production
|
|
236
|
+
Use this gem in development and test. For production, prefer the secret store that matches the host:
|
|
258
237
|
|
|
259
|
-
- **Kubernetes**:
|
|
260
|
-
- **AWS**:
|
|
261
|
-
- **Azure**:
|
|
262
|
-
- **GCP**:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
-
|
|
279
|
-
-
|
|
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)` -
|
|
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)` -
|
|
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
|
|
302
|
+
#### Data flow
|
|
334
303
|
|
|
335
|
-
1. **Configuration
|
|
336
|
-
2. **Path
|
|
337
|
-
3. **Client
|
|
338
|
-
4. **Secret
|
|
339
|
-
5. **Parsing
|
|
340
|
-
6. **Environment
|
|
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 ||=
|
|
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
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" =>
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
items = api_request(:get, "/v1/vaults/#{vault_id}/items")
|
|
176
|
-
item = items.find { |
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
257
|
-
return {} if response.body.empty? || code == 204
|
|
258
|
-
|
|
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
|
-
|
|
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)
|
data/lib/opdotenv/exporter.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
return :
|
|
13
|
-
return :
|
|
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
|
data/lib/opdotenv/op_client.rb
CHANGED
|
@@ -27,33 +27,48 @@ module Opdotenv
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def item_create_note(vault:, title:, notes:)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
data/lib/opdotenv/version.rb
CHANGED
data/lib/opdotenv.rb
CHANGED
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.
|
|
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: '
|
|
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: '
|
|
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:
|
|
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:
|
|
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: '
|
|
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:
|
|
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: []
|