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
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
4
+
5
+ require 'tty-reader'
6
+ require 'tty-cursor'
7
+ require 'tty-screen'
8
+
9
+ module EasyCreds
10
+ module Views
11
+ module InitTree
12
+ MAX_WIDTH = 120
13
+ MIN_LEFT_W = 32
14
+ MAX_LEFT_W = 54
15
+ HEADER_ROWS = 2
16
+ FOOTER_ROWS = 2
17
+
18
+ SOURCE_TAG = {
19
+ unset: ['○', 'unset', :dim],
20
+ template: ['▾', 'tmpl', :dim],
21
+ generate: ['⚡', 'gen', :ok],
22
+ generate_alt: ['⚡', 'alt', :ok],
23
+ from_op: ['☁', 'op', :info],
24
+ manual: ['✎', 'manual', :warn],
25
+ ar_batch: ['⚡', 'batch', :ok]
26
+ }.freeze
27
+
28
+ def self.run(state, ctx)
29
+ reader = TTY::Reader.new(interrupt: :signal)
30
+ show_help = false
31
+
32
+ print TTY::Cursor.hide
33
+ redraw(state, ctx, show_help: show_help)
34
+
35
+ loop do
36
+ key = reader.read_keypress(nonblock: false)
37
+ signal = InitDispatch.handle(key, state, root: ctx.root)
38
+
39
+ case signal
40
+ when :manual_entry
41
+ print TTY::Cursor.show
42
+ val = ctx.prompt.ask(' Value (input hidden): ') { |q| q.echo(false) }
43
+ print TTY::Cursor.hide
44
+ state.set_entry(state.current, value: val, source: :manual) if val.present?
45
+ when :toggle_help
46
+ show_help = !show_help
47
+ when :save
48
+ return :saved
49
+ when :quit
50
+ return :aborted unless state.dirty
51
+
52
+ print TTY::Cursor.show
53
+ system('clear')
54
+ return :aborted unless ctx.prompt.yes?(' Quit without saving?', default: false)
55
+
56
+ print TTY::Cursor.hide
57
+ end
58
+
59
+ redraw(state, ctx, show_help: show_help)
60
+ end
61
+ rescue Interrupt
62
+ :aborted
63
+ ensure
64
+ print TTY::Cursor.show
65
+ print TTY::Cursor.move_to(0, 0)
66
+ print TTY::Cursor.clear_screen_down
67
+ end
68
+
69
+ def self.build_tree_rows(entries)
70
+ seen_groups = {}
71
+ rows = []
72
+ entries.each do |entry|
73
+ parts = entry.key.split('.')
74
+ group = parts.size > 1 ? parts.first : nil
75
+ leaf = parts.size > 1 ? parts[1..].join('.') : parts.first
76
+ if group && !seen_groups[group]
77
+ seen_groups[group] = true
78
+ rows << { kind: :header, label: group }
79
+ end
80
+ indent = group ? 4 : 2
81
+ rows << { kind: :entry, label: leaf, entry: entry, indent: indent }
82
+ end
83
+ rows
84
+ end
85
+
86
+ def self.redraw(state, ctx, show_help:)
87
+ width = [TTY::Screen.width, MAX_WIDTH].min
88
+ height = TTY::Screen.height
89
+ content_rows = [height - HEADER_ROWS - FOOTER_ROWS, 4].max
90
+
91
+ # Right pane is wider (~56%) per design: left ratio 1.1, right 1.4
92
+ left_w = (width * 0.44).to_i.clamp(MIN_LEFT_W, MAX_LEFT_W)
93
+ right_w = width - left_w - 1
94
+
95
+ tree_rows = build_tree_rows(state.entries)
96
+ cursor_tree_i = tree_rows.index { |r| r[:kind] == :entry && r[:entry] == state.current } || 0
97
+ state.adjust_scroll(cursor_tree_i, content_rows)
98
+
99
+ visible_rows = tree_rows[state.scroll_offset, content_rows] || []
100
+ right_rows = show_help ? help_right_rows(right_w) : detail_right_rows(state, right_w)
101
+
102
+ buf = +''
103
+ buf << "\e[H\e[J"
104
+
105
+ # Traffic-light title bar
106
+ set_c = state.set_count == state.total ? state.set_count.to_s : state.set_count.to_s
107
+ status = "#{state.set_count}/#{state.total} set"
108
+ buf << "#{Theme.title_bar("init · #{ctx.env}", width, status: status)}\n"
109
+ buf << "#{Theme.box_h(width)}\n"
110
+
111
+ content_rows.times do |i|
112
+ left_row = build_left_row(visible_rows[i], state, left_w)
113
+ right_row = right_rows[i] || ''
114
+ buf << "#{left_row}#{Theme.box_side}#{rpad(right_row, right_w)}\n"
115
+ end
116
+
117
+ buf << "#{Theme.box_h(width)}\n"
118
+ buf << footer_line(state, width, show_help)
119
+
120
+ print buf
121
+ end
122
+
123
+ private_class_method def self.build_left_row(row_data, state, width)
124
+ return ' ' * width unless row_data
125
+
126
+ if row_data[:kind] == :header
127
+ label = Theme.dim(" ▾ #{row_data[:label]}")
128
+ return rpad(label, width)
129
+ end
130
+
131
+ entry = row_data[:entry]
132
+ indent = row_data[:indent]
133
+ cursor = entry == state.current
134
+
135
+ icon, tag_label, color_m = SOURCE_TAG.fetch(entry.source, SOURCE_TAG[:unset])
136
+ badge = "#{Theme.public_send(color_m, icon)} #{Theme.dim(tag_label)}"
137
+
138
+ prefix = cursor ? Theme.accent('› ') : ' '
139
+ leaf = row_data[:label]
140
+ leaf_w = width - indent - badge_visible_length - 4
141
+ line = (' ' * indent) + prefix + rpad(leaf, [leaf_w, 4].max) + badge
142
+
143
+ line
144
+ end
145
+
146
+ private_class_method def self.badge_visible_length
147
+ 8
148
+ end
149
+
150
+ private_class_method def self.detail_right_rows(state, width)
151
+ entry = state.current
152
+ rows = []
153
+
154
+ rows << ''
155
+ rows << " #{Theme.bold(entry.key)}"
156
+ rows << " #{Theme.dim('─' * [width - 4, 20].min)}"
157
+ rows << ''
158
+
159
+ icon, label, color_m = SOURCE_TAG.fetch(entry.source, SOURCE_TAG[:unset])
160
+ status_str = Theme.public_send(color_m, "#{icon} #{label}")
161
+ masked = Theme.dim(entry.masked_value || '—')
162
+ rows << " #{Theme.dim('Status ')} #{status_str} #{masked}"
163
+ rows << ''
164
+
165
+ rows << " #{Theme.dim('Hint ')} #{Theme.accent(":#{entry.hint}")}" if entry.hint
166
+
167
+ if entry.placeholder
168
+ ph_display = entry.placeholder.to_s[0, [width - 14, 20].max]
169
+ rows << " #{Theme.dim('Template')} #{Theme.dim(ph_display)}"
170
+ end
171
+
172
+ op_str = entry.op_value ? Theme.ok('available') : Theme.dim('—')
173
+ rows << " #{Theme.dim('Remote ')} #{op_str}"
174
+
175
+ rows << ''
176
+ rows << " #{Theme.dim('Actions ──────────────────')}"
177
+ is_ar = entry.hint == :ar_encryption
178
+ g_action = is_ar ? Theme.dim('generate (use [b] for AR)') : ('generate ' + Theme.dim('(default)'))
179
+ alt_action = is_ar ? Theme.dim('n/a') : ('alt generate ' + Theme.dim('(hex64)'))
180
+ rows << " #{Theme.kbd('g')} #{g_action}"
181
+ rows << " #{Theme.kbd('G')} #{alt_action}"
182
+ rows << " #{Theme.kbd('b')} #{Theme.ok('ar_encryption batch')} #{Theme.dim('(3 keys)')}"
183
+ rows << " #{Theme.kbd('t')} use template placeholder"
184
+ op_action = entry.op_value ? 'use 1Password value' : Theme.dim('1Password value (—)')
185
+ rows << " #{Theme.kbd('o')} #{op_action}"
186
+ rows << " #{Theme.kbd('m')} enter manually #{Theme.dim('(hidden)')}"
187
+ rows << " #{Theme.kbd('c')} clear / reset to unset"
188
+ rows
189
+ end
190
+
191
+ private_class_method def self.help_right_rows(width)
192
+ rows = []
193
+ rows << ''
194
+ rows << " #{Theme.bold('Keyboard shortcuts')}"
195
+ rows << " #{Theme.dim('─' * [width - 4, 20].min)}"
196
+ rows << ''
197
+ rows << " #{Theme.bold('Navigate')}"
198
+ rows << " #{Theme.kbd('↑')} #{Theme.dim('/')} #{Theme.kbd('↓')} #{Theme.dim('or')} #{Theme.kbd('k')} #{Theme.dim('/')} #{Theme.kbd('j')} move cursor"
199
+ rows << " #{Theme.kbd('PgUp')} #{Theme.kbd('PgDn')} jump 10 rows"
200
+ rows << " #{Theme.kbd('Home')} #{Theme.kbd('End')} first / last entry"
201
+ rows << ''
202
+ rows << " #{Theme.bold('Fill single entry')}"
203
+ rows << " #{Theme.kbd('g')} generate (default strategy)"
204
+ rows << " #{Theme.kbd('G')} alt generate #{Theme.dim('(SecureRandom hex64)')}"
205
+ rows << " #{Theme.kbd('b')} AR encryption batch #{Theme.dim('(3 keys)')}"
206
+ rows << " #{Theme.kbd('t')} use template placeholder"
207
+ rows << " #{Theme.kbd('o')} use 1Password remote value"
208
+ rows << " #{Theme.kbd('m')} enter manually #{Theme.dim('(hidden input)')}"
209
+ rows << " #{Theme.kbd('c')} clear / reset to unset"
210
+ rows << ''
211
+ rows << " #{Theme.bold('Fill all unset')}"
212
+ rows << " #{Theme.kbd('T')} template all"
213
+ rows << " #{Theme.kbd('R')} generate all with hints"
214
+ rows << " #{Theme.kbd('O')} 1Password fill all"
215
+ rows << ''
216
+ rows << " #{Theme.bold('Session')}"
217
+ rows << " #{Theme.kbd('s')} save and write .yml.enc"
218
+ rows << " #{Theme.kbd('q')} quit #{Theme.dim('(confirm if unsaved)')}"
219
+ rows << " #{Theme.kbd('?')} toggle this help"
220
+ rows
221
+ end
222
+
223
+ private_class_method def self.footer_line(state, width, show_help)
224
+ pct = state.total.zero? ? 0 : (state.set_count * 100 / state.total)
225
+ bar_w = 10
226
+ filled = (bar_w * pct / 100).round
227
+ bar = Theme.ok('█' * filled) + Theme.dim('░' * (bar_w - filled))
228
+ count = Theme.dim("#{state.set_count}/#{state.total}")
229
+
230
+ if show_help
231
+ hint = " #{bar} #{count} #{Theme.kbd('?')} #{Theme.dim('hide help')}\n"
232
+ else
233
+ keys = [
234
+ ['↑↓', 'nav'], ['g', 'gen'], ['G', 'alt'], ['b', 'batch'],
235
+ ['t', 'tmpl'], ['m', 'enter'], ['c', 'clear'], ['?', 'help']
236
+ ].map { |k, l| "#{Theme.kbd(k)} #{Theme.dim(l)}" }.join(' ')
237
+ hint = " #{bar} #{count} #{keys}\n"
238
+ end
239
+ hint
240
+ end
241
+
242
+ private_class_method def self.rpad(str, width)
243
+ vl = str.gsub(/\e\[[0-9;]*[mJHABCDsuK]/, '').length
244
+ str + (' ' * [width - vl, 0].max)
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Views
5
+ module LocalMenu
6
+ def self.ask(prompt, env)
7
+ choices = [
8
+ { name: " #{Theme.bold('status')} #{Theme.dim('─')} show local overlay file + key presence",
9
+ value: :status },
10
+ { name: " #{Theme.bold('init')} #{Theme.dim('─')} create #{env}_local.yml.enc + key",
11
+ value: :init },
12
+ { name: Theme.dim(' ─────────────────────────────────────────────'),
13
+ value: nil, disabled: '' },
14
+ { name: " #{Theme.bold('edit')} #{Theme.dim('─')} interactive key-by-key editor (local)",
15
+ value: :edit },
16
+ { name: " #{Theme.bold('editor-edit')} #{Theme.dim('─')} open local YAML in $EDITOR",
17
+ value: :editor_edit },
18
+ { name: " #{Theme.bold('sync-key')} #{Theme.dim('─')} push/pull #{env}_local.key to 1Password",
19
+ value: :sync_key },
20
+ { name: Theme.dim(' ─────────────────────────────────────────────'),
21
+ value: nil, disabled: '' },
22
+ { name: " #{Theme.dim("delete ─ remove #{env}_local.yml.enc + .key")}",
23
+ value: :delete },
24
+ { name: " #{Theme.dim('back ─ return to main menu')}",
25
+ value: :back }
26
+ ]
27
+
28
+ prompt.select(
29
+ "\nLocal overlay — #{Theme.env_tag(env)}:",
30
+ choices,
31
+ cycle: true,
32
+ filter: false,
33
+ per_page: choices.size
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Views
5
+ module Menu
6
+ def self.ask(prompt)
7
+ p = Theme.pastel
8
+
9
+ choices = [
10
+ # ── Inspect ───────────────────────────────────────────────────────
11
+ { name: Theme.dim(' ── Inspect ──────────────────────────────────'),
12
+ value: nil, disabled: '' },
13
+ { name: " #{Theme.bold('status')} #{Theme.dim('─')} show local vs 1Password drift",
14
+ value: :status },
15
+
16
+ # ── Sync ──────────────────────────────────────────────────────────
17
+ { name: Theme.dim(' ── Sync ─────────────────────────────────────'),
18
+ value: nil, disabled: '' },
19
+ { name: " #{Theme.bold('init')} #{Theme.dim('─')} bootstrap credentials from example.yml",
20
+ value: :init },
21
+ { name: " #{Theme.bold('push')} #{Theme.dim('─')} #{Theme::ICONS[:push]} local → 1Password",
22
+ value: :push },
23
+ { name: " #{Theme.bold('pull')} #{Theme.dim('─')} #{Theme::ICONS[:pull]} 1Password → local",
24
+ value: :pull },
25
+
26
+ # ── Edit ──────────────────────────────────────────────────────────
27
+ { name: Theme.dim(' ── Edit ─────────────────────────────────────'),
28
+ value: nil, disabled: '' },
29
+ { name: " #{Theme.bold('edit')} #{Theme.dim('─')} interactive key-by-key editor",
30
+ value: :edit },
31
+ { name: " #{Theme.bold('editor')} #{Theme.dim('─')} open decrypted YAML in $EDITOR",
32
+ value: :editor_edit },
33
+
34
+ # ── Other ─────────────────────────────────────────────────────────
35
+ { name: Theme.dim(' ─────────────────────────────────────────────'),
36
+ value: nil, disabled: '' },
37
+ { name: " #{Theme.bold('local')} #{Theme.dim('─')} manage encrypted local override",
38
+ value: :local },
39
+ { name: " #{Theme.bold('switch')} #{Theme.dim('─')} change environment",
40
+ value: :switch },
41
+ { name: " #{Theme.dim('quit')}",
42
+ value: :quit }
43
+ ]
44
+
45
+ prompt.select(
46
+ "\nWhat would you like to do?",
47
+ choices,
48
+ cycle: true,
49
+ filter: false,
50
+ per_page: choices.size
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Views
5
+ module ProjectPicker
6
+ # Returns a Projects::Project, :init_here, :templates, :settings, or :quit
7
+ def self.ask(prompt, registry)
8
+ choices = []
9
+
10
+ if registry.projects.any?
11
+ choices << { name: Theme.dim(' ── Registered projects ──────────────────────'),
12
+ value: nil, disabled: '' }
13
+ registry.projects
14
+ .sort_by { |pr| -(pr.last_seen_at&.to_i || 0) }
15
+ .each { |pr| choices << { name: project_row(pr), value: pr } }
16
+ end
17
+
18
+ choices << { name: Theme.dim(' ── Actions ──────────────────────────────────'),
19
+ value: nil, disabled: '' }
20
+ choices << { name: " #{Theme.bold('init here')} #{Theme.dim('─')} register current directory as a project",
21
+ value: :init_here }
22
+ choices << { name: " #{Theme.bold('templates')} #{Theme.dim('─')} manage global template library",
23
+ value: :templates }
24
+ choices << { name: " #{Theme.bold('settings')} #{Theme.dim('─')} edit global easy_creds config",
25
+ value: :settings }
26
+ choices << { name: " #{Theme.dim('quit')}", value: :quit }
27
+
28
+ prompt.select(
29
+ "\nWhat would you like to do?",
30
+ choices,
31
+ cycle: true,
32
+ filter: false,
33
+ per_page: [choices.size, 20].min
34
+ )
35
+ end
36
+
37
+ def self.project_row(pr)
38
+ miss = pr.exists_on_disk? ? '' : " #{Theme.error('(missing)')}"
39
+ kind = pr.rails? ? Theme.ok('rails') : Theme.accent('standalone')
40
+ seen = relative_time(pr.last_seen_at)
41
+ " #{Theme.bold(pr.name.ljust(20))} #{kind} #{Theme.dim(pr.display_path)} #{Theme.dim(seen)}#{miss}"
42
+ end
43
+
44
+ def self.relative_time(t)
45
+ return '—' unless t
46
+
47
+ secs = Time.now.utc - t.to_time.utc
48
+ return 'just now' if secs < 60
49
+ return "#{(secs / 60).to_i}m ago" if secs < 3_600
50
+ return "#{(secs / 3_600).to_i}h ago" if secs < 86_400
51
+
52
+ "#{(secs / 86_400).to_i}d ago"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
4
+
5
+ module EasyCreds
6
+ module Views
7
+ module SettingsMenu
8
+ def self.run(prompt)
9
+ loop do
10
+ cfg = EasyCreds.config
11
+ p = Theme.pastel
12
+
13
+ choice = prompt.select(
14
+ "\nGlobal settings:",
15
+ [
16
+ { name: " #{p.bold('default vault')} #{p.dim('─')} #{cfg.default_vault || p.dim('(not set)')}",
17
+ value: :vault },
18
+ { name: " #{p.bold('default template')} #{p.dim('─')} #{cfg.default_template}",
19
+ value: :template },
20
+ { name: " #{p.bold('default provider')} #{p.dim('─')} #{cfg.default_provider}",
21
+ value: :provider },
22
+ { name: " #{p.bold('editor')} #{p.dim('─')} #{cfg.editor || p.dim('($VISUAL/$EDITOR)')}",
23
+ value: :editor },
24
+ { name: " #{p.bold('test 1Password')} #{p.dim('─')} verify op CLI sign-in",
25
+ value: :op_test },
26
+ { name: " #{p.bold('show paths')} #{p.dim('─')} display config / template / project locations",
27
+ value: :paths },
28
+ { name: " #{p.bold('prune missing')} #{p.dim('─')} remove registry entries for deleted paths",
29
+ value: :prune },
30
+ { name: " #{p.dim('back')}", value: :back }
31
+ ],
32
+ cycle: true, per_page: 10
33
+ )
34
+
35
+ case choice
36
+ when :back then return
37
+ when :vault then update_setting(:default_vault, prompt_vault(prompt))
38
+ when :template then update_setting(:default_template, prompt_template(prompt))
39
+ when :provider then update_setting(:default_provider,
40
+ prompt.select('Provider:', [{ name: '1Password', value: :onepassword }]))
41
+ when :editor then update_setting(:editor,
42
+ prompt.ask('Editor command:', default: cfg.editor))
43
+ when :op_test then run_op_test
44
+ when :paths then show_paths(cfg)
45
+ when :prune then prune_projects(prompt)
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.update_setting(attr, value)
51
+ return unless value
52
+
53
+ EasyCreds.configure { |c| c.public_send(:"#{attr}=", value) }
54
+ path = File.join(EasyCreds.config.global_dir, 'config.yml')
55
+ Configuration.write_file!(EasyCreds.config, path)
56
+ Theme.success("#{attr} → #{value}")
57
+ end
58
+
59
+ def self.prompt_vault(prompt)
60
+ VaultPicker.pick(prompt)
61
+ end
62
+
63
+ def self.prompt_template(prompt)
64
+ registry = Templates::Registry.new(global_dir: EasyCreds.config.global_dir)
65
+ names = registry.list.map(&:to_s)
66
+ prompt.select('Select default template:', names, cycle: true)&.to_sym
67
+ end
68
+
69
+ def self.run_op_test
70
+ if system('op whoami > /dev/null 2>&1')
71
+ email = `op whoami --format=json 2>/dev/null`.then do |raw|
72
+ JSON.parse(raw)['email']
73
+ rescue StandardError
74
+ '(unknown)'
75
+ end
76
+ Theme.success("Signed in to 1Password as #{email}")
77
+ else
78
+ Theme.failure('Not signed in to 1Password. Run: op signin')
79
+ end
80
+ end
81
+
82
+ def self.show_paths(cfg)
83
+ p = Theme.pastel
84
+ puts ''
85
+ puts " #{p.dim('global dir')} #{cfg.global_dir}"
86
+ puts " #{p.dim('config.yml')} #{File.join(cfg.global_dir, 'config.yml')}"
87
+ puts " #{p.dim('templates')} #{cfg.resolved_templates_dir}"
88
+ puts " #{p.dim('projects.yml')} #{Projects::Registry.registry_path}"
89
+ puts ''
90
+ puts ' Press enter to continue...'
91
+ $stdin.gets
92
+ end
93
+
94
+ def self.prune_projects(prompt)
95
+ registry = Projects::Registry.load
96
+ gone = registry.prune_missing!
97
+
98
+ if gone.empty?
99
+ Theme.notice('No missing projects found.')
100
+ else
101
+ gone.each { |pr| Theme.success("Removed #{pr.name} (#{pr.path})") }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
4
+
5
+ module EasyCreds
6
+ module Views
7
+ module TemplatesMenu
8
+ def self.run(prompt)
9
+ loop do
10
+ registry = Templates::Registry.new(global_dir: EasyCreds.config.global_dir)
11
+ default = EasyCreds.config.default_template
12
+
13
+ choices = build_choices(registry, default)
14
+ choice = prompt.select("\nTemplate library:", choices, cycle: true, filter: false,
15
+ per_page: [choices.size, 20].min)
16
+
17
+ case choice
18
+ when nil then next
19
+ when :create then create_template(prompt, registry)
20
+ when :back then return
21
+ else
22
+ template_actions(prompt, registry, choice, default)
23
+ end
24
+ end
25
+ end
26
+
27
+ def self.build_choices(registry, default)
28
+ p = Theme.pastel
29
+ choices = [{ name: p.dim(' ── Bundled (read-only) ────────────────────────'),
30
+ value: nil, disabled: '' }]
31
+
32
+ registry.send(:bundled_names).sort.each do |name|
33
+ star = name == default ? " #{p.green('[default]')}" : ''
34
+ choices << { name: " #{p.bold(name.to_s.ljust(28))} #{p.dim('bundled')}#{star}", value: name }
35
+ end
36
+
37
+ user_names = registry.send(:user_names).sort
38
+ if user_names.any?
39
+ choices << { name: p.dim(' ── User templates ─────────────────────────────'),
40
+ value: nil, disabled: '' }
41
+ user_names.each do |name|
42
+ star = name == default ? " #{p.green('[default]')}" : ''
43
+ choices << { name: " #{p.bold(name.to_s.ljust(28))} #{p.dim('user')}#{star}", value: name }
44
+ end
45
+ end
46
+
47
+ choices << { name: p.dim(' ─────────────────────────────────────────────────'),
48
+ value: nil, disabled: '' }
49
+ choices << { name: " #{p.bold('create new')}", value: :create }
50
+ choices << { name: " #{p.dim('back')}", value: :back }
51
+ choices
52
+ end
53
+
54
+ def self.template_actions(prompt, registry, name, default)
55
+ p = Theme.pastel
56
+ is_user = registry.user_defined?(name)
57
+
58
+ actions = [
59
+ { name: " #{p.bold('set as default')} #{p.dim('─')} mark this as the default template", value: :set_default }
60
+ ]
61
+ actions << { name: " #{p.bold('edit ($EDITOR)')} #{p.dim('─')} open YAML in $EDITOR", value: :edit_editor }
62
+ actions << { name: " #{p.bold('duplicate')} #{p.dim('─')} copy to user templates dir", value: :duplicate }
63
+ actions << { name: " #{p.bold('delete')} #{p.dim('─')} remove (user templates only)", value: :delete } if is_user
64
+ actions << { name: " #{p.dim('back')}", value: :back }
65
+
66
+ action = prompt.select("Template: #{p.bold(name.to_s)}", actions, cycle: true)
67
+
68
+ case action
69
+ when :set_default then set_default(name)
70
+ when :edit_editor then edit_in_editor(registry, name)
71
+ when :duplicate then duplicate_template(prompt, registry, name)
72
+ when :delete then delete_template(prompt, registry, name)
73
+ end
74
+ end
75
+
76
+ def self.set_default(name)
77
+ EasyCreds.configure { |c| c.default_template = name }
78
+ path = File.join(EasyCreds.config.global_dir, 'config.yml')
79
+ Configuration.write_file!(EasyCreds.config, path)
80
+ Theme.success("Default template set to #{name}")
81
+ end
82
+
83
+ def self.edit_in_editor(registry, name)
84
+ path = prepare_user_copy(registry, name)
85
+ editor = EasyCreds.config.editor || ENV['VISUAL'] || ENV.fetch('EDITOR', 'vi')
86
+ system("#{editor} #{Shellwords.escape(path)}")
87
+ end
88
+
89
+ def self.prepare_user_copy(registry, name)
90
+ user_path = File.join(EasyCreds.config.global_dir, 'templates', "#{name}.yml")
91
+ return user_path if File.exist?(user_path)
92
+
93
+ bundled = registry.send(:bundled_path, name)
94
+ FileUtils.cp(bundled, user_path)
95
+ Theme.notice("Copied bundled template to #{user_path} — editing your copy.")
96
+ user_path
97
+ end
98
+
99
+ def self.duplicate_template(prompt, registry, name)
100
+ new_name = prompt.ask('New template name (used as filename):') do |q|
101
+ q.validate(/\A[a-z0-9_-]+\z/, 'Use lowercase letters, digits, hyphens, or underscores.')
102
+ end
103
+ return unless new_name
104
+
105
+ src = registry.path_for(name)
106
+ dest = File.join(EasyCreds.config.global_dir, 'templates', "#{new_name}.yml")
107
+ if File.exist?(dest)
108
+ Theme.failure("#{dest} already exists.")
109
+ return
110
+ end
111
+ FileUtils.cp(src, dest)
112
+ Theme.success("Created #{dest}")
113
+ end
114
+
115
+ def self.delete_template(prompt, registry, name)
116
+ return unless prompt.yes?("Delete template '#{name}'?", default: false)
117
+
118
+ registry.delete(name)
119
+ Theme.success("Deleted #{name}")
120
+ end
121
+
122
+ def self.create_template(prompt, _registry)
123
+ name = prompt.ask('New template name:') do |q|
124
+ q.validate(/\A[a-z0-9_-]+\z/, 'Use lowercase letters, digits, hyphens, or underscores.')
125
+ end
126
+ return unless name
127
+
128
+ dest = File.join(EasyCreds.config.global_dir, 'templates', "#{name}.yml")
129
+ if File.exist?(dest)
130
+ Theme.failure("#{dest} already exists.")
131
+ return
132
+ end
133
+ File.write(dest, "# #{name}\n# Add your credential keys below:\n")
134
+ editor = EasyCreds.config.editor || ENV['VISUAL'] || ENV.fetch('EDITOR', 'vi')
135
+ system("#{editor} #{Shellwords.escape(dest)}")
136
+ Theme.success("Created #{dest}")
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize