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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'pathname'
5
+
6
+ module EasyCreds
7
+ SyncConfig = Data.define(
8
+ :vault,
9
+ :credentials_item,
10
+ :default_env,
11
+ :ignore_keys
12
+ )
13
+
14
+ module ConfigLoader
15
+ CONFIG_FILE = 'config/1password.yml'
16
+
17
+ DEFAULTS = SyncConfig.new(
18
+ vault: nil,
19
+ credentials_item: 'credentials',
20
+ default_env: 'development',
21
+ ignore_keys: []
22
+ ).freeze
23
+
24
+ def self.load(root)
25
+ path = Pathname.new(root).join(CONFIG_FILE)
26
+ return DEFAULTS unless path.exist?
27
+
28
+ build_config(YAML.safe_load(path.read, symbolize_names: true) || {})
29
+ end
30
+
31
+ def self.build_config(raw)
32
+ SyncConfig.new(**merge_defaults(raw))
33
+ end
34
+ private_class_method :build_config
35
+
36
+ def self.merge_defaults(raw)
37
+ items = (raw[:items] || {}).transform_keys(&:to_sym)
38
+ {
39
+ vault: pick(raw[:vault], DEFAULTS.vault),
40
+ credentials_item: pick(items[:credentials_item], DEFAULTS.credentials_item),
41
+ default_env: pick(raw[:default_env], DEFAULTS.default_env),
42
+ ignore_keys: Array(raw[:ignore_keys]).map(&:to_s)
43
+ }
44
+ end
45
+ private_class_method :merge_defaults
46
+
47
+ def self.pick(val, default)
48
+ val&.to_s || default
49
+ end
50
+ private_class_method :pick
51
+ end
52
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ class Configuration
5
+ attr_accessor :global_dir,
6
+ :default_provider,
7
+ :default_vault,
8
+ :templates_dir,
9
+ :default_template,
10
+ :editor,
11
+ :log_level,
12
+ :schema_version
13
+
14
+ def initialize
15
+ @global_dir = File.expand_path('~/.easy_creds')
16
+ @default_provider = :onepassword
17
+ @default_vault = nil
18
+ @templates_dir = nil
19
+ @default_template = :default
20
+ @editor = ENV['VISUAL'] || ENV.fetch('EDITOR', nil)
21
+ @log_level = :debug
22
+ @schema_version = 1
23
+ end
24
+
25
+ def resolved_templates_dir
26
+ @templates_dir || File.join(global_dir, 'templates')
27
+ end
28
+
29
+ def self.load_file(path)
30
+ return Configuration.new unless File.exist?(path)
31
+
32
+ raw = YAML.safe_load_file(path) || {}
33
+ cfg = Configuration.new
34
+ cfg.global_dir = raw['global_dir'] if raw['global_dir']
35
+ cfg.default_vault = raw['default_vault']
36
+ cfg.default_provider = (raw['default_provider']&.to_sym || :onepassword)
37
+ cfg.default_template = (raw['default_template']&.to_sym || :default)
38
+ cfg.templates_dir = raw['templates_dir']
39
+ cfg.editor = raw['editor'] || ENV['VISUAL'] || ENV.fetch('EDITOR', nil)
40
+ cfg.log_level = (raw['log_level']&.to_sym || :debug)
41
+ cfg.schema_version = raw['schema_version'] || 1
42
+ cfg
43
+ end
44
+
45
+ def self.write_file!(cfg, path)
46
+ data = {
47
+ 'schema_version' => 2,
48
+ 'global_dir' => cfg.global_dir,
49
+ 'default_vault' => cfg.default_vault,
50
+ 'default_provider' => cfg.default_provider.to_s,
51
+ 'default_template' => cfg.default_template.to_s,
52
+ 'templates_dir' => cfg.templates_dir,
53
+ 'editor' => cfg.editor,
54
+ 'log_level' => cfg.log_level.to_s
55
+ }.compact
56
+ FileUtils.mkdir_p(File.dirname(path))
57
+ File.write(path, data.to_yaml)
58
+ FileUtils.chmod(0o600, path)
59
+ end
60
+ end
61
+
62
+ class << self
63
+ def config
64
+ @config ||= Configuration.new
65
+ end
66
+
67
+ def configure
68
+ yield config
69
+ end
70
+
71
+ def reset_config!
72
+ @config = Configuration.new
73
+ end
74
+
75
+ def load_global_config!
76
+ path = File.join(config.global_dir, 'config.yml')
77
+ @config = Configuration.load_file(path)
78
+ end
79
+
80
+ def ensure_default_template!
81
+ dest = File.join(config.resolved_templates_dir, 'default.yml')
82
+ return if File.exist?(dest)
83
+
84
+ src = File.join(Templates::Registry::BUNDLED_DIR, 'default-beastmode.yml')
85
+ return unless File.exist?(src)
86
+
87
+ FileUtils.mkdir_p(File.dirname(dest))
88
+ FileUtils.cp(src, dest)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'pathname'
5
+ require 'securerandom'
6
+ require 'active_support/encrypted_configuration'
7
+
8
+ module EasyCreds
9
+ class CredentialsIO
10
+ GITIGNORE_RULE = '/config/credentials/*.key'
11
+ LOCAL_GITIGNORE_RULE = '/config/credentials/*_local.yml.enc'
12
+
13
+ def initialize(root, git_repo: true)
14
+ @root = Pathname.new(root)
15
+ @git_repo = git_repo
16
+ end
17
+
18
+ def read(env, local: false)
19
+ content = configuration(env, local: local).read
20
+ return {} if content.blank?
21
+
22
+ YAML.safe_load(content, symbolize_names: true, permitted_classes: [Symbol, Date, Time]) || {}
23
+ rescue ActiveSupport::EncryptedFile::MissingKeyError
24
+ {}
25
+ end
26
+
27
+ def write(env, hash, local: false)
28
+ configuration(env, local: local).write(hash.deep_stringify_keys.to_yaml)
29
+ end
30
+
31
+ def key_exists?(env, local: false)
32
+ key_path(env, local: local).exist?
33
+ end
34
+
35
+ def key_value(env, local: false)
36
+ key_path(env, local: local).read.strip
37
+ rescue Errno::ENOENT
38
+ nil
39
+ end
40
+
41
+ def write_key(env, value, local: false)
42
+ path = key_path(env, local: local)
43
+ path.dirname.mkpath
44
+ path.write(value)
45
+ path.chmod(0o600)
46
+ end
47
+
48
+ def ensure_key!(env, local: false)
49
+ return if key_exists?(env, local: local)
50
+
51
+ write_key(env, SecureRandom.hex(16), local: local)
52
+ end
53
+
54
+ def read_raw(env, local: false)
55
+ content = configuration(env, local: local).read
56
+ content.presence || "--- {}\n"
57
+ rescue ActiveSupport::EncryptedFile::MissingKeyError
58
+ "--- {}\n"
59
+ end
60
+
61
+ def write_raw(env, yaml_str, local: false)
62
+ configuration(env, local: local).write(yaml_str)
63
+ end
64
+
65
+ def enc_exists?(env, local: false)
66
+ enc_path(env, local: local).exist?
67
+ end
68
+
69
+ def gitignore_has_rule?
70
+ return false unless @git_repo
71
+
72
+ gitignore_path.exist? && gitignore_path.read.include?(GITIGNORE_RULE)
73
+ end
74
+
75
+ def add_gitignore_rule!
76
+ return unless @git_repo
77
+
78
+ gitignore_path.open('a') { |f| f.puts("\n#{GITIGNORE_RULE}") }
79
+ end
80
+
81
+ def gitignore_has_local_rule?
82
+ return false unless @git_repo
83
+
84
+ gitignore_path.exist? && gitignore_path.read.include?(LOCAL_GITIGNORE_RULE)
85
+ end
86
+
87
+ def add_local_gitignore_rule!
88
+ return unless @git_repo
89
+ return if gitignore_has_local_rule?
90
+
91
+ gitignore_path.open('a') { |f| f.puts('', LOCAL_GITIGNORE_RULE) }
92
+ end
93
+
94
+ def example_template
95
+ path = @root.join('config/credentials/example.yml')
96
+ return {} unless path.exist?
97
+
98
+ YAML.safe_load(path.read, symbolize_names: true) || {}
99
+ rescue Psych::SyntaxError
100
+ {}
101
+ end
102
+
103
+ private
104
+
105
+ def gitignore_path
106
+ @root.join('.gitignore')
107
+ end
108
+
109
+ def configuration(env, local: false)
110
+ ActiveSupport::EncryptedConfiguration.new(
111
+ config_path: enc_path(env, local: local).to_s,
112
+ key_path: key_path(env, local: local).to_s,
113
+ env_key: local ? 'RAILS_MASTER_KEY_LOCAL_UNUSED' : 'RAILS_MASTER_KEY',
114
+ raise_if_missing_key: false
115
+ )
116
+ end
117
+
118
+ def enc_path(env, local: false)
119
+ suffix = local ? '_local' : ''
120
+ @root.join("config/credentials/#{env}#{suffix}.yml.enc")
121
+ end
122
+
123
+ def key_path(env, local: false)
124
+ suffix = local ? '_local' : ''
125
+ @root.join("config/credentials/#{env}#{suffix}.key")
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Differ
5
+ Change = Data.define(:kind, :key, :side)
6
+ # kind: :added (remote-only), :removed (local-only), :modified (differs)
7
+ # side: :local, :remote, :both
8
+
9
+ def self.compare(local:, remote:)
10
+ local_flat = Flatten.dot(local)
11
+ remote_flat = Flatten.dot(remote)
12
+ all_keys = (local_flat.keys | remote_flat.keys).sort
13
+
14
+ all_keys.each_with_object([]) do |key, changes|
15
+ change = classify_change(key, local_flat, remote_flat)
16
+ changes << change if change
17
+ end
18
+ end
19
+
20
+ def self.classify_change(key, local_flat, remote_flat)
21
+ kind, side = change_kind(
22
+ local_flat.key?(key), remote_flat.key?(key),
23
+ local_flat[key], remote_flat[key]
24
+ )
25
+ Change.new(kind: kind, key: key, side: side) if kind
26
+ end
27
+ private_class_method :classify_change
28
+
29
+ def self.change_kind(in_local, in_remote, local_val, remote_val)
30
+ return %i[removed local] if in_local && !in_remote
31
+ return %i[added remote] if !in_local && in_remote
32
+
33
+ %i[modified both] if local_val.to_s != remote_val.to_s
34
+ end
35
+ private_class_method :change_kind
36
+
37
+ def self.empty?(changes)
38
+ changes.empty?
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ class Doctor
5
+ def initialize(root: nil, verbose: false)
6
+ @root = root ? Pathname.new(root) : detect_root
7
+ @verbose = verbose
8
+ @checks = []
9
+ end
10
+
11
+ def run
12
+ check_op_installed
13
+ check_op_signed_in
14
+ check_vault
15
+ check_project
16
+ check_gitignore
17
+
18
+ print_summary
19
+ @checks.all? { |c| c[:ok] }
20
+ end
21
+
22
+ private
23
+
24
+ def check_op_installed
25
+ ok = system('which op > /dev/null 2>&1')
26
+ add_check('op CLI installed', ok, ok ? nil : 'Install with: brew install 1password-cli')
27
+ end
28
+
29
+ def check_op_signed_in
30
+ return add_check('op signed in', false, 'op not installed') unless op_available?
31
+
32
+ email = `op whoami --format=json 2>/dev/null`.then { |raw| parse_email(raw) }
33
+ add_check('op signed in', !email.nil?, email ? nil : 'Run: op signin', email)
34
+ end
35
+
36
+ def check_vault
37
+ return add_check('vault reachable', false, 'op not signed in') unless op_signed_in?
38
+
39
+ config = ConfigLoader.load(@root)
40
+ vault = config.vault
41
+ return add_check('vault configured', false, 'No vault set. Run: easy_creds init') unless vault
42
+
43
+ `op vault get #{vault.shellescape} 2>/dev/null`
44
+ add_check("vault #{vault}", $CHILD_STATUS.success?, $CHILD_STATUS.success? ? nil : "Vault '#{vault}' not found")
45
+ end
46
+
47
+ def check_project
48
+ rails = @root.join('config/application.rb').exist?
49
+ kind = rails ? "Rails (#{@root})" : "Standalone (#{File.basename(@root)})"
50
+ add_check("project detected: #{kind}", true)
51
+ end
52
+
53
+ def check_gitignore
54
+ io = CredentialsIO.new(@root)
55
+ ok = io.gitignore_has_rule?
56
+ add_check('credentials/*.key in .gitignore', ok,
57
+ ok ? nil : 'Run: easy_creds install or bin/rails easy_creds:install')
58
+ end
59
+
60
+ def add_check(label, passed, hint = nil, detail = nil)
61
+ @checks << { label: label, ok: passed, hint: hint, detail: detail }
62
+ icon = passed ? Theme.ok(Theme::ICONS[:ok]) : Theme.warn(Theme::ICONS[:warn])
63
+ line = " #{icon} #{label}"
64
+ line += " #{Theme.dim(detail)}" if detail && @verbose
65
+ puts line
66
+ puts " #{Theme.dim("→ #{hint}")}" if hint && !passed
67
+ end
68
+
69
+ def print_summary
70
+ puts ''
71
+ failures = @checks.reject { |c| c[:ok] }
72
+ if failures.empty?
73
+ Theme.success('All checks passed')
74
+ else
75
+ Theme.failure("#{failures.size} check(s) failed")
76
+ end
77
+ end
78
+
79
+ def op_available?
80
+ @checks.find { |c| c[:label] == 'op CLI installed' }&.dig(:ok)
81
+ end
82
+
83
+ def op_signed_in?
84
+ @checks.find { |c| c[:label] == 'op signed in' }&.dig(:ok)
85
+ end
86
+
87
+ def parse_email(raw)
88
+ data = JSON.parse(raw)
89
+ data['email']
90
+ rescue JSON::ParserError, TypeError
91
+ nil
92
+ end
93
+
94
+ def detect_root
95
+ dir = Pathname.new(Dir.pwd)
96
+ dir = dir.parent until dir.root? || dir.join('config/application.rb').exist?
97
+ dir.root? ? Pathname.new(Dir.pwd) : dir
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module EasyCreds
6
+ module EnvPicker
7
+ def self.available(project_or_root)
8
+ dir = if project_or_root.respond_to?(:environments_dir)
9
+ project_or_root.environments_dir
10
+ else
11
+ Pathname.new(project_or_root.to_s).join('config/environments')
12
+ end
13
+
14
+ return ['default'] if dir.nil? || !dir.exist?
15
+
16
+ envs = dir.glob('*.rb').map { |f| f.basename('.rb').to_s }.sort
17
+ envs.empty? ? ['default'] : envs
18
+ end
19
+
20
+ def self.default_index(envs, default_env)
21
+ envs.index(default_env) || 0
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Flatten
5
+ def self.dot(hash, prefix = nil)
6
+ hash.each_with_object({}) do |(key, value), result|
7
+ full_key = prefix ? "#{prefix}.#{key}" : key.to_s
8
+ if value.is_a?(Hash)
9
+ result.merge!(dot(value, full_key))
10
+ else
11
+ result[full_key] = value
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.unflatten(flat)
17
+ flat.each_with_object({}) do |(key, value), result|
18
+ parts = key.to_s.split('.')
19
+ leaf = parts.pop
20
+ node = parts.reduce(result) { |acc, part| acc[part.to_sym] ||= {} }
21
+ node[leaf.to_sym] = value
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'open3'
5
+ require 'yaml'
6
+
7
+ module EasyCreds
8
+ module Generators
9
+ KNOWN = {
10
+ /\bsecret_key_base\b/ => :secret_key_base,
11
+ /\bdevise_secret_key\b/ => :devise_secret_key,
12
+ /\bcrypt_secret\b/ => :crypt_secret,
13
+ /active_record_encryption\./ => :ar_encryption
14
+ }.freeze
15
+
16
+ def self.hint_for(key)
17
+ KNOWN.each { |pattern, name| return name if key.match?(pattern) }
18
+ nil
19
+ end
20
+
21
+ def self.secret_key_base = SecureRandom.hex(64)
22
+ def self.devise_secret_key = SecureRandom.hex(64)
23
+ def self.crypt_secret = SecureRandom.hex(32)
24
+ def self.random_hex(len = 32) = SecureRandom.hex(len)
25
+ def self.random_alpha(len = 32) = SecureRandom.alphanumeric(len)
26
+
27
+ # Rails-derived Active Record Encryption keys via `rails db:encryption:init`.
28
+ # Falls back to independent SecureRandom keys when run outside a Rails
29
+ # project (nil root) or when the shell-out fails.
30
+ def self.ar_encryption_keys(root = nil)
31
+ return random_ar_encryption_keys if root.nil?
32
+
33
+ stdout, _stderr, status = Open3.capture3(
34
+ 'bundle', 'exec', 'rails', 'db:encryption:init',
35
+ chdir: root.to_s
36
+ )
37
+ return random_ar_encryption_keys unless status.success?
38
+
39
+ # Output looks like:
40
+ # active_record_encryption:
41
+ # primary_key: ...
42
+ # deterministic_key: ...
43
+ # key_derivation_salt: ...
44
+ section = stdout[/active_record_encryption:.*\z/m]
45
+ return random_ar_encryption_keys unless section
46
+
47
+ data = YAML.safe_load(section)
48
+ inner = data&.dig('active_record_encryption') || {}
49
+ return random_ar_encryption_keys if inner.empty?
50
+
51
+ inner.transform_keys { |k| "active_record_encryption.#{k}" }
52
+ rescue StandardError => e
53
+ warn "⚠️ db:encryption:init failed: #{e.message}"
54
+ random_ar_encryption_keys
55
+ end
56
+
57
+ def self.random_ar_encryption_keys
58
+ {
59
+ 'active_record_encryption.primary_key' => SecureRandom.hex(32),
60
+ 'active_record_encryption.deterministic_key' => SecureRandom.hex(32),
61
+ 'active_record_encryption.key_derivation_salt' => SecureRandom.hex(32)
62
+ }
63
+ end
64
+
65
+ # Shells out to `rails secret` (canonical secret_key_base style).
66
+ # Falls back to SecureRandom on a nil root or any failure.
67
+ def self.rails_secret(root: nil)
68
+ return SecureRandom.hex(64) if root.nil?
69
+
70
+ stdout, _err, status = Open3.capture3(
71
+ 'bundle', 'exec', 'rails', 'secret',
72
+ chdir: root.to_s
73
+ )
74
+ return SecureRandom.hex(64) unless status.success?
75
+
76
+ line = stdout.lines.find { |l| l.strip =~ /\A[0-9a-f]{64,}\z/ }
77
+ line ? line.strip : SecureRandom.hex(64)
78
+ rescue StandardError
79
+ SecureRandom.hex(64)
80
+ end
81
+
82
+ def self.default_for(hint, root: nil)
83
+ case hint
84
+ when :secret_key_base, :devise_secret_key, :crypt_secret
85
+ rails_secret(root: root)
86
+ when :ar_encryption
87
+ ar_encryption_keys(root)
88
+ else
89
+ random_hex(32)
90
+ end
91
+ end
92
+
93
+ def self.alternative_for(hint)
94
+ case hint
95
+ when :secret_key_base, :devise_secret_key then random_hex(64)
96
+ when :crypt_secret then random_hex(32)
97
+ else random_alpha(32)
98
+ end
99
+ end
100
+
101
+ def self.bulk_for(hint)
102
+ case hint
103
+ when :secret_key_base, :devise_secret_key then random_hex(64)
104
+ when :ar_encryption then nil
105
+ else random_hex(32)
106
+ end
107
+ end
108
+
109
+ def self.generate(hint, root: nil)
110
+ default_for(hint, root: root)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ class Entry
5
+ attr_accessor :key, :placeholder, :hint, :value, :source
6
+
7
+ def initialize(key:, placeholder:, hint:, op_value: nil)
8
+ @key = key
9
+ @placeholder = placeholder
10
+ @hint = hint
11
+ @op_value = op_value
12
+ @value = nil
13
+ @source = :unset
14
+ end
15
+
16
+ def set? = source != :unset
17
+ attr_reader :op_value
18
+
19
+ def masked_value
20
+ return nil unless value
21
+
22
+ len = value.to_s.length
23
+ "#{'●' * [len, 8].min} (#{len} chars)"
24
+ end
25
+ end
26
+
27
+ class InitState
28
+ attr_reader :entries, :op_values
29
+ attr_accessor :cursor, :scroll_offset, :dirty
30
+
31
+ def initialize(entries:, op_values: {})
32
+ @entries = entries
33
+ @op_values = op_values
34
+ @cursor = 0
35
+ @scroll_offset = 0
36
+ @dirty = false
37
+ end
38
+
39
+ def current = entries[cursor]
40
+ def set_count = entries.count(&:set?)
41
+ def total = entries.size
42
+
43
+ def move_up(steps = 1) = @cursor = [@cursor - steps, 0].max
44
+ def move_down(steps = 1) = @cursor = [@cursor + steps, entries.size - 1].min
45
+ def move_first = @cursor = 0
46
+ def move_last = @cursor = entries.size - 1
47
+
48
+ def set_entry(entry, value:, source:)
49
+ entry.value = value
50
+ entry.source = source
51
+ @dirty = true
52
+ end
53
+
54
+ def clear_entry(entry)
55
+ entry.value = nil
56
+ entry.source = :unset
57
+ end
58
+
59
+ def adjust_scroll(cursor_tree_idx, content_rows)
60
+ @scroll_offset = cursor_tree_idx if cursor_tree_idx < @scroll_offset
61
+ @scroll_offset = cursor_tree_idx - content_rows + 1 if cursor_tree_idx >= @scroll_offset + content_rows
62
+ @scroll_offset = [@scroll_offset, 0].max
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ class Installer
5
+ INITIALIZER_PATH = 'config/initializers/00_credentials_local_overlay.rb'
6
+ GITIGNORE_PATH = '.gitignore'
7
+ GITIGNORE_RULE = "/config/credentials/*.key\n"
8
+
9
+ INITIALIZER_CONTENT = <<~RUBY
10
+ # frozen_string_literal: true
11
+
12
+ # Merge config/credentials/<env>_local.yml.enc on top of the base credentials.
13
+ # The local overlay is gitignored and never pushed to 1Password — use it for
14
+ # machine-specific overrides (e.g. a local database URL different from staging).
15
+ require 'easy_creds/overlay'
16
+ EasyCreds::Overlay.apply!(Rails.application)
17
+ RUBY
18
+
19
+ def initialize(root)
20
+ @root = Pathname.new(root)
21
+ end
22
+
23
+ def run
24
+ install_initializer
25
+ install_gitignore
26
+ end
27
+
28
+ private
29
+
30
+ def install_initializer
31
+ target = @root.join(INITIALIZER_PATH)
32
+ if target.exist?
33
+ Theme.notice("#{INITIALIZER_PATH} already exists — skipped")
34
+ return
35
+ end
36
+
37
+ target.parent.mkpath
38
+ target.write(INITIALIZER_CONTENT)
39
+ Theme.success("Created #{INITIALIZER_PATH}")
40
+ end
41
+
42
+ def install_gitignore
43
+ gitignore = @root.join(GITIGNORE_PATH)
44
+ content = gitignore.exist? ? gitignore.read : ''
45
+
46
+ if content.include?('credentials/*.key')
47
+ Theme.notice('.gitignore already has credentials/*.key rule — skipped')
48
+ return
49
+ end
50
+
51
+ File.open(gitignore, 'a') { |f| f.write(GITIGNORE_RULE) }
52
+ Theme.success('Added /config/credentials/*.key to .gitignore')
53
+ end
54
+ end
55
+ end