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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +51 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +165 -0
  5. data/exe/easy_creds +6 -0
  6. data/lib/easy_creds/actions/edit.rb +156 -0
  7. data/lib/easy_creds/actions/editor_edit.rb +70 -0
  8. data/lib/easy_creds/actions/init.rb +93 -0
  9. data/lib/easy_creds/actions/local/delete.rb +44 -0
  10. data/lib/easy_creds/actions/local/edit.rb +13 -0
  11. data/lib/easy_creds/actions/local/editor_edit.rb +13 -0
  12. data/lib/easy_creds/actions/local/init.rb +32 -0
  13. data/lib/easy_creds/actions/local/status.rb +49 -0
  14. data/lib/easy_creds/actions/local/sync_key.rb +63 -0
  15. data/lib/easy_creds/actions/local.rb +31 -0
  16. data/lib/easy_creds/actions/pull.rb +142 -0
  17. data/lib/easy_creds/actions/push.rb +149 -0
  18. data/lib/easy_creds/actions/status.rb +82 -0
  19. data/lib/easy_creds/cli.rb +147 -0
  20. data/lib/easy_creds/config.rb +52 -0
  21. data/lib/easy_creds/configuration.rb +91 -0
  22. data/lib/easy_creds/credentials_io.rb +128 -0
  23. data/lib/easy_creds/differ.rb +41 -0
  24. data/lib/easy_creds/doctor.rb +100 -0
  25. data/lib/easy_creds/env_picker.rb +24 -0
  26. data/lib/easy_creds/flatten.rb +25 -0
  27. data/lib/easy_creds/generators.rb +113 -0
  28. data/lib/easy_creds/init_state.rb +65 -0
  29. data/lib/easy_creds/installer.rb +55 -0
  30. data/lib/easy_creds/onboarding/gem_setup.rb +98 -0
  31. data/lib/easy_creds/onboarding/project_wizard.rb +106 -0
  32. data/lib/easy_creds/onboarding/register_prompt.rb +47 -0
  33. data/lib/easy_creds/onboarding/runner.rb +17 -0
  34. data/lib/easy_creds/onboarding/template_picker.rb +36 -0
  35. data/lib/easy_creds/overlay.rb +71 -0
  36. data/lib/easy_creds/project.rb +74 -0
  37. data/lib/easy_creds/projects/registry.rb +135 -0
  38. data/lib/easy_creds/provider.rb +30 -0
  39. data/lib/easy_creds/providers/base.rb +25 -0
  40. data/lib/easy_creds/providers/one_password.rb +187 -0
  41. data/lib/easy_creds/railtie.rb +10 -0
  42. data/lib/easy_creds/templates/files/default-beastmode.yml +33 -0
  43. data/lib/easy_creds/templates/files/microservice-minimal.yml +7 -0
  44. data/lib/easy_creds/templates/files/rails-api.yml +20 -0
  45. data/lib/easy_creds/templates/files/rails-fullstack.yml +37 -0
  46. data/lib/easy_creds/templates/registry.rb +62 -0
  47. data/lib/easy_creds/templates/renderer.rb +37 -0
  48. data/lib/easy_creds/theme.rb +129 -0
  49. data/lib/easy_creds/thor_cli.rb +299 -0
  50. data/lib/easy_creds/vault_picker.rb +56 -0
  51. data/lib/easy_creds/version.rb +5 -0
  52. data/lib/easy_creds/views/diff_table.rb +36 -0
  53. data/lib/easy_creds/views/header.rb +40 -0
  54. data/lib/easy_creds/views/init_dispatch.rb +132 -0
  55. data/lib/easy_creds/views/init_tree.rb +250 -0
  56. data/lib/easy_creds/views/local_menu.rb +38 -0
  57. data/lib/easy_creds/views/menu.rb +55 -0
  58. data/lib/easy_creds/views/project_picker.rb +56 -0
  59. data/lib/easy_creds/views/settings_menu.rb +108 -0
  60. data/lib/easy_creds/views/templates_menu.rb +142 -0
  61. data/lib/easy_creds/views/welcome_screen.rb +131 -0
  62. data/lib/easy_creds.rb +54 -0
  63. data/lib/tasks/easy_creds.rake +23 -0
  64. 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
+ [![Gem Version](https://badge.fury.io/rb/easy_creds.svg)](https://rubygems.org/gems/easy_creds)
6
+
7
+ **[💎 RubyGems](https://rubygems.org/gems/easy_creds)** &nbsp;|&nbsp; **[📖 Documentation](https://pniemczyk.github.io/easy_creds/)** &nbsp;|&nbsp; **[GitHub](https://github.com/pniemczyk/easy_creds)** &nbsp;|&nbsp; **[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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'easy_creds'
5
+
6
+ EasyCreds::ThorCLI.start(ARGV)
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Actions
5
+ module Local
6
+ module Edit
7
+ def self.call(ctx)
8
+ Actions::Edit.call(ctx, local: true)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Actions
5
+ module Local
6
+ module EditorEdit
7
+ def self.call(ctx)
8
+ Actions::EditorEdit.call(ctx, local: true)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ 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