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