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
|
@@ -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
|