easy_creds 1.0.1
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 +51 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/exe/easy_creds +6 -0
- data/lib/easy_creds/actions/edit.rb +156 -0
- data/lib/easy_creds/actions/editor_edit.rb +70 -0
- data/lib/easy_creds/actions/init.rb +93 -0
- data/lib/easy_creds/actions/local/delete.rb +44 -0
- data/lib/easy_creds/actions/local/edit.rb +13 -0
- data/lib/easy_creds/actions/local/editor_edit.rb +13 -0
- data/lib/easy_creds/actions/local/init.rb +32 -0
- data/lib/easy_creds/actions/local/status.rb +49 -0
- data/lib/easy_creds/actions/local/sync_key.rb +63 -0
- data/lib/easy_creds/actions/local.rb +31 -0
- data/lib/easy_creds/actions/pull.rb +142 -0
- data/lib/easy_creds/actions/push.rb +149 -0
- data/lib/easy_creds/actions/status.rb +82 -0
- data/lib/easy_creds/cli.rb +147 -0
- data/lib/easy_creds/config.rb +52 -0
- data/lib/easy_creds/configuration.rb +91 -0
- data/lib/easy_creds/credentials_io.rb +128 -0
- data/lib/easy_creds/differ.rb +41 -0
- data/lib/easy_creds/doctor.rb +100 -0
- data/lib/easy_creds/env_picker.rb +24 -0
- data/lib/easy_creds/flatten.rb +25 -0
- data/lib/easy_creds/generators.rb +113 -0
- data/lib/easy_creds/init_state.rb +65 -0
- data/lib/easy_creds/installer.rb +55 -0
- data/lib/easy_creds/onboarding/gem_setup.rb +98 -0
- data/lib/easy_creds/onboarding/project_wizard.rb +106 -0
- data/lib/easy_creds/onboarding/register_prompt.rb +47 -0
- data/lib/easy_creds/onboarding/runner.rb +17 -0
- data/lib/easy_creds/onboarding/template_picker.rb +36 -0
- data/lib/easy_creds/overlay.rb +71 -0
- data/lib/easy_creds/project.rb +74 -0
- data/lib/easy_creds/projects/registry.rb +135 -0
- data/lib/easy_creds/provider.rb +30 -0
- data/lib/easy_creds/providers/base.rb +25 -0
- data/lib/easy_creds/providers/one_password.rb +187 -0
- data/lib/easy_creds/railtie.rb +10 -0
- data/lib/easy_creds/templates/files/default-beastmode.yml +33 -0
- data/lib/easy_creds/templates/files/microservice-minimal.yml +7 -0
- data/lib/easy_creds/templates/files/rails-api.yml +20 -0
- data/lib/easy_creds/templates/files/rails-fullstack.yml +37 -0
- data/lib/easy_creds/templates/registry.rb +62 -0
- data/lib/easy_creds/templates/renderer.rb +37 -0
- data/lib/easy_creds/theme.rb +129 -0
- data/lib/easy_creds/thor_cli.rb +299 -0
- data/lib/easy_creds/vault_picker.rb +56 -0
- data/lib/easy_creds/version.rb +5 -0
- data/lib/easy_creds/views/diff_table.rb +36 -0
- data/lib/easy_creds/views/header.rb +40 -0
- data/lib/easy_creds/views/init_dispatch.rb +132 -0
- data/lib/easy_creds/views/init_tree.rb +250 -0
- data/lib/easy_creds/views/local_menu.rb +38 -0
- data/lib/easy_creds/views/menu.rb +55 -0
- data/lib/easy_creds/views/project_picker.rb +56 -0
- data/lib/easy_creds/views/settings_menu.rb +108 -0
- data/lib/easy_creds/views/templates_menu.rb +142 -0
- data/lib/easy_creds/views/welcome_screen.rb +131 -0
- data/lib/easy_creds.rb +54 -0
- data/lib/tasks/easy_creds.rake +23 -0
- metadata +292 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e4de9acf2675f1cd6b624e7217d09d36ea2af3a502b1b799d6d5e578f542d719
|
|
4
|
+
data.tar.gz: c7dac7a501ebfcc89a4aa3caa35bfc4fe817b374076a6c7a745271d6dc467303
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6bb5396a44b65b2692b6a303196a98073fd2c42c97dcc21ca2fb4b512d05d6b2e2a8f5762f174d932914c16687b82ea072c5685ce5ac8b39d0f74751961931e4
|
|
7
|
+
data.tar.gz: 9638ad3ce2c9165555d7a1a71aba4b816944cc8db2f5ab8505bda2edc3f75bb0ae9cad80047ff33f6c2e3109ca92478086a7ec6ea646cf796c9ebf2a97fb1100
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.1] - 2026-06-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Static documentation site under `docs/` (GitHub Pages): overview, getting
|
|
13
|
+
started, CLI & rake reference, TUI navigator, 1Password sync, local overlay,
|
|
14
|
+
configuration, templates, and Rails integration.
|
|
15
|
+
- `documentation_uri` metadata pointing at the published docs site.
|
|
16
|
+
|
|
17
|
+
## [0.1.0] - 2026-06-15
|
|
18
|
+
|
|
19
|
+
First public release, extracted and generalized from fishme/backend
|
|
20
|
+
`lib/credentials_op_sync`.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `EasyCreds::Overlay.apply!` now accepts either a Rails application
|
|
25
|
+
(`apply!(Rails.application)`, the form the installer-generated initializer
|
|
26
|
+
emits) or the explicit `apply!(creds, root, env)` triple. Previously the
|
|
27
|
+
installer-written initializer passed a single argument to a 3-arity method,
|
|
28
|
+
which raised `ArgumentError` at boot.
|
|
29
|
+
- Test suite ported from `credentials_op_sync`: `CredentialsIO`,
|
|
30
|
+
`Providers::OnePassword`, the local overlay actions, and a dedicated
|
|
31
|
+
`overlay_test` that locks both `apply!` call forms.
|
|
32
|
+
|
|
33
|
+
- Interactive TUI (sync, push, pull, init, edit, editor, local overlay management).
|
|
34
|
+
- 1Password CLI provider (`EasyCreds::Providers::OnePassword`) over the
|
|
35
|
+
`op://<vault>/<env>/<field>` URI scheme.
|
|
36
|
+
- Standalone mode — works outside a Rails project.
|
|
37
|
+
- Onboarding wizard (`easy_creds init`, `easy_creds onboard`).
|
|
38
|
+
- Bundled templates: `rails-fullstack`, `rails-api`, `microservice-minimal`.
|
|
39
|
+
- Thor CLI entrypoint (`easy_creds` binary).
|
|
40
|
+
- Doctor command for health checks.
|
|
41
|
+
- Rails integration via Railtie (`credentials:sync`, `easy_creds:install`,
|
|
42
|
+
`easy_creds:onboard`).
|
|
43
|
+
- **Rails-derived value generation with fallback** — inside a Rails project the
|
|
44
|
+
init navigator generates `secret_key_base` / `devise_secret_key` / `crypt_secret`
|
|
45
|
+
via `rails secret`, and the three `active_record_encryption.*` keys via
|
|
46
|
+
`rails db:encryption:init`. Falls back to `SecureRandom` when the shell-out
|
|
47
|
+
fails or when run standalone. This matches the behavior of the original
|
|
48
|
+
`credentials_op_sync` tool.
|
|
49
|
+
- Provider audit logging to `log/easy_creds.log` when running inside a project.
|
|
50
|
+
|
|
51
|
+
[0.1.0]: https://github.com/way2do-it/easy_creds/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Pawel Niemczyk
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# easy_creds
|
|
2
|
+
|
|
3
|
+
Interactive TUI for syncing encrypted Rails credentials with 1Password. Works inside Rails projects or as a standalone CLI.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/easy_creds)
|
|
6
|
+
|
|
7
|
+
**[💎 RubyGems](https://rubygems.org/gems/easy_creds)** | **[📖 Documentation](https://pniemczyk.github.io/easy_creds/)** | **[GitHub](https://github.com/pniemczyk/easy_creds)** | **[Changelog](CHANGELOG.md)**
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Full-screen TUI navigator** — browse all credential keys, generate values, pull from 1Password, or enter manually, all from one interface
|
|
12
|
+
- **Push / pull diff** — see exactly what changed before committing to 1Password
|
|
13
|
+
- **Local overlay** — keep machine-specific secrets in a gitignored `_local.yml.enc` that merges on top of the base credentials at boot
|
|
14
|
+
- **Onboarding wizard** — scaffold an `example.yml` for any new project from bundled or custom templates
|
|
15
|
+
- **Provider abstraction** — 1Password CLI ships in v1; the seam is open for other providers
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Ruby ≥ 3.2
|
|
20
|
+
- [1Password CLI](https://developer.1password.com/docs/cli/) (`op`) installed and signed in
|
|
21
|
+
- Rails ≥ 7.0 (for Rails usage) — or any directory for standalone usage
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Add to your Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem 'easy_creds'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then run:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle install
|
|
35
|
+
bin/rails easy_creds:install # install overlay initializer + gitignore rules
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start (Rails)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# First time — scaffold example.yml
|
|
42
|
+
bin/rails easy_creds:onboard
|
|
43
|
+
|
|
44
|
+
# Day-to-day — open TUI
|
|
45
|
+
bin/rails credentials:sync
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start (standalone CLI)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# First time — gem setup + pick vault
|
|
52
|
+
easy_creds init
|
|
53
|
+
|
|
54
|
+
# Per-project onboarding
|
|
55
|
+
cd /path/to/project
|
|
56
|
+
easy_creds onboard
|
|
57
|
+
|
|
58
|
+
# Open sync TUI
|
|
59
|
+
easy_creds sync
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## CLI reference
|
|
63
|
+
|
|
64
|
+
| Command | Description |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `easy_creds sync [ENV]` | Open interactive TUI loop |
|
|
67
|
+
| `easy_creds init` | First-time gem setup (global dir, vault) |
|
|
68
|
+
| `easy_creds onboard` | Per-project example.yml wizard |
|
|
69
|
+
| `easy_creds install` | Install overlay initializer + gitignore rules |
|
|
70
|
+
| `easy_creds doctor` | Health check (op CLI, sign-in, vault, project) |
|
|
71
|
+
| `easy_creds template list` | List available templates |
|
|
72
|
+
| `easy_creds version` | Print gem version |
|
|
73
|
+
|
|
74
|
+
## Rake tasks (Rails)
|
|
75
|
+
|
|
76
|
+
| Task | Description |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `rails credentials:sync` | Open TUI sync loop |
|
|
79
|
+
| `rails easy_creds:install` | Install overlay initializer + gitignore |
|
|
80
|
+
| `rails easy_creds:onboard` | Per-project onboarding wizard |
|
|
81
|
+
|
|
82
|
+
## TUI keyboard shortcuts
|
|
83
|
+
|
|
84
|
+
| Key | Action |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `↑` / `↓` or `k` / `j` | Navigate keys |
|
|
87
|
+
| `PgUp` / `PgDn` | Jump 10 |
|
|
88
|
+
| `Home` / `End` | First / last |
|
|
89
|
+
| `g` | Generate value (default) |
|
|
90
|
+
| `G` | Generate alt value |
|
|
91
|
+
| `b` | Batch generate AR encryption keys (3 keys) |
|
|
92
|
+
| `t` | Use template placeholder |
|
|
93
|
+
| `o` | Fill from 1Password |
|
|
94
|
+
| `m` | Enter manually (hidden input) |
|
|
95
|
+
| `c` | Clear / reset to unset |
|
|
96
|
+
| `T` | Template all unset |
|
|
97
|
+
| `R` | Generate all suggested |
|
|
98
|
+
| `O` | Fill all unset from 1Password |
|
|
99
|
+
| `s` | Save and write `.yml.enc` |
|
|
100
|
+
| `q` | Quit (confirm if unsaved) |
|
|
101
|
+
| `?` / `h` | Toggle help panel |
|
|
102
|
+
|
|
103
|
+
> **Value generation.** Inside a Rails project, `g` / `b` / `R` derive secrets
|
|
104
|
+
> with Rails itself — `rails secret` for `secret_key_base` / `devise_secret_key`
|
|
105
|
+
> / `crypt_secret`, and `rails db:encryption:init` for the three
|
|
106
|
+
> `active_record_encryption.*` keys. If the shell-out fails or you run
|
|
107
|
+
> standalone (outside a Rails app), generation falls back to `SecureRandom`.
|
|
108
|
+
> `G` (alt) always uses `SecureRandom`.
|
|
109
|
+
|
|
110
|
+
## Local overlay
|
|
111
|
+
|
|
112
|
+
The overlay merges a gitignored `config/credentials/<env>_local.yml.enc` on top of the base credentials at boot. Useful for machine-specific overrides (e.g. a local database URL).
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Open the local overlay editor
|
|
116
|
+
bin/rails credentials:sync # → choose Local → Edit (inline)
|
|
117
|
+
# or
|
|
118
|
+
easy_creds sync development # → Local → Edit
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The overlay key (`<env>_local.key`) is never pushed to 1Password.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# config/initializers/easy_creds.rb (optional)
|
|
127
|
+
EasyCreds.configure do |config|
|
|
128
|
+
config.global_dir = '~/.easy_creds' # default
|
|
129
|
+
config.default_provider = :onepassword # default
|
|
130
|
+
config.default_vault = nil # prompted on first run
|
|
131
|
+
config.default_template = :rails_fullstack # default
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Precedence (low → high): built-in defaults → `~/.easy_creds/config.yml` → `<project>/config/easy_creds.yml` → `EasyCreds.configure { }`.
|
|
136
|
+
|
|
137
|
+
## Templates
|
|
138
|
+
|
|
139
|
+
Three bundled templates:
|
|
140
|
+
|
|
141
|
+
| Name | Use case |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `rails-fullstack` | Full Rails app (DB, AR encryption, OAuth, S3) |
|
|
144
|
+
| `rails-api` | API-only Rails (DB, AR encryption, OAuth) |
|
|
145
|
+
| `microservice-minimal` | Minimal service (DB URL, one API key) |
|
|
146
|
+
|
|
147
|
+
Save a custom template:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
easy_creds template save myapp --from config/credentials/example.yml
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
cd ~/git/cowork/gems/easy_creds
|
|
157
|
+
bundle install
|
|
158
|
+
bundle exec rake test # run test suite
|
|
159
|
+
bundle exec exe/easy_creds version
|
|
160
|
+
bundle exec exe/easy_creds doctor
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
data/exe/easy_creds
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Naming/PredicateMethod
|
|
4
|
+
|
|
5
|
+
require 'tty-spinner'
|
|
6
|
+
|
|
7
|
+
module EasyCreds
|
|
8
|
+
module Actions
|
|
9
|
+
module Edit
|
|
10
|
+
def self.call(ctx, local: false)
|
|
11
|
+
label = local ? "#{ctx.env}_local" : ctx.env
|
|
12
|
+
Theme.section("Edit — credentials/#{label}.yml.enc")
|
|
13
|
+
|
|
14
|
+
unless ctx.io.key_exists?(ctx.env, local: local)
|
|
15
|
+
Theme.failure('No local key. Run `init` or `pull` first.')
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
local_hash, remote_fields = load_data(ctx, local: local)
|
|
20
|
+
local_flat = Flatten.dot(local_hash)
|
|
21
|
+
modified = run_edit_loop(ctx, local_flat, remote_fields, local: local)
|
|
22
|
+
|
|
23
|
+
persist(ctx, local_flat, label, local: local, modified: modified)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method def self.run_edit_loop(ctx, local_flat, remote_flat, local:)
|
|
27
|
+
modified = false
|
|
28
|
+
loop do
|
|
29
|
+
puts ''
|
|
30
|
+
all_keys = (local_flat.keys | remote_flat.keys).sort
|
|
31
|
+
choices = all_keys.map do |k|
|
|
32
|
+
{ name: format_key_choice(k, local_flat, remote_flat, local: local), value: k }
|
|
33
|
+
end
|
|
34
|
+
choices << { name: Theme.dim('─── done ───'), value: :done }
|
|
35
|
+
|
|
36
|
+
key = ctx.prompt.select('Pick a key to edit:', choices, cycle: true, filter: true, per_page: 15)
|
|
37
|
+
break if key == :done
|
|
38
|
+
|
|
39
|
+
modified = true if edit_key(ctx, key, local_flat, remote_flat, local: local)
|
|
40
|
+
end
|
|
41
|
+
modified
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private_class_method def self.load_data(ctx, local:)
|
|
45
|
+
if local
|
|
46
|
+
spinner = TTY::Spinner.new('[:spinner] Reading...', format: :dots)
|
|
47
|
+
spinner.auto_spin
|
|
48
|
+
result = ctx.io.read(ctx.env, local: true)
|
|
49
|
+
spinner.stop('')
|
|
50
|
+
[result, {}]
|
|
51
|
+
else
|
|
52
|
+
spinner = TTY::Spinner.new('[:spinner] Fetching...', format: :dots)
|
|
53
|
+
spinner.auto_spin
|
|
54
|
+
result = ctx.io.read(ctx.env)
|
|
55
|
+
remote_fields = ctx.op.item(ctx.env) || {}
|
|
56
|
+
spinner.stop('')
|
|
57
|
+
[result, remote_fields]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method def self.persist(ctx, local_flat, label, local:, modified:)
|
|
62
|
+
unless modified
|
|
63
|
+
Theme.notice('No changes made')
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ctx.io.write(ctx.env, Flatten.unflatten(local_flat), local: local)
|
|
68
|
+
Theme.success("Saved config/credentials/#{label}.yml.enc")
|
|
69
|
+
|
|
70
|
+
Actions::Push.call(ctx) if !local && ctx.prompt.yes?('Push changes to 1Password now?')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method def self.format_key_choice(key, local_flat, remote_flat, local: false)
|
|
74
|
+
in_local = local_flat.key?(key)
|
|
75
|
+
in_remote = remote_flat.key?(key)
|
|
76
|
+
hint = Generators.hint_for(key)
|
|
77
|
+
|
|
78
|
+
flags = []
|
|
79
|
+
flags << Theme.ok('L') if in_local
|
|
80
|
+
flags << Theme.dim('-') unless in_local
|
|
81
|
+
unless local
|
|
82
|
+
flags << Theme.info('R') if in_remote
|
|
83
|
+
flags << Theme.dim('-') unless in_remote
|
|
84
|
+
end
|
|
85
|
+
flags << Theme.dim(' ⚡') if hint
|
|
86
|
+
|
|
87
|
+
"#{Theme.key_tag(key.ljust(45))} #{flags.join}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private_class_method def self.edit_key(ctx, key, local_flat, remote_flat, local: false)
|
|
91
|
+
hint = Generators.hint_for(key)
|
|
92
|
+
local_val = local_flat[key]
|
|
93
|
+
remote_val = remote_flat[key]
|
|
94
|
+
|
|
95
|
+
puts ''
|
|
96
|
+
puts " #{Theme.bold(key)}"
|
|
97
|
+
puts " #{Theme.dim('local: ')} #{local_val ? '(set)' : Theme.dim('(missing)')}"
|
|
98
|
+
puts " #{Theme.dim('remote:')} #{remote_val ? '(set)' : Theme.dim('(missing)')}" unless local
|
|
99
|
+
puts " #{Theme.dim('generator:')} #{hint}" if hint
|
|
100
|
+
|
|
101
|
+
action = ctx.prompt.select('Action:', build_choices(hint, remote_val, local_val, local: local), cycle: false)
|
|
102
|
+
apply_edit(action, ctx, local_flat, key: key, remote_val: remote_val)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private_class_method def self.build_choices(hint, remote_val, local_val, local:)
|
|
106
|
+
choices = []
|
|
107
|
+
choices << { name: "generate (#{hint})", value: :generate } if hint
|
|
108
|
+
choices << { name: 'enter new value', value: :enter }
|
|
109
|
+
choices << { name: 'use remote value', value: :use_remote } if remote_val && !local
|
|
110
|
+
choices << { name: 'delete (remove key)', value: :delete } if local_val
|
|
111
|
+
choices << { name: 'cancel', value: :cancel }
|
|
112
|
+
choices
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private_class_method def self.apply_edit(action, ctx, local_flat, key:, remote_val:)
|
|
116
|
+
hint = Generators.hint_for(key)
|
|
117
|
+
case action
|
|
118
|
+
when :generate then apply_generate(hint, key, local_flat, ctx)
|
|
119
|
+
when :enter then apply_enter(ctx, key, local_flat)
|
|
120
|
+
when :use_remote
|
|
121
|
+
local_flat[key] = remote_val
|
|
122
|
+
puts " #{Theme.ok('Copied from remote')}"
|
|
123
|
+
true
|
|
124
|
+
when :delete
|
|
125
|
+
local_flat.delete(key)
|
|
126
|
+
puts " #{Theme.warn('Deleted')}"
|
|
127
|
+
true
|
|
128
|
+
when :cancel then false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private_class_method def self.apply_generate(hint, key, local_flat, ctx)
|
|
133
|
+
val = Generators.generate(hint, root: ctx.root)
|
|
134
|
+
if val.is_a?(Hash)
|
|
135
|
+
val.each { |k, v| local_flat[k] = v }
|
|
136
|
+
puts " #{Theme.ok("Generated #{val.size} key(s)")}"
|
|
137
|
+
else
|
|
138
|
+
local_flat[key] = val
|
|
139
|
+
puts " #{Theme.ok('Generated')}"
|
|
140
|
+
end
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private_class_method def self.apply_enter(ctx, key, local_flat)
|
|
145
|
+
val = ctx.prompt.ask(' Value (input hidden):') { |q| q.echo(false) }
|
|
146
|
+
return false if val.blank?
|
|
147
|
+
|
|
148
|
+
local_flat[key] = val
|
|
149
|
+
puts " #{Theme.ok('Set')}"
|
|
150
|
+
true
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# rubocop:enable Naming/PredicateMethod
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module EasyCreds
|
|
8
|
+
module Actions
|
|
9
|
+
module EditorEdit
|
|
10
|
+
def self.call(ctx, local: false)
|
|
11
|
+
label = local ? "#{ctx.env}_local" : ctx.env
|
|
12
|
+
Theme.section("Editor — credentials/#{label}.yml.enc")
|
|
13
|
+
|
|
14
|
+
unless ctx.io.key_exists?(ctx.env, local: local)
|
|
15
|
+
Theme.failure('No local key. Run `init` or `pull` first.')
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
editor_cmd = resolve_editor
|
|
20
|
+
unless editor_cmd
|
|
21
|
+
Theme.failure("$VISUAL / $EDITOR is not set.\n Examples: export EDITOR=\"code --wait\"")
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
yaml_str = ctx.io.read_raw(ctx.env, local: local)
|
|
26
|
+
saved = edit_in_editor(ctx, label, yaml_str, editor_cmd, local: local)
|
|
27
|
+
Actions::Push.call(ctx) if !local && saved && ctx.prompt.yes?('Push changes to 1Password now?')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private_class_method def self.edit_in_editor(ctx, label, yaml_str, editor_cmd, local:)
|
|
31
|
+
saved = false
|
|
32
|
+
Tempfile.create(["credentials_#{label}_", '.yml']) do |f|
|
|
33
|
+
f.write(yaml_str)
|
|
34
|
+
f.flush
|
|
35
|
+
File.chmod(0o600, f.path)
|
|
36
|
+
puts " #{Theme.dim("#{editor_cmd.first} #{f.path}")}"
|
|
37
|
+
puts " #{Theme.dim('(save and close the file to continue)')}"
|
|
38
|
+
puts ''
|
|
39
|
+
system(*editor_cmd, f.path)
|
|
40
|
+
saved = apply_edit(ctx, f.path, yaml_str, label, local: local)
|
|
41
|
+
end
|
|
42
|
+
saved
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method def self.apply_edit(ctx, path, original, label, local:)
|
|
46
|
+
new_yaml = File.read(path)
|
|
47
|
+
begin
|
|
48
|
+
YAML.safe_load(new_yaml, permitted_classes: [Symbol, Date, Time])
|
|
49
|
+
rescue Psych::SyntaxError => e
|
|
50
|
+
Theme.failure("YAML syntax error — not saved: #{e.message}")
|
|
51
|
+
return false
|
|
52
|
+
end
|
|
53
|
+
return Theme.notice('No changes made') || false if new_yaml == original
|
|
54
|
+
|
|
55
|
+
ctx.io.write_raw(ctx.env, new_yaml, local: local)
|
|
56
|
+
Theme.success("Saved config/credentials/#{label}.yml.enc")
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method def self.resolve_editor
|
|
61
|
+
raw = ENV['VISUAL'].presence || ENV['EDITOR'].presence
|
|
62
|
+
return nil unless raw
|
|
63
|
+
|
|
64
|
+
Shellwords.split(raw)
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
[raw]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-spinner'
|
|
4
|
+
|
|
5
|
+
module EasyCreds
|
|
6
|
+
module Actions
|
|
7
|
+
module Init
|
|
8
|
+
def self.call(ctx)
|
|
9
|
+
Theme.section("Init — bootstrap credentials/#{ctx.env}.yml.enc")
|
|
10
|
+
return unless overwrite?(ctx)
|
|
11
|
+
|
|
12
|
+
template = load_template(ctx)
|
|
13
|
+
return unless template
|
|
14
|
+
|
|
15
|
+
remote_fields = fetch_remote(ctx)
|
|
16
|
+
state = build_state(template, remote_fields, ctx.config.ignore_keys)
|
|
17
|
+
run_navigator(state, ctx)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private_class_method def self.overwrite?(ctx)
|
|
21
|
+
return true unless ctx.io.enc_exists?(ctx.env)
|
|
22
|
+
|
|
23
|
+
Theme.warning("config/credentials/#{ctx.env}.yml.enc already exists.")
|
|
24
|
+
ctx.prompt.yes?('Continue and overwrite with a new file?', default: false)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private_class_method def self.load_template(ctx)
|
|
28
|
+
tpl = ctx.io.example_template
|
|
29
|
+
return tpl unless tpl.empty?
|
|
30
|
+
|
|
31
|
+
Theme.failure('config/credentials/example.yml not found. Cannot bootstrap.')
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method def self.fetch_remote(ctx)
|
|
36
|
+
spinner = TTY::Spinner.new('[:spinner] Checking 1Password for existing values...', format: :dots)
|
|
37
|
+
spinner.auto_spin
|
|
38
|
+
fields = Flatten.dot(ctx.op.item(ctx.env) || {})
|
|
39
|
+
spinner.stop('')
|
|
40
|
+
fields
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method def self.run_navigator(state, ctx)
|
|
44
|
+
puts ''
|
|
45
|
+
Theme.notice("Opening navigator — #{state.total} keys from example.yml")
|
|
46
|
+
puts ''
|
|
47
|
+
sleep 0.8
|
|
48
|
+
|
|
49
|
+
result = Views::InitTree.run(state, ctx)
|
|
50
|
+
result == :saved ? save!(state, ctx) : Theme.warning('Init aborted — no files written.')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method def self.build_state(template, remote_fields, ignore_keys)
|
|
54
|
+
entries = Flatten.dot(template).filter_map do |key, placeholder|
|
|
55
|
+
next if ignore_keys.include?(key)
|
|
56
|
+
|
|
57
|
+
Entry.new(
|
|
58
|
+
key: key,
|
|
59
|
+
placeholder: placeholder,
|
|
60
|
+
hint: Generators.hint_for(key),
|
|
61
|
+
op_value: remote_fields[key]
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
InitState.new(entries: entries, op_values: remote_fields)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private_class_method def self.save!(state, ctx)
|
|
68
|
+
env = ctx.env
|
|
69
|
+
result_flat = state.entries.reject { |e| e.source == :unset }.to_h { |e| [e.key, e.value] }
|
|
70
|
+
|
|
71
|
+
ctx.io.ensure_key!(env) unless ctx.io.key_exists?(env)
|
|
72
|
+
ensure_gitignore(ctx)
|
|
73
|
+
|
|
74
|
+
ctx.io.write(env, Flatten.unflatten(result_flat))
|
|
75
|
+
Theme.success("Wrote config/credentials/#{env}.yml.enc (#{result_flat.size} keys)")
|
|
76
|
+
|
|
77
|
+
offer_push(ctx, env)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private_class_method def self.ensure_gitignore(ctx)
|
|
81
|
+
return if ctx.io.gitignore_has_rule?
|
|
82
|
+
|
|
83
|
+
ctx.io.add_gitignore_rule!
|
|
84
|
+
Theme.notice('Added /config/credentials/*.key to .gitignore')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private_class_method def self.offer_push(ctx, env)
|
|
88
|
+
puts ''
|
|
89
|
+
Actions::Push.call(ctx) if ctx.prompt.yes?("Push these credentials to op://#{ctx.config.vault}/#{env} now?")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module EasyCreds
|
|
6
|
+
module Actions
|
|
7
|
+
module Local
|
|
8
|
+
module Delete
|
|
9
|
+
def self.call(ctx)
|
|
10
|
+
env = ctx.env
|
|
11
|
+
Theme.section("Local overlay — delete #{env}_local")
|
|
12
|
+
|
|
13
|
+
enc_exists = ctx.io.enc_exists?(env, local: true)
|
|
14
|
+
key_exists = ctx.io.key_exists?(env, local: true)
|
|
15
|
+
|
|
16
|
+
return Theme.notice("No local overlay files found for #{env}.") unless enc_exists || key_exists
|
|
17
|
+
|
|
18
|
+
preview_files(env, enc_exists, key_exists)
|
|
19
|
+
return Theme.notice('Cancelled') unless ctx.prompt.yes?('Are you sure?', default: false)
|
|
20
|
+
|
|
21
|
+
perform_delete(Pathname.new(ctx.root), env, enc_exists, key_exists)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private_class_method def self.preview_files(env, enc_exists, key_exists)
|
|
25
|
+
puts ''
|
|
26
|
+
puts ' This will permanently delete:'
|
|
27
|
+
puts " #{Theme.warn("config/credentials/#{env}_local.yml.enc")}" if enc_exists
|
|
28
|
+
puts " #{Theme.warn("config/credentials/#{env}_local.key")}" if key_exists
|
|
29
|
+
puts ''
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private_class_method def self.perform_delete(root, env, enc_exists, key_exists)
|
|
33
|
+
delete_file(root.join("config/credentials/#{env}_local.yml.enc"), "#{env}_local.yml.enc") if enc_exists
|
|
34
|
+
delete_file(root.join("config/credentials/#{env}_local.key"), "#{env}_local.key") if key_exists
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private_class_method def self.delete_file(path, label)
|
|
38
|
+
path.delete
|
|
39
|
+
Theme.success("Deleted #{label}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyCreds
|
|
4
|
+
module Actions
|
|
5
|
+
module Local
|
|
6
|
+
module Init
|
|
7
|
+
def self.call(ctx)
|
|
8
|
+
Theme.section("Local overlay — init #{ctx.env}_local")
|
|
9
|
+
|
|
10
|
+
if ctx.io.enc_exists?(ctx.env, local: true)
|
|
11
|
+
Theme.notice("#{ctx.env}_local.yml.enc already exists. Use `edit` or `editor-edit` to modify it.")
|
|
12
|
+
return
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
create_overlay!(ctx)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private_class_method def self.create_overlay!(ctx)
|
|
19
|
+
ctx.io.add_local_gitignore_rule!
|
|
20
|
+
ctx.io.ensure_key!(ctx.env, local: true)
|
|
21
|
+
ctx.io.write(ctx.env, {}, local: true)
|
|
22
|
+
|
|
23
|
+
puts ''
|
|
24
|
+
Theme.success("Created config/credentials/#{ctx.env}_local.yml.enc")
|
|
25
|
+
Theme.success("Created config/credentials/#{ctx.env}_local.key")
|
|
26
|
+
|
|
27
|
+
Local::EditorEdit.call(ctx) if ctx.prompt.yes?('Open in editor now?')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|