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,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyCreds
|
|
4
|
+
module Actions
|
|
5
|
+
module Local
|
|
6
|
+
module Status
|
|
7
|
+
def self.call(ctx)
|
|
8
|
+
env = ctx.env
|
|
9
|
+
Theme.section("Local overlay — #{env}_local")
|
|
10
|
+
|
|
11
|
+
enc_exists = ctx.io.enc_exists?(env, local: true)
|
|
12
|
+
key_exists = ctx.io.key_exists?(env, local: true)
|
|
13
|
+
gi_ok = ctx.io.gitignore_has_local_rule?
|
|
14
|
+
|
|
15
|
+
puts ''
|
|
16
|
+
print_status_rows(env, enc_exists, key_exists, gi_ok)
|
|
17
|
+
print_key_count(ctx) if enc_exists && key_exists
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private_class_method def self.print_status_rows(env, enc_exists, key_exists, gi_ok)
|
|
21
|
+
print_row("#{env}_local.yml.enc", enc_exists, presence(enc_exists))
|
|
22
|
+
print_row("#{env}_local.key ", key_exists, presence(key_exists))
|
|
23
|
+
print_row('.gitignore rule ', gi_ok, gi_label(gi_ok))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method def self.print_row(label, flag, value_str)
|
|
27
|
+
icon = flag ? Theme.ok(Theme::ICONS[:ok]) : Theme.warn(Theme::ICONS[:warn])
|
|
28
|
+
puts " #{icon} #{label} #{value_str}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private_class_method def self.presence(flag)
|
|
32
|
+
flag ? Theme.ok('present') : Theme.dim('absent')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method def self.gi_label(flag)
|
|
36
|
+
flag ? Theme.ok('present') : Theme.warn('missing — run init to add')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method def self.print_key_count(ctx)
|
|
40
|
+
puts ''
|
|
41
|
+
count = Flatten.dot(ctx.io.read(ctx.env, local: true)).size
|
|
42
|
+
puts " #{Theme.dim('keys in overlay:')} #{Theme.bold(count.to_s)}"
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
puts " #{Theme.warn('Could not read overlay:')} #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-spinner'
|
|
4
|
+
|
|
5
|
+
module EasyCreds
|
|
6
|
+
module Actions
|
|
7
|
+
module Local
|
|
8
|
+
module SyncKey
|
|
9
|
+
def self.call(ctx)
|
|
10
|
+
Theme.section("Local overlay — sync key #{ctx.env}_local")
|
|
11
|
+
|
|
12
|
+
direction = ctx.prompt.select(
|
|
13
|
+
'Sync direction:',
|
|
14
|
+
[
|
|
15
|
+
{ name: "push — upload #{ctx.env}_local.key to 1Password", value: :push },
|
|
16
|
+
{ name: 'pull — download key from 1Password', value: :pull },
|
|
17
|
+
{ name: 'cancel', value: :cancel }
|
|
18
|
+
],
|
|
19
|
+
cycle: false
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return if direction == :cancel
|
|
23
|
+
|
|
24
|
+
local_field = "#{ctx.env}_local"
|
|
25
|
+
direction == :push ? push_key(ctx, local_field) : pull_key(ctx, local_field)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private_class_method def self.push_key(ctx, local_field)
|
|
29
|
+
unless ctx.io.key_exists?(ctx.env, local: true)
|
|
30
|
+
Theme.failure("#{ctx.env}_local.key not found locally. Run `init` first.")
|
|
31
|
+
return
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
key_val = ctx.io.key_value(ctx.env, local: true)
|
|
35
|
+
spinner = TTY::Spinner.new('[:spinner] Pushing key to 1Password...', format: :dots)
|
|
36
|
+
spinner.auto_spin
|
|
37
|
+
ok = ctx.op.write_credentials_key(local_field, key_val, ctx.config.credentials_item)
|
|
38
|
+
spinner.stop('')
|
|
39
|
+
if ok
|
|
40
|
+
Theme.success("Key pushed to op://#{ctx.config.vault}/#{ctx.config.credentials_item}/#{local_field}")
|
|
41
|
+
else
|
|
42
|
+
Theme.failure('Failed to push key — check op CLI logs')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method def self.pull_key(ctx, local_field)
|
|
47
|
+
spinner = TTY::Spinner.new('[:spinner] Pulling key from 1Password...', format: :dots)
|
|
48
|
+
spinner.auto_spin
|
|
49
|
+
remote_key = ctx.op.read_credentials_key(local_field, ctx.config.credentials_item)
|
|
50
|
+
spinner.stop('')
|
|
51
|
+
|
|
52
|
+
unless remote_key
|
|
53
|
+
Theme.failure("Key not found at op://#{ctx.config.vault}/#{ctx.config.credentials_item}/#{local_field}")
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
ctx.io.write_key(ctx.env, remote_key, local: true)
|
|
58
|
+
Theme.success("Key pulled to config/credentials/#{ctx.env}_local.key")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyCreds
|
|
4
|
+
module Actions
|
|
5
|
+
module Local
|
|
6
|
+
def self.call(ctx)
|
|
7
|
+
loop do
|
|
8
|
+
Views::Header.render(ctx)
|
|
9
|
+
action = Views::LocalMenu.ask(ctx.prompt, ctx.env)
|
|
10
|
+
|
|
11
|
+
case action
|
|
12
|
+
when :status then Local::Status.call(ctx)
|
|
13
|
+
when :init then Local::Init.call(ctx)
|
|
14
|
+
when :edit then Local::Edit.call(ctx)
|
|
15
|
+
when :editor_edit then Local::EditorEdit.call(ctx)
|
|
16
|
+
when :sync_key then Local::SyncKey.call(ctx)
|
|
17
|
+
when :delete then Local::Delete.call(ctx)
|
|
18
|
+
when :back then break
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
next if action == :back
|
|
22
|
+
|
|
23
|
+
puts ''
|
|
24
|
+
ctx.prompt.keypress(" #{Theme.dim('Press any key to continue...')}", keys: %i[return space])
|
|
25
|
+
rescue TTY::Reader::InputInterrupt
|
|
26
|
+
break
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-spinner'
|
|
4
|
+
|
|
5
|
+
module EasyCreds
|
|
6
|
+
module Actions
|
|
7
|
+
module Pull
|
|
8
|
+
def self.call(ctx)
|
|
9
|
+
env = ctx.env
|
|
10
|
+
Theme.section("Pull — op://#{ctx.config.vault}/#{env} → local")
|
|
11
|
+
|
|
12
|
+
spinner = TTY::Spinner.new('[:spinner] Fetching from 1Password...', format: :dots)
|
|
13
|
+
spinner.auto_spin
|
|
14
|
+
remote_fields = ctx.op.item(env)
|
|
15
|
+
stored_key = ctx.op.read_credentials_key(env, ctx.config.credentials_item)
|
|
16
|
+
spinner.stop('')
|
|
17
|
+
|
|
18
|
+
unless remote_fields
|
|
19
|
+
Theme.failure("No item found at op://#{ctx.config.vault}/#{env}. Run `push` or `init` first.")
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
remote_hash = Flatten.unflatten(remote_fields)
|
|
24
|
+
if ctx.io.enc_exists?(env)
|
|
25
|
+
pull_with_diff(ctx, env, remote_hash, remote_fields, stored_key)
|
|
26
|
+
else
|
|
27
|
+
fresh_pull(ctx, env, remote_hash, remote_fields, stored_key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
offer_key_restore(ctx, env, stored_key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method def self.fresh_pull(ctx, env, remote_hash, remote_fields, stored_key)
|
|
34
|
+
count = remote_fields.size
|
|
35
|
+
return unless ctx.prompt.yes?("No local credentials for #{env}. Create from remote (#{count} fields)?")
|
|
36
|
+
|
|
37
|
+
ensure_key(ctx, env, stored_key)
|
|
38
|
+
ctx.io.write(env, remote_hash)
|
|
39
|
+
Theme.success("Created config/credentials/#{env}.yml.enc")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private_class_method def self.pull_with_diff(ctx, env, remote_hash, _remote_fields, stored_key)
|
|
43
|
+
local_hash = ctx.io.read(env)
|
|
44
|
+
changes = Differ.compare(local: local_hash, remote: remote_hash)
|
|
45
|
+
|
|
46
|
+
puts ''
|
|
47
|
+
if changes.empty?
|
|
48
|
+
Theme.success('Already in sync — nothing to pull')
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
puts ' Changes from remote:'
|
|
53
|
+
Views::DiffTable.render(changes)
|
|
54
|
+
puts ''
|
|
55
|
+
apply_pull(ctx, changes, local_hash, remote_hash, stored_key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method def self.apply_pull(ctx, changes, local_hash, remote_hash, stored_key)
|
|
59
|
+
env = ctx.env
|
|
60
|
+
action = ctx.prompt.select('Apply changes?', pull_choices, cycle: false)
|
|
61
|
+
case action
|
|
62
|
+
when :apply
|
|
63
|
+
ensure_key(ctx, env, stored_key)
|
|
64
|
+
ctx.io.write(env, remote_hash)
|
|
65
|
+
Theme.success("Pulled #{Flatten.dot(remote_hash).size} field(s)")
|
|
66
|
+
when :review
|
|
67
|
+
resolved = review_changes(ctx, changes, local_hash, remote_hash)
|
|
68
|
+
ensure_key(ctx, env, stored_key)
|
|
69
|
+
ctx.io.write(env, resolved)
|
|
70
|
+
Theme.success('Pulled resolved fields')
|
|
71
|
+
when :cancel
|
|
72
|
+
Theme.notice('Pull cancelled')
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method def self.pull_choices
|
|
77
|
+
[
|
|
78
|
+
{ name: 'apply — pull all changes', value: :apply },
|
|
79
|
+
{ name: 'review — resolve key by key', value: :review },
|
|
80
|
+
{ name: 'cancel', value: :cancel }
|
|
81
|
+
]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private_class_method def self.ensure_key(ctx, env, stored_key)
|
|
85
|
+
if stored_key && !ctx.io.key_exists?(env)
|
|
86
|
+
ctx.io.write_key(env, stored_key)
|
|
87
|
+
Theme.notice("Wrote config/credentials/#{env}.key from 1Password")
|
|
88
|
+
ensure_gitignore(ctx)
|
|
89
|
+
elsif !ctx.io.key_exists?(env)
|
|
90
|
+
ctx.io.ensure_key!(env)
|
|
91
|
+
Theme.notice("Generated new key at config/credentials/#{env}.key")
|
|
92
|
+
ensure_gitignore(ctx)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private_class_method def self.ensure_gitignore(ctx)
|
|
97
|
+
return if ctx.io.gitignore_has_rule?
|
|
98
|
+
|
|
99
|
+
ctx.io.add_gitignore_rule!
|
|
100
|
+
Theme.notice('Added /config/credentials/*.key to .gitignore')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private_class_method def self.offer_key_restore(ctx, env, stored_key)
|
|
104
|
+
return if ctx.io.key_exists?(env) || !stored_key
|
|
105
|
+
|
|
106
|
+
puts ''
|
|
107
|
+
Theme.notice('Encryption key found in 1Password but missing locally')
|
|
108
|
+
return unless ctx.prompt.yes?("Restore config/credentials/#{env}.key from 1Password?")
|
|
109
|
+
|
|
110
|
+
ctx.io.write_key(env, stored_key)
|
|
111
|
+
Theme.success('Key restored')
|
|
112
|
+
ensure_gitignore(ctx)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private_class_method def self.review_changes(ctx, changes, local_hash, remote_hash)
|
|
116
|
+
local_flat = Flatten.dot(local_hash)
|
|
117
|
+
remote_flat = Flatten.dot(remote_hash)
|
|
118
|
+
resolved = local_flat.dup
|
|
119
|
+
|
|
120
|
+
changes.each do |change|
|
|
121
|
+
action = prompt_change(ctx, change, local_flat, remote_flat)
|
|
122
|
+
resolved[change.key] = remote_flat[change.key] if action == :remote
|
|
123
|
+
resolved.delete(change.key) if action == :remote && remote_flat[change.key].nil?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
Flatten.unflatten(resolved)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private_class_method def self.prompt_change(ctx, change, local_flat, remote_flat)
|
|
130
|
+
puts ''
|
|
131
|
+
puts " #{Theme.bold(change.key)} #{Theme.dim("(#{change.kind})")}"
|
|
132
|
+
puts " #{Theme.dim('local: ')} #{local_flat[change.key] ? '(set)' : '(missing)'}"
|
|
133
|
+
puts " #{Theme.dim('remote:')} #{remote_flat[change.key] ? '(set)' : '(missing)'}"
|
|
134
|
+
ctx.prompt.select("Action for #{Theme.key_tag(change.key)}:", [
|
|
135
|
+
{ name: 'use remote value', value: :remote },
|
|
136
|
+
{ name: 'use local value', value: :local },
|
|
137
|
+
{ name: 'skip (keep local)', value: :skip }
|
|
138
|
+
], cycle: false)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-spinner'
|
|
4
|
+
|
|
5
|
+
module EasyCreds
|
|
6
|
+
module Actions
|
|
7
|
+
module Push
|
|
8
|
+
def self.call(ctx)
|
|
9
|
+
env = ctx.env
|
|
10
|
+
Theme.section("Push — local → op://#{ctx.config.vault}/#{env}")
|
|
11
|
+
|
|
12
|
+
unless ctx.io.key_exists?(env)
|
|
13
|
+
return Theme.failure("No local key (config/credentials/#{env}.key). Run `init` or `pull` first.")
|
|
14
|
+
end
|
|
15
|
+
unless ctx.io.enc_exists?(env)
|
|
16
|
+
return Theme.failure("No local credentials (config/credentials/#{env}.yml.enc). Run `init` first.")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
local_flat = Flatten.dot(ctx.io.read(env)).except(*ctx.config.ignore_keys)
|
|
20
|
+
vault_ok, item_ok = check_remote(ctx, env)
|
|
21
|
+
|
|
22
|
+
ensure_vault(ctx) unless vault_ok
|
|
23
|
+
item_ok ? update_existing(ctx, env, local_flat) : create_new(ctx, env, local_flat)
|
|
24
|
+
push_key_if_needed(ctx, env)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private_class_method def self.check_remote(ctx, env)
|
|
28
|
+
spinner = TTY::Spinner.new('[:spinner] Connecting to 1Password...', format: :dots)
|
|
29
|
+
spinner.auto_spin
|
|
30
|
+
vault_ok = ctx.op.vault_exists?
|
|
31
|
+
item_ok = vault_ok && ctx.op.item_exists?(env)
|
|
32
|
+
spinner.stop('')
|
|
33
|
+
[vault_ok, item_ok]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method def self.ensure_vault(ctx)
|
|
37
|
+
return unless ctx.prompt.yes?("Vault #{Theme.bold(ctx.config.vault)} does not exist. Create it?")
|
|
38
|
+
|
|
39
|
+
spinner = TTY::Spinner.new('[:spinner] Creating vault...', format: :dots)
|
|
40
|
+
spinner.auto_spin
|
|
41
|
+
ctx.op.create_vault(ctx.config.vault)
|
|
42
|
+
spinner.stop('')
|
|
43
|
+
Theme.success('Vault created')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_class_method def self.create_new(ctx, env, local_flat)
|
|
47
|
+
count = local_flat.size
|
|
48
|
+
return unless ctx.prompt.yes?("Item #{Theme.bold(env)} does not exist. Create with #{count} field(s)?")
|
|
49
|
+
|
|
50
|
+
spinner = TTY::Spinner.new('[:spinner] Creating item...', format: :dots)
|
|
51
|
+
spinner.auto_spin
|
|
52
|
+
ok = ctx.op.create_item(env, local_flat)
|
|
53
|
+
spinner.stop('')
|
|
54
|
+
ok ? Theme.success('Item created') : Theme.failure('Failed to create item.')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method def self.update_existing(ctx, env, local_flat)
|
|
58
|
+
remote_fields = ctx.op.item(env)
|
|
59
|
+
remote_hash = Flatten.unflatten(remote_fields || {})
|
|
60
|
+
changes = Differ.compare(local: Flatten.unflatten(local_flat), remote: remote_hash)
|
|
61
|
+
|
|
62
|
+
puts ''
|
|
63
|
+
if changes.empty?
|
|
64
|
+
Theme.success('Already in sync — nothing to push')
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts ' Changes to push:'
|
|
69
|
+
Views::DiffTable.render(changes)
|
|
70
|
+
puts ''
|
|
71
|
+
apply_push(ctx, env, local_flat, remote_fields || {}, changes)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private_class_method def self.apply_push(ctx, env, local_flat, remote_fields, changes)
|
|
75
|
+
action = ctx.prompt.select('Apply changes?', push_choices, cycle: false)
|
|
76
|
+
case action
|
|
77
|
+
when :apply
|
|
78
|
+
perform_update(ctx, env, remote_fields.merge(local_flat), 'Pushing...')
|
|
79
|
+
when :review
|
|
80
|
+
resolved = review_changes(ctx, changes, local_flat, remote_fields)
|
|
81
|
+
perform_update(ctx, env, resolved, 'Pushing resolved fields...')
|
|
82
|
+
when :cancel
|
|
83
|
+
Theme.notice('Push cancelled')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private_class_method def self.push_choices
|
|
88
|
+
[
|
|
89
|
+
{ name: 'apply — push all changes', value: :apply },
|
|
90
|
+
{ name: 'review — resolve key by key', value: :review },
|
|
91
|
+
{ name: 'cancel', value: :cancel }
|
|
92
|
+
]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method def self.perform_update(ctx, env, all_fields, msg)
|
|
96
|
+
spinner = TTY::Spinner.new("[:spinner] #{msg}", format: :dots)
|
|
97
|
+
spinner.auto_spin
|
|
98
|
+
ok = ctx.op.update_item(env, all_fields)
|
|
99
|
+
spinner.stop('')
|
|
100
|
+
ok ? Theme.success("Pushed #{all_fields.size} field(s)") : Theme.failure('Push failed.')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private_class_method def self.push_key_if_needed(ctx, env)
|
|
104
|
+
return unless ctx.io.key_exists?(env)
|
|
105
|
+
|
|
106
|
+
local_key = ctx.io.key_value(env)
|
|
107
|
+
stored_key = ctx.op.read_credentials_key(env, ctx.config.credentials_item)
|
|
108
|
+
return Theme.notice('Encryption key already in sync') if stored_key == local_key
|
|
109
|
+
|
|
110
|
+
store_key(ctx, env, local_key, stored_key)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private_class_method def self.store_key(ctx, env, local_key, stored_key)
|
|
114
|
+
label = stored_key ? 'Update' : 'Store'
|
|
115
|
+
cred_path = "op://#{ctx.config.vault}/#{ctx.config.credentials_item}/#{env}"
|
|
116
|
+
return unless ctx.prompt.yes?("#{label} encryption key in #{cred_path}?")
|
|
117
|
+
|
|
118
|
+
spinner = TTY::Spinner.new('[:spinner] Storing key...', format: :dots)
|
|
119
|
+
spinner.auto_spin
|
|
120
|
+
ok = ctx.op.write_credentials_key(env, local_key, ctx.config.credentials_item)
|
|
121
|
+
spinner.stop('')
|
|
122
|
+
ok ? Theme.success('Key stored') : Theme.failure('Key store failed')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private_class_method def self.review_changes(ctx, changes, local_flat, remote_fields)
|
|
126
|
+
resolved = remote_fields.dup
|
|
127
|
+
changes.each do |change|
|
|
128
|
+
action = prompt_change(ctx, change, local_flat, remote_fields)
|
|
129
|
+
resolved[change.key] = local_flat[change.key] if action == :local
|
|
130
|
+
resolved[change.key] = ctx.prompt.ask('New value (input hidden):') { |q| q.echo(false) } if action == :enter
|
|
131
|
+
end
|
|
132
|
+
resolved
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private_class_method def self.prompt_change(ctx, change, local_flat, remote_fields)
|
|
136
|
+
puts ''
|
|
137
|
+
puts " #{Theme.bold(change.key)} #{Theme.dim("(#{change.kind})")}"
|
|
138
|
+
puts " #{Theme.dim('local: ')} #{local_flat[change.key] ? '(set)' : '(missing)'}"
|
|
139
|
+
puts " #{Theme.dim('remote:')} #{remote_fields[change.key] ? '(set)' : '(missing)'}"
|
|
140
|
+
ctx.prompt.select("Action for #{Theme.key_tag(change.key)}:", [
|
|
141
|
+
{ name: 'use local value', value: :local },
|
|
142
|
+
{ name: 'use remote value', value: :remote },
|
|
143
|
+
{ name: 'enter new value', value: :enter },
|
|
144
|
+
{ name: 'skip (keep as-is)', value: :skip }
|
|
145
|
+
], cycle: false)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-spinner'
|
|
4
|
+
|
|
5
|
+
module EasyCreds
|
|
6
|
+
module Actions
|
|
7
|
+
module Status
|
|
8
|
+
def self.call(ctx)
|
|
9
|
+
env = ctx.env
|
|
10
|
+
Theme.section("Status — #{env}")
|
|
11
|
+
|
|
12
|
+
local_enc = ctx.io.enc_exists?(env)
|
|
13
|
+
local_key = ctx.io.key_exists?(env)
|
|
14
|
+
|
|
15
|
+
spinner = TTY::Spinner.new('[:spinner] Checking 1Password...', format: :dots)
|
|
16
|
+
spinner.auto_spin
|
|
17
|
+
remote_fields = ctx.op.item(env)
|
|
18
|
+
key_stored = ctx.op.read_credentials_key(env, ctx.config.credentials_item)
|
|
19
|
+
spinner.stop('')
|
|
20
|
+
|
|
21
|
+
puts ''
|
|
22
|
+
print_local_status(env, local_enc, local_key)
|
|
23
|
+
print_remote_status(ctx, env, remote_fields, key_stored)
|
|
24
|
+
print_drift(ctx, env, local_enc, local_key, remote_fields)
|
|
25
|
+
print_overlay_status(ctx, env)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private_class_method def self.print_local_status(env, local_enc, local_key)
|
|
29
|
+
print_file_row("config/credentials/#{env}.yml.enc", local_enc)
|
|
30
|
+
print_file_row("config/credentials/#{env}.key", local_key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method def self.print_remote_status(ctx, env, remote_fields, key_stored)
|
|
34
|
+
vault = ctx.config.vault
|
|
35
|
+
item_path = "op://#{vault}/#{env}"
|
|
36
|
+
key_path = "op://#{vault}/#{ctx.config.credentials_item}/#{env}"
|
|
37
|
+
|
|
38
|
+
if remote_fields
|
|
39
|
+
puts " #{Theme.ok(Theme::ICONS[:ok])} remote #{item_path} #{Theme.dim("(#{remote_fields.size} fields)")}"
|
|
40
|
+
else
|
|
41
|
+
puts " #{Theme.warn(Theme::ICONS[:warn])} remote #{item_path} #{Theme.dim('(not found)')}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if key_stored
|
|
45
|
+
puts " #{Theme.ok(Theme::ICONS[:ok])} key #{key_path} #{Theme.dim('(stored)')}"
|
|
46
|
+
else
|
|
47
|
+
puts " #{Theme.warn(Theme::ICONS[:warn])} key #{key_path} #{Theme.dim('(not stored)')}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private_class_method def self.print_drift(ctx, env, local_enc, local_key, remote_fields)
|
|
52
|
+
puts ''
|
|
53
|
+
unless local_enc && local_key && remote_fields
|
|
54
|
+
Theme.notice('Cannot compare — missing local credentials or remote item')
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local_hash = ctx.io.read(env)
|
|
59
|
+
changes = Differ.compare(local: local_hash, remote: Flatten.unflatten(remote_fields))
|
|
60
|
+
if changes.empty?
|
|
61
|
+
Theme.success('in sync')
|
|
62
|
+
else
|
|
63
|
+
puts " #{Theme.warn('drift:')} #{Theme.dim("(#{changes.size} change(s))")}"
|
|
64
|
+
puts ''
|
|
65
|
+
Views::DiffTable.render(changes)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method def self.print_overlay_status(ctx, env)
|
|
70
|
+
puts ''
|
|
71
|
+
overlay = ctx.io.enc_exists?(env, local: true) && ctx.io.key_exists?(env, local: true)
|
|
72
|
+
label = overlay ? Theme.ok("present (#{env}_local.yml.enc)") : Theme.dim('absent')
|
|
73
|
+
puts " #{Theme.dim('local overlay:')} #{label}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method def self.print_file_row(label, exists)
|
|
77
|
+
icon = exists ? Theme.ok(Theme::ICONS[:ok]) : Theme.warn(Theme::ICONS[:warn])
|
|
78
|
+
puts " #{icon} local #{label}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-prompt'
|
|
4
|
+
require 'tty-screen'
|
|
5
|
+
|
|
6
|
+
module EasyCreds
|
|
7
|
+
Context = Data.define(:env, :config, :op, :io, :theme, :prompt, :root)
|
|
8
|
+
|
|
9
|
+
class CLI
|
|
10
|
+
def self.start(project: nil, default_env: nil)
|
|
11
|
+
root = project&.root || detect_root
|
|
12
|
+
new(root: root, default_env: default_env).run
|
|
13
|
+
rescue Interrupt
|
|
14
|
+
puts "\n\n #{Theme.dim('Bye!')}\n"
|
|
15
|
+
exit 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(root:, default_env: nil)
|
|
19
|
+
@root = Pathname.new(root)
|
|
20
|
+
@default_env = default_env
|
|
21
|
+
@config = ConfigLoader.load(@root)
|
|
22
|
+
@io = CredentialsIO.new(@root)
|
|
23
|
+
@prompt = TTY::Prompt.new(interrupt: :exit, symbols: { selector: '›' })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
check_op_cli!
|
|
28
|
+
|
|
29
|
+
envs = EnvPicker.available(@root)
|
|
30
|
+
abort 'No environments found in config/environments/.' if envs.empty?
|
|
31
|
+
|
|
32
|
+
provider = build_op_client
|
|
33
|
+
env = pick_env(envs, provider)
|
|
34
|
+
main_loop(env, provider, envs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def check_op_cli!
|
|
40
|
+
return if system('which op > /dev/null 2>&1')
|
|
41
|
+
|
|
42
|
+
abort " #{Theme.error('op CLI not found.')} Install with: brew install 1password-cli"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_op_client
|
|
46
|
+
vault = @config.vault || EasyCreds.config.default_vault || prompt_vault
|
|
47
|
+
op = Providers::OnePassword.new(vault: vault, log_dir: @root.join('log'))
|
|
48
|
+
until op.signed_in?
|
|
49
|
+
puts "\n #{Theme.warn('Not signed in to 1Password.')}"
|
|
50
|
+
puts " Run #{Theme.bold('op signin')} in another terminal, then press Enter..."
|
|
51
|
+
$stdin.gets
|
|
52
|
+
end
|
|
53
|
+
op
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def prompt_vault
|
|
57
|
+
vaults = list_vaults
|
|
58
|
+
if vaults.any?
|
|
59
|
+
@prompt.select('Select vault:', vaults, cycle: true)
|
|
60
|
+
else
|
|
61
|
+
@prompt.ask('Enter vault name:')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def list_vaults
|
|
66
|
+
raw = `op vault list --format=json 2>/dev/null`
|
|
67
|
+
return [] unless $CHILD_STATUS.success?
|
|
68
|
+
|
|
69
|
+
JSON.parse(raw).map { |v| v['name'] }
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pick_env(envs, provider)
|
|
75
|
+
# Skip the interactive picker when a valid env was passed on the command line.
|
|
76
|
+
if @default_env && envs.include?(@default_env)
|
|
77
|
+
Views::Header.render(build_context(env: @default_env, provider: provider))
|
|
78
|
+
return @default_env
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Views::Header.render(build_context(env: nil, provider: provider))
|
|
82
|
+
@prompt.select(
|
|
83
|
+
'Select environment:',
|
|
84
|
+
envs,
|
|
85
|
+
default: EnvPicker.default_index(envs, @config.default_env) + 1,
|
|
86
|
+
cycle: true
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def main_loop(env, provider, envs)
|
|
91
|
+
loop do
|
|
92
|
+
ctx = build_context(env: env, provider: provider)
|
|
93
|
+
action = render_and_pick(ctx)
|
|
94
|
+
|
|
95
|
+
case action
|
|
96
|
+
when :status then Actions::Status.call(ctx)
|
|
97
|
+
when :push then Actions::Push.call(ctx)
|
|
98
|
+
when :pull then Actions::Pull.call(ctx)
|
|
99
|
+
when :init then Actions::Init.call(ctx)
|
|
100
|
+
when :edit then Actions::Edit.call(ctx)
|
|
101
|
+
when :editor_edit then Actions::EditorEdit.call(ctx)
|
|
102
|
+
when :local then Actions::Local.call(ctx)
|
|
103
|
+
when :switch
|
|
104
|
+
envs = EnvPicker.available(@root)
|
|
105
|
+
env = @prompt.select('Select environment:', envs, cycle: true)
|
|
106
|
+
next
|
|
107
|
+
when :quit
|
|
108
|
+
puts "\n #{Theme.dim('Bye!')}\n"
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
pause
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_and_pick(ctx)
|
|
117
|
+
Views::Header.render(ctx)
|
|
118
|
+
Views::Menu.ask(@prompt)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def pause
|
|
122
|
+
puts ''
|
|
123
|
+
@prompt.keypress(" #{Theme.dim('Press any key to continue...')}", keys: %i[return space])
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_context(env:, provider:)
|
|
129
|
+
Context.new(
|
|
130
|
+
env: env,
|
|
131
|
+
config: @config,
|
|
132
|
+
op: provider,
|
|
133
|
+
io: @io,
|
|
134
|
+
theme: Theme,
|
|
135
|
+
prompt: @prompt,
|
|
136
|
+
root: @root
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.detect_root
|
|
141
|
+
dir = Pathname.new(Dir.pwd)
|
|
142
|
+
dir = dir.parent until dir.root? || dir.join('config/application.rb').exist?
|
|
143
|
+
dir.root? ? Pathname.new(Dir.pwd) : dir
|
|
144
|
+
end
|
|
145
|
+
private_class_method :detect_root
|
|
146
|
+
end
|
|
147
|
+
end
|