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