opdotenv 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.md +23 -0
- data/README.md +333 -0
- data/bin/opdotenv +44 -0
- data/lib/opdotenv/anyway_loader.rb +125 -0
- data/lib/opdotenv/client_factory.rb +17 -0
- data/lib/opdotenv/connect_api_client.rb +290 -0
- data/lib/opdotenv/exporter.rb +59 -0
- data/lib/opdotenv/format_inferrer.rb +22 -0
- data/lib/opdotenv/loader.rb +72 -0
- data/lib/opdotenv/op_client.rb +94 -0
- data/lib/opdotenv/parsers/dotenv_parser.rb +28 -0
- data/lib/opdotenv/parsers/json_parser.rb +28 -0
- data/lib/opdotenv/parsers/yaml_parser.rb +15 -0
- data/lib/opdotenv/railtie.rb +55 -0
- data/lib/opdotenv/source_parser.rb +71 -0
- data/lib/opdotenv/version.rb +3 -0
- data/lib/opdotenv.rb +19 -0
- metadata +224 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 484498d0c2471d83e66fd42819feef80da6432e79c2166ceb15b1ece037b2046
|
|
4
|
+
data.tar.gz: fce7dbd63416abb37af84c4bdf78792d1172fa1bea972d467f7a871ca1d58e12
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 319700ee96600edbb66bc90db27dadb07cfe269cc0f10570b582ae349f8f6234ff29a5950590a57763450c48a95ab1fcd3925bf0d929a1604d2e15a5cc335b11
|
|
7
|
+
data.tar.gz: 351b895887435dabeb2dec23884d741f192a16aa3c26be083490ff9a7be8189b06d03d8c8f0e7e3373a875d3d8edda5aabc620e5b2a157f05390293fb5cfbaf9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## 1.0.0 (2025-11-04)
|
|
4
|
+
|
|
5
|
+
- Initial stable release
|
|
6
|
+
- Load environment variables from 1Password using op CLI or Connect API
|
|
7
|
+
- Support for dotenv, JSON, and YAML formats
|
|
8
|
+
- Automatic format inference from file extensions
|
|
9
|
+
- Rails integration with declarative configuration
|
|
10
|
+
- Anyway Config integration with strict prefix matching
|
|
11
|
+
- Export data back to 1Password
|
|
12
|
+
- CLI tool for read/export operations
|
|
13
|
+
- Support for field names in paths (e.g., op://Vault/Item/config.json)
|
|
14
|
+
- Security improvements (input validation, safe YAML parsing)
|
|
15
|
+
- Comprehensive documentation with security guidelines
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# opdotenv
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/opdotenv) [](https://github.com/amkisko/opdotenv/actions/workflows/ci.yml) [](https://codecov.io/gh/amkisko/opdotenv/graph/badge.svg?token=YOUR_TOKEN)
|
|
4
|
+
|
|
5
|
+
Load environment variables from 1Password using the `op` CLI or 1Password Connect Server API. Supports dotenv, JSON, and YAML formats.
|
|
6
|
+
|
|
7
|
+
Sponsored by [Kisko Labs](https://www.kiskolabs.com).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "opdotenv"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
Choose one:
|
|
20
|
+
- **1Password CLI** (`op`) - must be installed and authenticated (`op signin`)
|
|
21
|
+
- **1Password Connect Server** - set `OP_CONNECT_URL` and `OP_CONNECT_TOKEN` environment variables
|
|
22
|
+
|
|
23
|
+
Ruby 2.7+ supported.
|
|
24
|
+
|
|
25
|
+
## Rails
|
|
26
|
+
|
|
27
|
+
Configure in `config/application.rb` or environment-specific files:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
Rails.application.configure do
|
|
31
|
+
config.opdotenv.sources = [
|
|
32
|
+
"op://Vault/.env.development", # dotenv format (inferred)
|
|
33
|
+
"op://Vault/config.json", # json format (inferred from .json extension)
|
|
34
|
+
"op://Vault/App" # all fields without parsing
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Format is automatically inferred from item name or field name:
|
|
40
|
+
- `.env.*` → dotenv format
|
|
41
|
+
- `*.json` → JSON format
|
|
42
|
+
- `*.yaml` or `*.yml` → YAML format
|
|
43
|
+
- Other items → load all fields without parsing
|
|
44
|
+
|
|
45
|
+
You can also specify the field name with extension in the path:
|
|
46
|
+
- `op://Vault/Item Name/config.json` → uses field `config.json` as JSON
|
|
47
|
+
- `op://Vault/Item Name/production.json` → uses field `production.json` as JSON
|
|
48
|
+
- `op://Vault/Item Name/.env.development` → uses field `.env.development` as dotenv
|
|
49
|
+
|
|
50
|
+
### 1Password Connect
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
Rails.application.configure do
|
|
54
|
+
config.opdotenv.connect_url = "https://connect.example.com"
|
|
55
|
+
config.opdotenv.connect_token = Rails.application.credentials.dig(:op_connect, :token)
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Disable automatic loading
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Rails.application.configure do
|
|
63
|
+
config.opdotenv.auto_load = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Load manually when needed
|
|
67
|
+
Opdotenv::Loader.load("op://Vault/Item")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Standalone usage
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
require "opdotenv"
|
|
74
|
+
|
|
75
|
+
# Load from dotenv format (format inferred from item name)
|
|
76
|
+
Opdotenv::Loader.load("op://Vault/.env.development")
|
|
77
|
+
|
|
78
|
+
# Load from JSON format (any item name ending with .json)
|
|
79
|
+
Opdotenv::Loader.load("op://Vault/config.json")
|
|
80
|
+
Opdotenv::Loader.load("op://Vault/production.json")
|
|
81
|
+
|
|
82
|
+
# Load from field with extension in path
|
|
83
|
+
Opdotenv::Loader.load("op://Vault/Item Name/config.json")
|
|
84
|
+
|
|
85
|
+
# Load all fields from an item
|
|
86
|
+
Opdotenv::Loader.load("op://Vault/App")
|
|
87
|
+
|
|
88
|
+
# Don't overwrite existing ENV values
|
|
89
|
+
Opdotenv::Loader.load("op://Vault/Item", overwrite: false)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Anyway Config integration
|
|
93
|
+
|
|
94
|
+
Automatically registers when `anyway_config` is available:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class AppConfig < Anyway::Config
|
|
98
|
+
attr_config :api_key, :api_secret
|
|
99
|
+
|
|
100
|
+
# Format is inferred from item name
|
|
101
|
+
loader_options opdotenv: {
|
|
102
|
+
path: "op://Vault/.env.development" # dotenv format inferred
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Or load all fields from an item
|
|
107
|
+
class DatabaseConfig < Anyway::Config
|
|
108
|
+
attr_config :url, :username, :password
|
|
109
|
+
|
|
110
|
+
loader_options opdotenv: {
|
|
111
|
+
path: "op://Vault/Database" # all fields loaded
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Conditional Loading (Recommended for Security)
|
|
117
|
+
|
|
118
|
+
For better security, only load from 1Password in development/test environments:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
class TestConfig < Anyway::Config
|
|
122
|
+
config_name :test
|
|
123
|
+
attr_config :enabled, :sample
|
|
124
|
+
|
|
125
|
+
# Only load from 1Password in local/development environments
|
|
126
|
+
if Rails.env.local?
|
|
127
|
+
loader_options opdotenv: {
|
|
128
|
+
path: "op://Employee/.env.test"
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This ensures that production environments won't attempt to load secrets from 1Password, aligning with the [production recommendations](#environment-recommendation).
|
|
135
|
+
|
|
136
|
+
### Loading All Fields from an Item
|
|
137
|
+
|
|
138
|
+
When loading all fields from a 1Password item (not a parsed format), field names are automatically normalized to match the `env_prefix`:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class TestConfig < Anyway::Config
|
|
142
|
+
config_name :test
|
|
143
|
+
attr_config :enabled, :sample
|
|
144
|
+
|
|
145
|
+
loader_options opdotenv: {
|
|
146
|
+
path: "op://Employee/TestConfig" # Loads all fields from item
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Field name matching (strict with case-insensitive prefix):**
|
|
152
|
+
- Fields in 1Password **must** be prefixed with the `env_prefix` (e.g., `TEST_` for `config_name :test`)
|
|
153
|
+
- Matching is **case-insensitive**: `TEST_ENABLED`, `test_enabled`, `Test_Enabled` all work
|
|
154
|
+
- After prefix stripping, `TEST_ENABLED` becomes `enabled` (matching `attr_config :enabled`)
|
|
155
|
+
- Fields without the prefix (e.g., `enabled`, `ENABLED`) are ignored and logged as unmatched
|
|
156
|
+
|
|
157
|
+
**Debugging field matching:**
|
|
158
|
+
- Enable debug logging by setting `OPDOTENV_DEBUG=true`
|
|
159
|
+
- Check Rails logs for messages like:
|
|
160
|
+
```
|
|
161
|
+
[opdotenv] Available fields from 1Password: enabled, ENABLED, sample, SAMPLE
|
|
162
|
+
[opdotenv] Matched fields for TEST: enabled, sample
|
|
163
|
+
[opdotenv] Unmatched fields (must be prefixed with TEST_, case-insensitive): other_field
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Using with dotenv
|
|
167
|
+
|
|
168
|
+
Load order determines which values take precedence:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
require "dotenv"
|
|
172
|
+
require "opdotenv"
|
|
173
|
+
|
|
174
|
+
# Load local files first, then augment from 1Password (1Password values override by default)
|
|
175
|
+
Dotenv.load(".env", ".env.development")
|
|
176
|
+
Opdotenv::Loader.load("op://Vault/.env.development")
|
|
177
|
+
|
|
178
|
+
# Or load from 1Password first, then local files (local values override)
|
|
179
|
+
Opdotenv::Loader.load("op://Vault/.env.development", overwrite: false)
|
|
180
|
+
Dotenv.load(".env", ".env.development")
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Export to 1Password
|
|
184
|
+
|
|
185
|
+
### CLI
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# Export .env file (format inferred from path)
|
|
189
|
+
opdotenv export --path "op://Vault/.env.development" --file .env.development
|
|
190
|
+
|
|
191
|
+
# Export to item fields
|
|
192
|
+
opdotenv export --path "op://Vault/App" --file .env
|
|
193
|
+
|
|
194
|
+
# Read and print (format inferred from path)
|
|
195
|
+
opdotenv read --path "op://Vault/.env.development"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Ruby API
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# Export to Secure Note (format inferred from path)
|
|
202
|
+
Opdotenv::Exporter.export(
|
|
203
|
+
path: "op://Vault/.env.development",
|
|
204
|
+
data: {"API_KEY" => "secret"}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Export to item fields
|
|
208
|
+
Opdotenv::Exporter.export(
|
|
209
|
+
path: "op://Vault/App",
|
|
210
|
+
data: {"API_KEY" => "secret", "API_SECRET" => "another"}
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Supported formats
|
|
215
|
+
|
|
216
|
+
Format is automatically inferred from item name or field name:
|
|
217
|
+
- `.env.*` → dotenv format (`KEY=VALUE`)
|
|
218
|
+
- `*.json` → JSON format (nested structures flattened with underscores)
|
|
219
|
+
- `*.yaml` or `*.yml` → YAML format (nested structures flattened with underscores)
|
|
220
|
+
- Other items → load all fields without parsing
|
|
221
|
+
|
|
222
|
+
Field names can be specified with extensions in the path:
|
|
223
|
+
- `op://Vault/Item Name/config.json` → loads field `config.json` as JSON
|
|
224
|
+
- `op://Vault/Item Name/production.json` → loads field `production.json` as JSON
|
|
225
|
+
- `op://Vault/Item Name/.env.development` → loads field `.env.development` as dotenv
|
|
226
|
+
|
|
227
|
+
For advanced usage, you can explicitly specify `field_name` and `field_type` in the API.
|
|
228
|
+
|
|
229
|
+
## Security
|
|
230
|
+
|
|
231
|
+
### Environment Recommendation
|
|
232
|
+
|
|
233
|
+
**⚠️ This gem is recommended for development and test environments only.**
|
|
234
|
+
|
|
235
|
+
For production environments, we recommend using dedicated secret management solutions that integrate with your infrastructure:
|
|
236
|
+
|
|
237
|
+
- **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
|
|
238
|
+
- **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)
|
|
239
|
+
- **Azure**: Use [Azure Key Vault](https://azure.microsoft.com/en-us/products/key-vault)
|
|
240
|
+
- **GCP**: Use [Google Secret Manager](https://cloud.google.com/secret-manager)
|
|
241
|
+
|
|
242
|
+
Provision secrets through infrastructure-as-code tools:
|
|
243
|
+
- **Helm** (Kubernetes): Use `helm secrets` or external secrets operator
|
|
244
|
+
- **Terraform**: Use `aws_secretsmanager_secret`, `azurerm_key_vault_secret`, or `google_secret_manager_secret`
|
|
245
|
+
- **Bicep** (Azure): Use `Microsoft.KeyVault/vaults` resources
|
|
246
|
+
|
|
247
|
+
These solutions provide:
|
|
248
|
+
- Better audit trails and access controls
|
|
249
|
+
- Integration with IAM/RBAC systems
|
|
250
|
+
- Automatic secret rotation
|
|
251
|
+
- Compliance with security standards
|
|
252
|
+
- No dependency on external CLI tools or API servers
|
|
253
|
+
|
|
254
|
+
### Security Considerations
|
|
255
|
+
|
|
256
|
+
- **CLI mode**: Secrets fetched via authenticated `op` CLI session.
|
|
257
|
+
- **Connect API mode**: Secrets fetched via HTTPS. Ensure tokens are secure.
|
|
258
|
+
- The library does not persist secrets in memory or on disk.
|
|
259
|
+
- Always verify 1Password CLI or Connect server is up to date and authenticated.
|
|
260
|
+
|
|
261
|
+
### Code Locations for Security Review
|
|
262
|
+
|
|
263
|
+
For security-sensitive applications, developers should review where this gem reads and writes data from 1Password. The following locations handle secret data:
|
|
264
|
+
|
|
265
|
+
#### Reading Secrets (OpClient - CLI mode)
|
|
266
|
+
|
|
267
|
+
- **`lib/opdotenv/op_client.rb`**:
|
|
268
|
+
- `read(path)` - Executes `op read` command to fetch a single field value
|
|
269
|
+
- `item_get(item, vault:)` - Executes `op item get` command to fetch all item data as JSON
|
|
270
|
+
- `capture(args)` - Executes shell commands via `IO.popen` (array arguments, no shell interpretation)
|
|
271
|
+
|
|
272
|
+
#### Reading Secrets (ConnectApiClient - API mode)
|
|
273
|
+
|
|
274
|
+
- **`lib/opdotenv/connect_api_client.rb`**:
|
|
275
|
+
- `read(path)` - Makes HTTP GET request to fetch field or notesPlain content
|
|
276
|
+
- `item_get(item_title, vault:)` - Searches and fetches item data via API
|
|
277
|
+
- `get_item(vault_name, item_title)` - Fetches full item details including all fields
|
|
278
|
+
- `item_by_title_in_vault(vault_id, item_title)` - Lists items and fetches by title
|
|
279
|
+
- `list_vaults()` - Lists all accessible vaults (uses `api_request(:get, "/v1/vaults")`)
|
|
280
|
+
- `vault_name_to_id(vault_name)` - Resolves vault names to IDs (cached)
|
|
281
|
+
- `api_request(method, path, body)` - All HTTP requests go through this method
|
|
282
|
+
- `find_field(item, field_name)` - Searches item fields by label, ID, or purpose
|
|
283
|
+
|
|
284
|
+
#### Main Entry Points
|
|
285
|
+
|
|
286
|
+
- **`lib/opdotenv/loader.rb`**:
|
|
287
|
+
- `load(path, ...)` - Main entry point that orchestrates secret fetching
|
|
288
|
+
- `load_field(client, path, field_name, field_type)` - Loads and parses a single field
|
|
289
|
+
- `load_all_fields(client, path)` - Loads all fields from an item (skips notesPlain)
|
|
290
|
+
- `merge_into_env(env, hash, overwrite:)` - Writes secrets to environment hash
|
|
291
|
+
|
|
292
|
+
#### Rails Integration
|
|
293
|
+
|
|
294
|
+
- **`lib/opdotenv/railtie.rb`**:
|
|
295
|
+
- `initializer "opdotenv.load"` - Automatically loads secrets during Rails initialization
|
|
296
|
+
- Reads from `config.opdotenv.sources` array
|
|
297
|
+
- Sets `OP_CONNECT_URL` and `OP_CONNECT_TOKEN` from Rails config if provided
|
|
298
|
+
|
|
299
|
+
#### Anyway Config Integration
|
|
300
|
+
|
|
301
|
+
- **`lib/opdotenv/anyway_loader.rb`**:
|
|
302
|
+
- `Loader#call(...)` - Loads secrets for Anyway Config classes
|
|
303
|
+
- Uses `Opdotenv::Loader.load()` internally
|
|
304
|
+
|
|
305
|
+
#### Parsing and Processing
|
|
306
|
+
|
|
307
|
+
- **`lib/opdotenv/parsers/dotenv_parser.rb`** - Parses dotenv format strings
|
|
308
|
+
- **`lib/opdotenv/parsers/json_parser.rb`** - Parses and flattens JSON structures
|
|
309
|
+
- **`lib/opdotenv/parsers/yaml_parser.rb`** - Parses YAML (safe_load with aliases: false)
|
|
310
|
+
|
|
311
|
+
#### Data Flow
|
|
312
|
+
|
|
313
|
+
1. **Configuration** → Rails config or direct API calls
|
|
314
|
+
2. **Path Parsing** → `SourceParser.parse()` extracts vault/item/field from path
|
|
315
|
+
3. **Client Selection** → `ClientFactory.create()` chooses CLI or API client
|
|
316
|
+
4. **Secret Fetching** → Client reads from 1Password (CLI command or HTTP request)
|
|
317
|
+
5. **Parsing** → Format-specific parser converts to key-value pairs
|
|
318
|
+
6. **Environment Merge** → Secrets merged into `ENV` or provided hash
|
|
319
|
+
|
|
320
|
+
All secret data flows through these code paths. No secrets are persisted to disk or logged (except explicit error messages).
|
|
321
|
+
|
|
322
|
+
## Development
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
bundle install
|
|
326
|
+
bundle exec rspec
|
|
327
|
+
bundle exec rbs validate
|
|
328
|
+
bundle exec standardrb --fix
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## License
|
|
332
|
+
|
|
333
|
+
MIT
|
data/bin/opdotenv
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
5
|
+
require "opdotenv"
|
|
6
|
+
require "optparse"
|
|
7
|
+
|
|
8
|
+
cmd = ARGV.shift
|
|
9
|
+
|
|
10
|
+
case cmd
|
|
11
|
+
when "read"
|
|
12
|
+
path = nil
|
|
13
|
+
OptionParser.new do |o|
|
|
14
|
+
o.on("--path PATH", "op://Vault/Item") { |v| path = v }
|
|
15
|
+
end.parse!(ARGV)
|
|
16
|
+
abort("--path required") unless path
|
|
17
|
+
data = Opdotenv::Loader.load(path)
|
|
18
|
+
puts Opdotenv::Exporter.serialize_by_format(data, :dotenv)
|
|
19
|
+
when "export"
|
|
20
|
+
path = file = nil
|
|
21
|
+
field_type = nil
|
|
22
|
+
OptionParser.new do |o|
|
|
23
|
+
o.on("--path PATH", "op://Vault/Item") { |v| path = v }
|
|
24
|
+
o.on("--file PATH", "Source file (.env/json/yaml)") { |v| file = v }
|
|
25
|
+
o.on("--field-type TYPE", [:dotenv, :json, :yaml, :yml], "Override format (default: inferred from path)") { |v| field_type = v }
|
|
26
|
+
end.parse!(ARGV)
|
|
27
|
+
abort("--path and --file required") unless path && file
|
|
28
|
+
# Infer format from file extension if not provided
|
|
29
|
+
field_type ||= infer_format_from_file(file)
|
|
30
|
+
text = File.read(file)
|
|
31
|
+
data = Opdotenv::Loader.parse_by_format(text, field_type)
|
|
32
|
+
Opdotenv::Exporter.export(path: path, data: data, field_type: field_type)
|
|
33
|
+
else
|
|
34
|
+
warn "Usage: opdotenv (read|export) ..."
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def infer_format_from_file(file)
|
|
39
|
+
case File.extname(file)
|
|
40
|
+
when ".json" then :json
|
|
41
|
+
when ".yaml", ".yml" then :yaml
|
|
42
|
+
else :dotenv
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
module Opdotenv
|
|
2
|
+
# Anyway Config loader to fetch configuration from 1Password via opdotenv.
|
|
3
|
+
# Usage per-config:
|
|
4
|
+
# class MyConfig < Anyway::Config
|
|
5
|
+
# loader_options opdotenv: {
|
|
6
|
+
# path: "op://Vault/.env.development" # format inferred from item name
|
|
7
|
+
# }
|
|
8
|
+
# end
|
|
9
|
+
module AnywayLoader
|
|
10
|
+
class Loader < ::Anyway::Loaders::Base
|
|
11
|
+
def call(name:, env_prefix:, config_path:, **opts)
|
|
12
|
+
options = opts[:opdotenv] || {}
|
|
13
|
+
return {} if options.empty?
|
|
14
|
+
|
|
15
|
+
path = options[:path] || options["path"]
|
|
16
|
+
raise ArgumentError, "opdotenv loader requires :path" unless path
|
|
17
|
+
|
|
18
|
+
parsed = SourceParser.parse(path)
|
|
19
|
+
overwrite = options.key?(:overwrite) ? options[:overwrite] : true
|
|
20
|
+
|
|
21
|
+
# Use a separate env hash to avoid side effects on global ENV
|
|
22
|
+
data = Opdotenv::Loader.load(
|
|
23
|
+
path,
|
|
24
|
+
field_name: parsed[:field_name],
|
|
25
|
+
field_type: parsed[:field_type],
|
|
26
|
+
env: {},
|
|
27
|
+
overwrite: overwrite
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
strip_prefix_from_keys(data, env_prefix)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def strip_prefix_from_keys(data, env_prefix)
|
|
36
|
+
return data unless defined?(::Anyway::Env) && defined?(::Anyway::NoCast)
|
|
37
|
+
|
|
38
|
+
# Strict matching with case-insensitive prefix: Only fields with env_prefix will be matched
|
|
39
|
+
# Normalize keys to uppercase for case-insensitive matching
|
|
40
|
+
normalized_data = normalize_keys_for_prefix_matching(data, env_prefix)
|
|
41
|
+
|
|
42
|
+
# Use Anyway::Env to handle prefix stripping, matching how Doppler loader works
|
|
43
|
+
# This transforms keys like "TEST_ENABLED" -> "enabled" when env_prefix is "TEST"
|
|
44
|
+
env = ::Anyway::Env.new(type_cast: ::Anyway::NoCast, env_container: normalized_data)
|
|
45
|
+
conf, trace = env.fetch_with_trace(env_prefix)
|
|
46
|
+
|
|
47
|
+
if defined?(::Anyway::Tracing) && ::Anyway::Tracing.current_trace
|
|
48
|
+
::Anyway::Tracing.current_trace.merge!(trace)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Log available fields for debugging (only when OPDOTENV_DEBUG is enabled)
|
|
52
|
+
log_available_fields(data, env_prefix, conf)
|
|
53
|
+
|
|
54
|
+
conf
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def normalize_keys_for_prefix_matching(data, env_prefix)
|
|
58
|
+
return data if data.empty? || env_prefix.empty?
|
|
59
|
+
|
|
60
|
+
prefix_upper = env_prefix.upcase
|
|
61
|
+
prefix_with_underscore = "#{prefix_upper}_"
|
|
62
|
+
|
|
63
|
+
normalized = {}
|
|
64
|
+
data.each do |key, value|
|
|
65
|
+
key_str = key.to_s
|
|
66
|
+
key_upper = key_str.upcase
|
|
67
|
+
|
|
68
|
+
# Case-insensitive prefix matching: only include keys that start with PREFIX_
|
|
69
|
+
if key_upper.start_with?(prefix_with_underscore) || key_upper == prefix_upper
|
|
70
|
+
# Normalize to uppercase for consistent matching
|
|
71
|
+
normalized_key = key_upper
|
|
72
|
+
normalized[normalized_key] = value
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
normalized
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def log_available_fields(original_data, env_prefix, matched_data)
|
|
80
|
+
return unless ENV["OPDOTENV_DEBUG"] == "true"
|
|
81
|
+
return unless defined?(Rails) && Rails.logger
|
|
82
|
+
|
|
83
|
+
prefix_upper = env_prefix.upcase
|
|
84
|
+
prefix_with_underscore = "#{prefix_upper}_"
|
|
85
|
+
|
|
86
|
+
available_fields = original_data.keys.map(&:to_s)
|
|
87
|
+
matched_fields = matched_data.keys.map(&:to_s)
|
|
88
|
+
|
|
89
|
+
# Find fields that were available but didn't match the prefix (case-insensitive)
|
|
90
|
+
unmatched = available_fields.reject do |field|
|
|
91
|
+
field_upper = field.to_s.upcase
|
|
92
|
+
field_upper.start_with?(prefix_with_underscore) || field_upper == prefix_upper
|
|
93
|
+
end
|
|
94
|
+
|
|
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
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.register!
|
|
107
|
+
::Anyway.loaders.append :opdotenv, Loader
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
begin
|
|
113
|
+
# Auto-register if Anyway is available
|
|
114
|
+
# Silently skip if Anyway Config is not available or not fully loaded
|
|
115
|
+
if defined?(::Anyway) && ::Anyway.respond_to?(:loaders)
|
|
116
|
+
Opdotenv::AnywayLoader.register!
|
|
117
|
+
end
|
|
118
|
+
rescue => e
|
|
119
|
+
# Only warn if debugging is enabled, as this is expected when Anyway Config isn't used
|
|
120
|
+
if ENV["OPDOTENV_DEBUG"] == "true"
|
|
121
|
+
warn "[opdotenv] Failed to register Anyway loader: #{e.message}"
|
|
122
|
+
warn "[opdotenv] Error details: #{e.class}: #{e.message}"
|
|
123
|
+
warn "[opdotenv] Backtrace: #{e.backtrace.first(3).join("\n")}" if e.backtrace
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Opdotenv
|
|
2
|
+
class ClientFactory
|
|
3
|
+
# Creates appropriate client based on configuration or environment
|
|
4
|
+
# Supports both op CLI and Connect API
|
|
5
|
+
def self.create(env: ENV)
|
|
6
|
+
# Check for Connect API configuration
|
|
7
|
+
connect_url = env["OP_CONNECT_URL"] || env["OPDOTENV_CONNECT_URL"]
|
|
8
|
+
connect_token = env["OP_CONNECT_TOKEN"] || env["OPDOTENV_CONNECT_TOKEN"]
|
|
9
|
+
|
|
10
|
+
if connect_url && connect_token
|
|
11
|
+
ConnectApiClient.new(base_url: connect_url, access_token: connect_token, env: env)
|
|
12
|
+
else
|
|
13
|
+
OpClient.new(env: env)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|