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,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'logger' # stdlib
5
+ require 'pathname'
6
+ require 'tempfile'
7
+
8
+ module EasyCreds
9
+ module Providers
10
+ class OnePassword < Base
11
+ Result = Data.define(:ok, :stdout, :stderr)
12
+
13
+ attr_reader :vault
14
+
15
+ def initialize(vault:, runner: nil, log_dir: nil, **)
16
+ super()
17
+ @vault = vault
18
+ @runner = runner || method(:shell_run)
19
+ @logger = build_logger(log_dir)
20
+ end
21
+
22
+ def signed_in?
23
+ result = run('op', 'whoami', '--format=json')
24
+ return false unless result.ok
25
+
26
+ data = JSON.parse(result.stdout)
27
+ data.key?('account_uuid') || data.key?('email')
28
+ rescue JSON::ParserError
29
+ false
30
+ end
31
+
32
+ def account_email
33
+ result = run('op', 'whoami', '--format=json')
34
+ return nil unless result.ok
35
+
36
+ JSON.parse(result.stdout)['email']
37
+ rescue JSON::ParserError
38
+ nil
39
+ end
40
+
41
+ def vault_exists?
42
+ result = run('op', 'vault', 'list', '--format=json')
43
+ return false unless result.ok
44
+
45
+ vaults = JSON.parse(result.stdout)
46
+ vaults.any? { |v| v['name'] == @vault }
47
+ rescue JSON::ParserError
48
+ false
49
+ end
50
+
51
+ def create_vault(name)
52
+ run('op', 'vault', 'create', name).ok
53
+ end
54
+
55
+ def item(env)
56
+ result = run('op', 'item', 'get', env_item(env), "--vault=#{@vault}", '--format=json')
57
+ return nil unless result.ok
58
+
59
+ fields_from_json(result.stdout)
60
+ rescue JSON::ParserError
61
+ nil
62
+ end
63
+
64
+ def item_exists?(env)
65
+ run('op', 'item', 'get', env_item(env), "--vault=#{@vault}", '--format=json').ok
66
+ end
67
+
68
+ def create_item(env, fields)
69
+ with_template_file(build_template(env_item(env), fields)) do |path|
70
+ run('op', 'item', 'create', "--vault=#{@vault}", "--template=#{path}")
71
+ end.ok
72
+ end
73
+
74
+ def update_item(env, all_fields)
75
+ update_named_item(env_item(env), all_fields)
76
+ end
77
+
78
+ def read_credentials_key(env, credentials_item)
79
+ result = run('op', 'read', "op://#{@vault}/#{credentials_item}/#{env}")
80
+ result.ok ? result.stdout.strip : nil
81
+ end
82
+
83
+ def write_credentials_key(env, key_value, credentials_item)
84
+ existing = run('op', 'item', 'get', credentials_item, "--vault=#{@vault}", '--format=json')
85
+
86
+ if existing.ok
87
+ all_fields = fields_from_json(existing.stdout).merge(env => key_value)
88
+ update_named_item(credentials_item, all_fields)
89
+ else
90
+ with_template_file(build_template(credentials_item, { env => key_value })) do |path|
91
+ run('op', 'item', 'create', "--vault=#{@vault}", "--template=#{path}")
92
+ end.ok
93
+ end
94
+ rescue JSON::ParserError => e
95
+ log(:error, "JSON parse error in write_credentials_key: #{e.message}")
96
+ false
97
+ end
98
+
99
+ private
100
+
101
+ def env_item(env) = env.to_s
102
+
103
+ def run(*cmd)
104
+ @runner.call(*cmd)
105
+ end
106
+
107
+ def shell_run(*cmd)
108
+ log(:debug, "$ #{cmd.join(' ')}")
109
+ stdout_r, stdout_w = IO.pipe
110
+ stderr_r, stderr_w = IO.pipe
111
+ pid = spawn(*cmd, out: stdout_w, err: stderr_w)
112
+ stdout_w.close
113
+ stderr_w.close
114
+ stdout_str = stdout_r.read
115
+ stderr_str = stderr_r.read
116
+ stdout_r.close
117
+ stderr_r.close
118
+ _, status = Process.wait2(pid)
119
+ log_shell_result(status, stdout_str, stderr_str)
120
+ Result.new(ok: status.success?, stdout: stdout_str, stderr: stderr_str)
121
+ end
122
+
123
+ def log_shell_result(status, stdout_str, stderr_str)
124
+ if status.success?
125
+ log(:debug, ' exit 0')
126
+ else
127
+ log(:error, " exit #{status.exitstatus}")
128
+ log(:error, " stderr: #{stderr_str.strip}") unless stderr_str.strip.empty?
129
+ log(:error, " stdout: #{stdout_str[0, 500].strip}") unless stdout_str.strip.empty?
130
+ end
131
+ end
132
+
133
+ def update_named_item(name, all_fields)
134
+ log(:debug, "update_named_item: deleting '#{name}' then recreating")
135
+ del = run('op', 'item', 'delete', name, "--vault=#{@vault}")
136
+ log(:warn, "delete '#{name}' failed: #{del.stderr.strip}") unless del.ok
137
+ with_template_file(build_template(name, all_fields)) do |path|
138
+ run('op', 'item', 'create', "--vault=#{@vault}", "--template=#{path}")
139
+ end.ok
140
+ end
141
+
142
+ def fields_from_json(json_str)
143
+ data = JSON.parse(json_str)
144
+ fields = data['fields'] || []
145
+ fields.each_with_object({}) do |field, acc|
146
+ label = field['label']
147
+ value = field['value']
148
+ acc[label] = value if label.present?
149
+ end
150
+ end
151
+
152
+ def build_template(title, fields)
153
+ {
154
+ 'title' => title,
155
+ 'category' => 'SECURE_NOTE',
156
+ 'fields' => fields.map do |label, value|
157
+ { 'label' => label.to_s, 'type' => 'CONCEALED', 'value' => value.to_s }
158
+ end
159
+ }
160
+ end
161
+
162
+ def with_template_file(template)
163
+ Tempfile.create(['op_template', '.json']) do |f|
164
+ f.write(template.to_json)
165
+ f.flush
166
+ File.chmod(0o600, f.path)
167
+ yield f.path
168
+ end
169
+ end
170
+
171
+ def build_logger(log_dir)
172
+ return nil unless log_dir
173
+
174
+ dir = Pathname.new(log_dir)
175
+ dir.mkpath
176
+ l = ::Logger.new(dir.join('easy_creds.log'), 'daily')
177
+ l.level = ::Logger::DEBUG
178
+ l.formatter = proc { |sev, time, _, msg| "[#{time.strftime('%F %T')}] #{sev}: #{msg}\n" }
179
+ l
180
+ end
181
+
182
+ def log(level, msg)
183
+ @logger&.public_send(level, msg)
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ # Rails integration — loaded automatically when Rails::Railtie is defined.
5
+ # Registers the credentials:sync, easy_creds:install, and easy_creds:onboard
6
+ # rake tasks without booting the full Rails environment upfront.
7
+ class Railtie < ::Rails::Railtie
8
+ rake_tasks { load File.expand_path('../../tasks/easy_creds.rake', __dir__) }
9
+ end
10
+ end
@@ -0,0 +1,33 @@
1
+ # Application domain
2
+ app:
3
+ host: example.com
4
+ # cdn_host: cdn.example.com # set for staging/prod
5
+ allowed_hosts:
6
+ - example.com
7
+ - www.example.com
8
+ # - staging.example.com # set for staging
9
+
10
+ # CORS / API access
11
+ cors:
12
+ origins:
13
+ - https://example.com
14
+ - https://www.example.com
15
+ # - https://staging.example.com # staging only
16
+
17
+ # Database (rails fullstack)
18
+ database:
19
+ url: postgres://localhost:5432/__APP_NAME___development
20
+ # url: postgres://prod-host:5432/__APP_NAME___production # production
21
+
22
+ # Secrets — fill via TUI on first sync
23
+ secret_key_base:
24
+ active_record_encryption:
25
+ primary_key:
26
+ deterministic_key:
27
+ key_derivation_salt:
28
+
29
+ # Third-party (examples)
30
+ stripe:
31
+ publishable_key:
32
+ secret_key:
33
+ webhook_secret:
@@ -0,0 +1,7 @@
1
+ secret_key_base: <CHANGE_ME>
2
+
3
+ databases:
4
+ primary:
5
+ url: postgres://service:password@localhost/service_production
6
+
7
+ api_key: <CHANGE_ME>
@@ -0,0 +1,20 @@
1
+ secret_key_base: <CHANGE_ME>
2
+
3
+ databases:
4
+ primary:
5
+ url: postgres://myapp:password@localhost/myapp_production
6
+
7
+ app:
8
+ host: api.myapp.example.com
9
+
10
+ active_record_encryption:
11
+ primary_key: <CHANGE_ME>
12
+ deterministic_key: <CHANGE_ME>
13
+ key_derivation_salt: <CHANGE_ME>
14
+
15
+ crypt_secret: <CHANGE_ME>
16
+
17
+ oauth:
18
+ google:
19
+ client_id: <CHANGE_ME>
20
+ client_secret: <CHANGE_ME>
@@ -0,0 +1,37 @@
1
+ secret_key_base: <CHANGE_ME>
2
+
3
+ databases:
4
+ primary:
5
+ host: localhost
6
+ port: 5432
7
+ name: myapp_production
8
+ username: myapp
9
+ password: <CHANGE_ME>
10
+
11
+ active_record_encryption:
12
+ primary_key: <CHANGE_ME>
13
+ deterministic_key: <CHANGE_ME>
14
+ key_derivation_salt: <CHANGE_ME>
15
+
16
+ crypt_secret: <CHANGE_ME>
17
+
18
+ app:
19
+ host: myapp.example.com
20
+ name: MyApp
21
+
22
+ mailer:
23
+ from: noreply@myapp.example.com
24
+
25
+ oauth:
26
+ google:
27
+ client_id: <CHANGE_ME>
28
+ client_secret: <CHANGE_ME>
29
+
30
+ storage:
31
+ access_key_id: <CHANGE_ME>
32
+ secret_access_key: <CHANGE_ME>
33
+ region: us-east-1
34
+ bucket: myapp-production
35
+
36
+ redis:
37
+ url: redis://localhost:6379/0
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Templates
5
+ class Registry
6
+ BUNDLED_DIR = File.expand_path('files', __dir__).freeze
7
+
8
+ def initialize(global_dir: nil)
9
+ @global_dir = global_dir
10
+ end
11
+
12
+ def list
13
+ (bundled_names + user_names).uniq
14
+ end
15
+
16
+ def load(name)
17
+ path = user_path(name) || bundled_path(name)
18
+ raise ArgumentError, "Template '#{name}' not found" unless path
19
+
20
+ YAML.safe_load_file(path) || {}
21
+ end
22
+
23
+ def user_defined?(name)
24
+ user_path(name) ? true : false
25
+ end
26
+
27
+ def path_for(name)
28
+ user_path(name) || bundled_path(name)
29
+ end
30
+
31
+ def delete(name)
32
+ raise ArgumentError, "Cannot delete bundled template '#{name}'" unless user_defined?(name)
33
+
34
+ File.delete(user_path(name))
35
+ end
36
+
37
+ private
38
+
39
+ def bundled_names
40
+ Dir[File.join(BUNDLED_DIR, '*.yml')].map { |p| File.basename(p, '.yml').to_sym }
41
+ end
42
+
43
+ def user_names
44
+ return [] unless @global_dir
45
+
46
+ Dir[File.join(@global_dir, 'templates', '*.yml')].map { |p| File.basename(p, '.yml').to_sym }
47
+ end
48
+
49
+ def user_path(name)
50
+ return unless @global_dir
51
+
52
+ path = File.join(@global_dir, 'templates', "#{name}.yml")
53
+ File.exist?(path) ? path : nil
54
+ end
55
+
56
+ def bundled_path(name)
57
+ path = File.join(BUNDLED_DIR, "#{name}.yml")
58
+ File.exist?(path) ? path : nil
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Templates
5
+ class Renderer
6
+ def initialize(template)
7
+ @template = template
8
+ end
9
+
10
+ def customise(prompt)
11
+ return @template if @template.empty?
12
+
13
+ result = {}
14
+ @template.each do |section, value|
15
+ next unless prompt.yes?("Include section '#{section}'?", default: true)
16
+
17
+ result[section] = value.is_a?(Hash) ? customise_section(section, value, prompt) : value
18
+ end
19
+ result
20
+ end
21
+
22
+ private
23
+
24
+ def customise_section(section, hash, prompt)
25
+ hash.transform_values do |val|
26
+ val.is_a?(String) ? maybe_placeholder(section, val, prompt) : val
27
+ end
28
+ end
29
+
30
+ def maybe_placeholder(section, val, prompt)
31
+ return val unless val.to_s.start_with?('<', 'CHANGE_')
32
+
33
+ prompt.yes?("Keep placeholder for '#{section}'?", default: true) ? val : nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'tty-screen'
5
+
6
+ module EasyCreds
7
+ module Theme
8
+ module_function
9
+
10
+ # Calvin S figlet — "easy_creds" in 3 rows × 29 cols
11
+ BANNER_ART = [
12
+ '┌─┐┌─┐┌─┐┬ ┬ ┌─┐┬─┐┌─┐ ┬ ┌─┐',
13
+ '├─ ├─┤└─┐└┬┘ │ ├┬ ├─ ┌─┤└─┐',
14
+ '└─┘└─┘└─┘ ┴ └─┘┴ └─┘└─┘└─┘'
15
+ ].freeze
16
+
17
+ ICONS = {
18
+ ok: '✔',
19
+ warn: '⚠',
20
+ error: '✖',
21
+ info: 'ℹ',
22
+ push: '↑',
23
+ pull: '↓',
24
+ modified: '⇅',
25
+ added: '↗',
26
+ removed: '↙',
27
+ lock: '⚙',
28
+ vault: '▪',
29
+ key: '◆',
30
+ sync: '↔'
31
+ }.freeze
32
+
33
+ SPINNERS = %w[⠋ ⠙ ⠸ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
34
+
35
+ def pastel
36
+ @pastel ||= Pastel.new
37
+ end
38
+
39
+ # 24-bit true-color helpers — Midnight design system
40
+ def ok(str) = "\e[38;2;140;227;156m#{str}\e[0m" # #8ce39c green
41
+ def warn(str) = "\e[38;2;245;207;123m#{str}\e[0m" # #f5cf7b amber
42
+ def error(str) = "\e[38;2;255;122;139m#{str}\e[0m" # #ff7a8b red
43
+ def accent(str) = "\e[38;2;122;162;255m#{str}\e[0m" # #7aa2ff blue
44
+ def info(str) = accent(str)
45
+ def dim(str) = pastel.dim(str)
46
+ def bold(str) = pastel.bold(str)
47
+ def header(str) = "\e[1m\e[38;2;122;162;255m#{str}\e[0m"
48
+ def env_tag(str) = accent(str)
49
+ def key_tag(str) = accent(str)
50
+
51
+ def change_color(kind)
52
+ case kind
53
+ when :added then ok(ICONS[:added])
54
+ when :removed then error(ICONS[:removed])
55
+ when :modified then warn(ICONS[:modified])
56
+ end
57
+ end
58
+
59
+ # ── Rounded box-drawing helpers ────────────────────────────────────────
60
+ def box_top(width) = dim("╭#{'─' * [width - 2, 0].max}╮")
61
+ def box_bottom(width) = dim("╰#{'─' * [width - 2, 0].max}╯")
62
+ def box_h(width) = dim('─' * width)
63
+ def box_side = dim('│')
64
+
65
+ # Kbd-cap key hint: [k] desc
66
+ def kbd(key) = "#{dim('[')}" + accent(key) + "#{dim(']')}"
67
+
68
+ # Traffic-light title bar: ● ● ● title status
69
+ def title_bar(env_label, width, status: nil)
70
+ lights = "#{error('●')} #{warn('●')} #{ok('●')}"
71
+ lights_vis = 5
72
+ right_str = status ? dim(status) : ''
73
+ right_vis = status ? strip_ansi(right_str).length : 0
74
+ title_str = "\e[1m\e[38;2;122;162;255m#{env_label}\e[0m"
75
+ title_vis = env_label.length
76
+ pad_total = [width - lights_vis - 2 - right_vis - title_vis - 2, 0].max
77
+ pad_left = pad_total / 2
78
+ pad_right = pad_total - pad_left
79
+ " #{lights}#{' ' * pad_left}#{title_str}#{' ' * pad_right}#{right_str}"
80
+ end
81
+
82
+ def banner(vault, envs)
83
+ width = [TTY::Screen.width, 78].min
84
+ inner_w = width - 4 # '│ ' on left + ' │' on right
85
+
86
+ lines = [
87
+ " #{header("#{ICONS[:lock]} easy_creds")} #{dim('credentials ↔ 1Password sync')}",
88
+ '',
89
+ " #{dim('vault')} #{vault ? bold(vault) : dim('(not set)')}",
90
+ " #{dim('envs')} #{envs.any? ? envs.map { |e| env_tag(e) }.join(dim(' · ')) : dim('—')}"
91
+ ]
92
+
93
+ rows = lines.map { |l| "#{box_side} #{rpad(l, inner_w)} #{box_side}" }
94
+ ([box_top(width)] + rows + [box_bottom(width)]).join("\n")
95
+ end
96
+
97
+ def section(title)
98
+ puts ''
99
+ puts bold(title)
100
+ puts dim('─' * [TTY::Screen.width, 60].min)
101
+ end
102
+
103
+ # Shell-prompt line: user@host ~/cwd [git-branch]
104
+ def tprompt_line
105
+ user = ENV.fetch('USER', ENV.fetch('USERNAME', 'user'))
106
+ host = ENV.fetch('HOSTNAME', `hostname -s 2>/dev/null`.strip)
107
+ host = host.split('.').first
108
+ host = 'localhost' if host.to_s.empty?
109
+ cwd = Dir.pwd.gsub(Dir.home, '~')
110
+ branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
111
+ out = "#{ok(user)}#{dim('@')}#{accent(host)} #{dim(cwd)}"
112
+ out += " #{dim("[#{branch}]")}" unless branch.empty?
113
+ out
114
+ end
115
+
116
+ def success(msg) = puts(" #{ok(ICONS[:ok])} #{msg}")
117
+ def failure(msg) = puts(" #{error(ICONS[:error])} #{msg}")
118
+ def notice(msg) = puts(" #{accent(ICONS[:info])} #{msg}")
119
+ def warning(msg) = puts(" #{warn(ICONS[:warn])} #{msg}")
120
+
121
+ def strip_ansi(str)
122
+ str.gsub(/\e\[[0-9;]*[mJHABCDsuK]/, '')
123
+ end
124
+
125
+ def rpad(str, width)
126
+ str + (' ' * [width - strip_ansi(str).length, 0].max)
127
+ end
128
+ end
129
+ end