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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Onboarding
5
+ class GemSetup
6
+ GLOBAL_DIRS = %w[log templates projects].freeze
7
+
8
+ def self.run(prompt:)
9
+ new(prompt: prompt).run
10
+ end
11
+
12
+ def initialize(prompt:)
13
+ @prompt = prompt
14
+ end
15
+
16
+ def run
17
+ Theme.section('easy_creds — first time setup')
18
+
19
+ global_dir = ask_global_dir
20
+ EasyCreds.configure { |c| c.global_dir = global_dir }
21
+ create_dirs(global_dir)
22
+ seed_default_template(global_dir)
23
+ seed_projects_registry(global_dir)
24
+ check_op_cli!
25
+ ensure_signed_in
26
+ vault = pick_vault
27
+ EasyCreds.configure { |c| c.default_vault = vault }
28
+ write_config(global_dir)
29
+
30
+ Theme.success("Setup complete. Config written to #{global_dir}/config.yml")
31
+ Theme.notice('Run `easy_creds onboard` inside a project to scaffold credentials.')
32
+ global_dir
33
+ end
34
+
35
+ private
36
+
37
+ def ask_global_dir
38
+ default = File.expand_path('~/.easy_creds')
39
+ raw = @prompt.ask('Global config directory:', default: default)
40
+ File.expand_path(raw)
41
+ end
42
+
43
+ def create_dirs(global_dir)
44
+ GLOBAL_DIRS.each { |sub| FileUtils.mkdir_p(File.join(global_dir, sub)) }
45
+ Theme.notice("Created #{global_dir}/")
46
+ end
47
+
48
+ def seed_default_template(global_dir)
49
+ dest = File.join(global_dir, 'templates', 'default.yml')
50
+ return if File.exist?(dest)
51
+
52
+ src = File.join(Templates::Registry::BUNDLED_DIR, 'default-beastmode.yml')
53
+ return unless File.exist?(src)
54
+
55
+ FileUtils.cp(src, dest)
56
+ Theme.notice("Seeded default template at #{dest}")
57
+ end
58
+
59
+ def seed_projects_registry(global_dir)
60
+ projects_path = File.join(global_dir, 'projects.yml')
61
+ return if File.exist?(projects_path)
62
+
63
+ data = { 'schema_version' => 1, 'projects' => [] }
64
+ File.write(projects_path, data.to_yaml)
65
+ FileUtils.chmod(0o600, projects_path)
66
+ end
67
+
68
+ def check_op_cli!
69
+ return if system('which op > /dev/null 2>&1')
70
+
71
+ Theme.failure('op CLI not found. Install with: brew install 1password-cli')
72
+ exit 2
73
+ end
74
+
75
+ def ensure_signed_in
76
+ return if op_signed_in?
77
+
78
+ Theme.notice('Not signed in to 1Password.')
79
+ puts " Run #{Theme.bold('op signin')} in another terminal, then press Enter..."
80
+ $stdin.gets
81
+ ensure_signed_in
82
+ end
83
+
84
+ def op_signed_in?
85
+ system('op whoami > /dev/null 2>&1')
86
+ end
87
+
88
+ def pick_vault
89
+ VaultPicker.pick(@prompt)
90
+ end
91
+
92
+ def write_config(global_dir)
93
+ config_path = File.join(global_dir, 'config.yml')
94
+ Configuration.write_file!(EasyCreds.config, config_path)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Onboarding
5
+ class ProjectWizard
6
+ def self.run(prompt:, root: nil, force: false, non_interactive: false)
7
+ new(prompt: prompt, root: root, force: force,
8
+ non_interactive: non_interactive).run
9
+ end
10
+
11
+ def initialize(prompt:, root: nil, force: false, non_interactive: false)
12
+ @prompt = prompt
13
+ @root = root ? Pathname.new(root) : detect_root
14
+ @force = force
15
+ @non_interactive = non_interactive
16
+ @rails = @root.join('config/application.rb').exist?
17
+ end
18
+
19
+ def run
20
+ show_detected_mode
21
+ template = pick_template
22
+ dest = pick_destination_path
23
+ customised = customise(template)
24
+ write_example(customised, dest)
25
+ offer_bootstrap if @rails && !@non_interactive
26
+ end
27
+
28
+ private
29
+
30
+ def show_detected_mode
31
+ mode = @rails ? Theme.ok("Rails (#{@root})") : Theme.info("Standalone (#{slug})")
32
+ puts ''
33
+ puts " #{Theme.dim('Project:')} #{mode}"
34
+ puts ''
35
+ end
36
+
37
+ def pick_template
38
+ global_dir = EasyCreds.config.global_dir
39
+ if @non_interactive
40
+ Templates::Registry.new(global_dir: global_dir).load(:default)
41
+ else
42
+ TemplatePicker.choose(prompt: @prompt, global_dir: global_dir)
43
+ end
44
+ end
45
+
46
+ def pick_destination_path
47
+ choices = destination_choices
48
+ dest_key = @non_interactive ? :project : @prompt.select('Save example to:', choices, cycle: true)
49
+ resolve_dest(dest_key)
50
+ end
51
+
52
+ def destination_choices
53
+ global_dir = EasyCreds.config.global_dir
54
+ choices = []
55
+ choices << { name: "project — #{@root}/config/credentials/example.yml", value: :project } if @rails
56
+ choices << { name: "global — #{global_dir}/projects/#{slug}/example.yml", value: :global }
57
+ choices
58
+ end
59
+
60
+ def resolve_dest(dest_key)
61
+ global_dir = EasyCreds.config.global_dir
62
+ if dest_key == :project
63
+ @root.join('config/credentials/example.yml')
64
+ else
65
+ Pathname.new(File.join(global_dir, 'projects', slug, 'example.yml'))
66
+ end
67
+ end
68
+
69
+ def customise(template)
70
+ return template if @non_interactive
71
+
72
+ renderer = Templates::Renderer.new(template)
73
+ renderer.customise(@prompt)
74
+ end
75
+
76
+ def write_example(template, dest)
77
+ if dest.exist? && !@force
78
+ Theme.failure("#{dest} already exists. Use --force to overwrite.")
79
+ return
80
+ end
81
+
82
+ dest.parent.mkpath
83
+ dest.write(template.to_yaml)
84
+ Theme.success("Wrote #{dest}")
85
+ end
86
+
87
+ def offer_bootstrap
88
+ return unless @prompt.yes?('Bootstrap config/credentials/development.yml.enc now?', default: false)
89
+
90
+ io = CredentialsIO.new(@root)
91
+ io.ensure_key!('development') unless io.key_exists?('development')
92
+ Theme.success('Key generated at config/credentials/development.key')
93
+ end
94
+
95
+ def slug
96
+ File.basename(@root)
97
+ end
98
+
99
+ def detect_root
100
+ dir = Pathname.new(Dir.pwd)
101
+ dir = dir.parent until dir.root? || dir.join('config/application.rb').exist?
102
+ dir.root? ? Pathname.new(Dir.pwd) : dir
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Onboarding
5
+ module RegisterPrompt
6
+ # Returns :registered, :continue, or :quit
7
+ def self.run(prompt:, project:, registry:)
8
+ return :continue if registry.find_by_path(project.root)
9
+
10
+ Theme.section("New project detected at #{project.root}")
11
+ choice = prompt.select(
12
+ 'Register this project with easy_creds?',
13
+ [
14
+ { name: 'register and continue', value: :register },
15
+ { name: 'continue without registering', value: :skip },
16
+ { name: 'quit', value: :quit }
17
+ ],
18
+ cycle: true
19
+ )
20
+
21
+ case choice
22
+ when :register
23
+ registry.register(
24
+ name: project_name(project),
25
+ path: project.root.to_s,
26
+ kind: project.rails? ? :rails : :standalone,
27
+ github_url: detect_github_url(project.root)
28
+ )
29
+ :registered
30
+ when :skip then :continue
31
+ else :quit
32
+ end
33
+ end
34
+
35
+ def self.project_name(project)
36
+ File.basename(project.root.to_s)
37
+ end
38
+
39
+ def self.detect_github_url(root)
40
+ url = `git -C #{Shellwords.escape(root.to_s)} remote get-url origin 2>/dev/null`.strip
41
+ url.empty? ? nil : url
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Onboarding
5
+ class Runner
6
+ def self.start(project: nil, **)
7
+ prompt = TTY::Prompt.new(interrupt: :exit)
8
+ config_path = File.join(EasyCreds.config.global_dir, 'config.yml')
9
+
10
+ GemSetup.run(prompt: prompt) unless File.exist?(config_path)
11
+
12
+ root = project&.root || Dir.pwd
13
+ ProjectWizard.run(prompt: prompt, root: root, **)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Onboarding
5
+ class TemplatePicker
6
+ def self.choose(prompt:, global_dir: nil)
7
+ new(prompt: prompt, global_dir: global_dir).choose
8
+ end
9
+
10
+ def initialize(prompt:, global_dir: nil)
11
+ @prompt = prompt
12
+ @global_dir = global_dir
13
+ end
14
+
15
+ def choose
16
+ registry = Templates::Registry.new(global_dir: @global_dir)
17
+ choices = build_choices(registry)
18
+ name = @prompt.select('Select a template:', choices, cycle: true)
19
+ name == :blank ? {} : registry.load(name)
20
+ end
21
+
22
+ private
23
+
24
+ def build_choices(registry)
25
+ choices = registry.list.map { |name| { name: display_name(name, registry), value: name } }
26
+ choices << { name: 'custom (blank)', value: :blank }
27
+ choices
28
+ end
29
+
30
+ def display_name(name, registry)
31
+ source = registry.user_defined?(name) ? Theme.ok('user') : Theme.dim('built-in')
32
+ "#{name.to_s.ljust(30)} #{source}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/encrypted_configuration'
4
+ require 'active_support/ordered_options'
5
+ require 'active_support/core_ext/hash/deep_merge'
6
+ require 'active_support/core_ext/hash/keys'
7
+
8
+ module EasyCreds
9
+ module Overlay
10
+ # Merge config/credentials/<env>_local.yml.enc on top of the base credentials.
11
+ #
12
+ # Accepts either a Rails application (the form the installer-generated
13
+ # initializer uses) or the explicit (creds, root, env) triple:
14
+ #
15
+ # EasyCreds::Overlay.apply!(Rails.application)
16
+ # EasyCreds::Overlay.apply!(Rails.application.credentials, Rails.root, Rails.env)
17
+ #
18
+ # @param app_or_creds [#credentials, #root, #env] a Rails application, or the
19
+ # credentials object when root/env are supplied explicitly
20
+ # @param root [String, Pathname, nil] project root (omit when passing an app)
21
+ # @param env [String, Symbol, nil] environment name (omit when passing an app)
22
+ # @return [void]
23
+ def self.apply!(app_or_creds, root = nil, env = nil)
24
+ if root.nil? && env.nil?
25
+ app = app_or_creds
26
+ creds = app.credentials
27
+ root = app.root
28
+ env = app.env.to_s
29
+ else
30
+ creds = app_or_creds
31
+ env = env.to_s
32
+ end
33
+
34
+ enc = Pathname.new(root).join("config/credentials/#{env}_local.yml.enc")
35
+ key = Pathname.new(root).join("config/credentials/#{env}_local.key")
36
+
37
+ return unless enc.exist? && key.exist?
38
+
39
+ overlay = ActiveSupport::EncryptedConfiguration.new(
40
+ config_path: enc.to_s,
41
+ key_path: key.to_s,
42
+ env_key: 'RAILS_MASTER_KEY_LOCAL_UNUSED',
43
+ raise_if_missing_key: false
44
+ ).config.deep_symbolize_keys
45
+
46
+ overlay.each do |top_key, value|
47
+ merge_key(creds, top_key, value)
48
+ end
49
+ rescue StandardError => e
50
+ warn "[easy_creds_overlay] skipped: #{e.class}: #{e.message}"
51
+ end
52
+
53
+ def self.merge_key(creds, top_key, value)
54
+ if value.is_a?(Hash)
55
+ existing = creds.public_send(top_key)
56
+ base = existing.respond_to?(:to_h) ? existing.to_h.deep_symbolize_keys : {}
57
+ creds.public_send(:"#{top_key}=", to_ordered_options(base.deep_merge(value)))
58
+ else
59
+ creds.public_send(:"#{top_key}=", value)
60
+ end
61
+ end
62
+ private_class_method :merge_key
63
+
64
+ def self.to_ordered_options(hash)
65
+ hash.each_with_object(ActiveSupport::OrderedOptions.new) do |(k, v), opts|
66
+ opts[k] = v.is_a?(Hash) ? to_ordered_options(v) : v
67
+ end
68
+ end
69
+ private_class_method :to_ordered_options
70
+ end
71
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ # Detects whether the current working directory is a Rails project or a
5
+ # standalone directory, and exposes project-scoped paths (credentials dir,
6
+ # log dir, etc.) to the rest of the gem without coupling to Rails constants.
7
+ module Project
8
+ # Returns a Rails or Standalone project object for the given path.
9
+ def self.detect(cwd = Dir.pwd)
10
+ root = find_rails_root(Pathname.new(cwd.to_s))
11
+ root ? Project::Rails.new(root: root) : build_standalone(cwd)
12
+ end
13
+
14
+ def self.build_standalone(cwd)
15
+ slug = Pathname.new(cwd.to_s).basename.to_s.gsub(/[^a-z0-9_-]/i, '-').downcase
16
+ global_dir = Pathname.new(EasyCreds.config.global_dir).expand_path
17
+ Standalone.new(root: Pathname.new(cwd.to_s), slug: slug, global_dir: global_dir)
18
+ end
19
+ private_class_method :build_standalone
20
+
21
+ def self.find_rails_root(dir)
22
+ until dir.root?
23
+ return dir if dir.join('config/application.rb').exist?
24
+
25
+ dir = dir.parent
26
+ end
27
+ nil
28
+ end
29
+ private_class_method :find_rails_root
30
+
31
+ # Represents a Rails application project.
32
+ class Rails
33
+ attr_reader :root
34
+
35
+ def initialize(root:)
36
+ @root = Pathname.new(root.to_s)
37
+ end
38
+
39
+ def rails? = true
40
+ def standalone? = false
41
+
42
+ def log_dir = @root.join('log')
43
+ def credentials_dir = @root.join('config/credentials')
44
+ def environments_dir = @root.join('config/environments')
45
+ def gitignore_path = @root.join('.gitignore')
46
+ def git_repo? = @root.join('.git').exist?
47
+ def example_template_path = credentials_dir.join('example.yml')
48
+ def config_file_path = @root.join('config/easy_creds.yml')
49
+ def slug = @root.basename.to_s
50
+ end
51
+
52
+ # Represents a non-Rails directory (standalone usage).
53
+ class Standalone
54
+ attr_reader :root, :slug, :global_dir
55
+
56
+ def initialize(root:, slug:, global_dir:)
57
+ @root = Pathname.new(root.to_s)
58
+ @slug = slug
59
+ @global_dir = Pathname.new(global_dir.to_s)
60
+ end
61
+
62
+ def rails? = false
63
+ def standalone? = true
64
+
65
+ def log_dir = @global_dir.join('log')
66
+ def credentials_dir = @global_dir.join('projects', slug)
67
+ def environments_dir = nil
68
+ def gitignore_path = @root.join('.gitignore')
69
+ def git_repo? = @root.join('.git').exist?
70
+ def example_template_path = credentials_dir.join('example.yml')
71
+ def config_file_path = nil
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Projects
5
+ Project = Data.define(
6
+ :name, :path, :kind, :github_url, :template, :last_seen_at, :registered_at
7
+ ) do
8
+ def exists_on_disk? = File.directory?(path)
9
+ def display_path = path.sub(Dir.home, '~')
10
+ def rails? = kind == :rails
11
+
12
+ def to_h_yaml
13
+ {
14
+ 'name' => name,
15
+ 'path' => path,
16
+ 'kind' => kind.to_s,
17
+ 'github_url' => github_url,
18
+ 'template' => template&.to_s,
19
+ 'last_seen_at' => last_seen_at&.iso8601,
20
+ 'registered_at' => registered_at&.iso8601
21
+ }.compact
22
+ end
23
+ end
24
+
25
+ class Registry
26
+ def self.registry_path
27
+ File.join(EasyCreds.config.global_dir, 'projects.yml')
28
+ end
29
+
30
+ def self.load
31
+ path = registry_path
32
+ unless File.exist?(path)
33
+ return new({ 'schema_version' => 1, 'projects' => [] })
34
+ end
35
+
36
+ data = YAML.safe_load_file(path) ||
37
+ { 'schema_version' => 1, 'projects' => [] }
38
+ new(data)
39
+ end
40
+
41
+ def initialize(data)
42
+ @data = data
43
+ @projects = (data['projects'] || []).map { |h| build(h) }
44
+ end
45
+
46
+ attr_reader :projects
47
+
48
+ def find_by_path(path)
49
+ norm = File.expand_path(path.to_s)
50
+ @projects.find { |p| File.expand_path(p.path) == norm }
51
+ end
52
+
53
+ def find_containing(cwd)
54
+ norm = File.expand_path(cwd.to_s)
55
+ @projects
56
+ .select { |p| norm == File.expand_path(p.path) || norm.start_with?(File.expand_path(p.path) + '/') }
57
+ .max_by { |p| p.path.length }
58
+ end
59
+
60
+ def register(name:, path:, kind:, github_url: nil, template: nil)
61
+ norm = File.expand_path(path.to_s)
62
+ existing = find_by_path(norm)
63
+ if existing
64
+ touch(existing)
65
+ return existing
66
+ end
67
+
68
+ now = Time.now.utc
69
+ proj = Project.new(
70
+ name: name, path: norm, kind: kind.to_sym,
71
+ github_url: github_url.presence, template: template&.to_sym,
72
+ last_seen_at: now, registered_at: now
73
+ )
74
+ @projects << proj
75
+ save!
76
+ proj
77
+ end
78
+
79
+ def touch(project)
80
+ idx = @projects.index { |p| p.path == project.path }
81
+ return unless idx
82
+
83
+ @projects[idx] = project.with(last_seen_at: Time.now.utc)
84
+ save!
85
+ @projects[idx]
86
+ end
87
+
88
+ def remove(project)
89
+ @projects.reject! { |p| p.path == project.path }
90
+ save!
91
+ end
92
+
93
+ def prune_missing!
94
+ gone = @projects.reject(&:exists_on_disk?)
95
+ return [] if gone.empty?
96
+
97
+ gone_paths = gone.map(&:path).to_set
98
+ @projects.reject! { |p| gone_paths.include?(p.path) }
99
+ save!
100
+ gone
101
+ end
102
+
103
+ def save!
104
+ path = self.class.registry_path
105
+ FileUtils.mkdir_p(File.dirname(path))
106
+ out = { 'schema_version' => 1, 'projects' => @projects.map(&:to_h_yaml) }
107
+ File.write(path, out.to_yaml)
108
+ FileUtils.chmod(0o600, path)
109
+ end
110
+
111
+ private
112
+
113
+ def build(h)
114
+ Project.new(
115
+ name: h['name'] || File.basename(h['path'].to_s),
116
+ path: h['path'].to_s,
117
+ kind: (h['kind'] || 'standalone').to_sym,
118
+ github_url: h['github_url'],
119
+ template: h['template']&.to_sym,
120
+ last_seen_at: parse_time(h['last_seen_at']),
121
+ registered_at: parse_time(h['registered_at'])
122
+ )
123
+ end
124
+
125
+ def parse_time(val)
126
+ return nil unless val
127
+ return val if val.is_a?(Time)
128
+
129
+ Time.parse(val.to_s)
130
+ rescue ArgumentError
131
+ nil
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ # Registry of available secret-storage providers.
5
+ # v1 ships only :onepassword. Future providers (env-var, AWS Secrets Manager,
6
+ # HashiCorp Vault) register here without changing call-sites.
7
+ module Provider
8
+ @registry = {}
9
+
10
+ class << self
11
+ def register(name, klass)
12
+ @registry[name.to_sym] = klass
13
+ end
14
+
15
+ def fetch(name)
16
+ @registry.fetch(name.to_sym) do
17
+ raise ArgumentError, "Unknown provider '#{name}'. Registered: #{@registry.keys.join(', ')}"
18
+ end
19
+ end
20
+
21
+ def build(name, **opts)
22
+ fetch(name).new(**opts)
23
+ end
24
+
25
+ def registered
26
+ @registry.keys
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCreds
4
+ module Providers
5
+ # Abstract interface all EasyCreds providers must implement.
6
+ # Subclasses raise NotImplementedError for any unimplemented method.
7
+ class Base
8
+ def initialize(config: nil, logger: nil)
9
+ @config = config
10
+ @logger = logger
11
+ end
12
+
13
+ def signed_in? = raise(NotImplementedError, "#{self.class}#signed_in?")
14
+ def account_email = raise(NotImplementedError, "#{self.class}#account_email")
15
+ def vault_exists? = raise(NotImplementedError, "#{self.class}#vault_exists?")
16
+ def create_vault(name) = raise(NotImplementedError, "#{self.class}#create_vault")
17
+ def item(env) = raise(NotImplementedError, "#{self.class}#item")
18
+ def item_exists?(env) = raise(NotImplementedError, "#{self.class}#item_exists?")
19
+ def create_item(env, fields) = raise(NotImplementedError, "#{self.class}#create_item")
20
+ def update_item(env, all_fields) = raise(NotImplementedError, "#{self.class}#update_item")
21
+ def read_credentials_key(env, item) = raise(NotImplementedError, "#{self.class}#read_credentials_key")
22
+ def write_credentials_key(env, value, item) = raise(NotImplementedError, "#{self.class}#write_credentials_key")
23
+ end
24
+ end
25
+ end