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