hanami-settings-stores 0.0.1 → 0.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2361fa59d804b5036ad6a843b9f38c75308554f786386ce2ec9acac2c3441f2
4
- data.tar.gz: f608a5c736208e1be95995d9a083cff71e9c9af3e6d3dfcbfe4475d7f2fb0ecf
3
+ metadata.gz: 7c8228cf550f63d29462cb128f2949d9fa9a744aac843fbac90a811a5d550ec4
4
+ data.tar.gz: f4abd1d91b57a4946712d602f1f37d1274d5ac89dc2e2e18eeb5f756877e4875
5
5
  SHA512:
6
- metadata.gz: 451b9a9796b6acec960d5542ee0359e4d761d7a20aab58452e9c927d66e05bffc37ea48660c45fbcc71176464785782607d2821540cf8af6a03d19e6cabf410e
7
- data.tar.gz: c193fac8e097d8975a54dac1ae6c1fcda6f11c6c1297031e267eed8456e2451dda952075b26c233ddd0de69acaad2b2ff608bcac0d95a928265195309620e6b9
6
+ metadata.gz: 6986d005390d907eadd3342e78da83016f495c91c48dbd7c53ecdb84d0aa3979ed459d2f5307445ae2e358fdae9db1ffa8cda6ddc02813cd199c61f39a81440f
7
+ data.tar.gz: bf07691cc6721383c34c095f4c8122ee0fc6724e970c2084db25cea20e3fa93d579f751d19a81bfbd299642e28cb4d8b2fa9fc7cf849c0fd6db6ff716b795c6e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ### Added
10
+ ## v0.1.0 - 2026-03-17
11
11
 
12
- - `Hanami::Settings::Stores::CompositeStore` - chains multiple stores with fallback resolution
12
+ Initial release.
13
+
14
+ [Unreleased]: https://github.com/aaronmallen/hanami-settings-stores/compare/v0.1.0...HEAD
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Hanami
7
+ class Settings
8
+ # A settings store backed by Bitwarden.
9
+ #
10
+ # Uses mappings defined via {BitwardenStore::Mixin#bw_setting} on your Settings class to
11
+ # resolve setting values from Bitwarden items. Requires the `bw` CLI to be installed and
12
+ # authenticated.
13
+ #
14
+ # Item fields are cached in memory after the first fetch for each item.
15
+ #
16
+ # @example
17
+ # # config/settings.rb
18
+ # class Settings < Hanami::Settings
19
+ # bw_setting :database_url, item: "MyApp", constructor: Types::String
20
+ # bw_setting :api_key, item: "ExternalService", key: :secret_key, constructor: Types::String
21
+ # end
22
+ #
23
+ # # config/app.rb
24
+ # config.settings_store = Hanami::Settings::CompositeStore.new(
25
+ # Hanami::Settings::EnvStore.new,
26
+ # Hanami::Settings::BitwardenStore.new
27
+ # )
28
+ #
29
+ # @api public
30
+ # @since 0.1.0
31
+ class BitwardenStore
32
+ # DSL extension for {Hanami::Settings} that adds the {#bw_setting} method.
33
+ #
34
+ # Automatically included when this file is required.
35
+ #
36
+ # @api public
37
+ # @since 0.1.0
38
+ module Mixin
39
+ # @api private
40
+ module ClassMethods
41
+ # Returns the Bitwarden mappings for this settings class.
42
+ #
43
+ # @return [Hash{Symbol => Hash}]
44
+ # @api private
45
+ def bw_mappings
46
+ @bw_mappings ||= {}
47
+ end
48
+
49
+ # Defines a setting backed by a Bitwarden item field.
50
+ #
51
+ # @param name [Symbol] the setting name
52
+ # @param item [String] the Bitwarden item name or ID
53
+ # @param key [Symbol, String] the field name (defaults to the setting name)
54
+ # @param options [Hash] additional options passed to `Dry::Configurable#setting`
55
+ #
56
+ # @api public
57
+ # @since 0.1.0
58
+ def bw_setting(name, item:, key: name, **options)
59
+ bw_mappings[name] = {item: item.to_s, key: key.to_s}
60
+ setting(name, **options)
61
+ end
62
+ end
63
+
64
+ # @api private
65
+ def self.prepended(base)
66
+ base.extend(ClassMethods)
67
+ end
68
+
69
+ # @api private
70
+ def initialize(store = EMPTY_STORE)
71
+ store.bw_mappings = self.class.bw_mappings if store.respond_to?(:bw_mappings=)
72
+ super
73
+ end
74
+ end
75
+
76
+ def initialize
77
+ @bw_mappings = {}
78
+ @cache = {}
79
+ end
80
+
81
+ # Sets the Bitwarden mappings from the settings class.
82
+ #
83
+ # @param mappings [Hash{Symbol => Hash}]
84
+ # @api private
85
+ attr_writer :bw_mappings
86
+
87
+ # Fetches a setting value using its Bitwarden mapping.
88
+ #
89
+ # @param name [String, Symbol] the setting name
90
+ # @param args [Array] optional default value
91
+ # @yield [name] optional block for default value
92
+ # @return [String] the field value
93
+ # @raise [KeyError] if the field is not found and no default is given
94
+ #
95
+ # @api public
96
+ # @since 0.1.0
97
+ def fetch(name, *args, &block)
98
+ mapping = @bw_mappings[name.to_sym]
99
+
100
+ unless mapping
101
+ return args.first unless args.empty?
102
+ return yield(name) if block
103
+
104
+ raise KeyError, "key not found: #{name.inspect}"
105
+ end
106
+
107
+ item_fields(mapping[:item]).fetch(mapping[:key], *args, &block)
108
+ end
109
+
110
+ private
111
+
112
+ # @api private
113
+ def item_fields(item_name)
114
+ @cache[item_name] ||= load_item_fields(item_name)
115
+ end
116
+
117
+ # @api private
118
+ def load_item_fields(item_name)
119
+ stdout, stderr, status = Open3.capture3("bw", "get", "item", item_name)
120
+ raise "Bitwarden CLI error: #{stderr.strip}" unless status.success?
121
+
122
+ item = JSON.parse(stdout)
123
+ item.fetch("fields", []).each_with_object({}) do |field, hash|
124
+ name = field["name"]
125
+ hash[name] = field["value"] unless name.nil? || name.empty?
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ Hanami::Settings.prepend(Hanami::Settings::BitwardenStore::Mixin)
@@ -17,6 +17,10 @@ module Hanami
17
17
  # @api public
18
18
  # @since 0.1.0
19
19
  class CompositeStore
20
+ # @api private
21
+ NOT_SET = Object.new.freeze
22
+ private_constant :NOT_SET
23
+
20
24
  # @param stores [Array<#fetch>] ordered list of stores to query
21
25
  def initialize(*stores)
22
26
  @stores = stores
@@ -43,9 +47,6 @@ module Hanami
43
47
 
44
48
  raise KeyError, "key not found: #{name.inspect}"
45
49
  end
46
-
47
- NOT_SET = Object.new.freeze
48
- private_constant :NOT_SET
49
50
  end
50
51
  end
51
52
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Hanami
7
+ class Settings
8
+ # A settings store backed by 1Password.
9
+ #
10
+ # Uses mappings defined via {OnePasswordStore::Mixin#op_setting} on your Settings class to
11
+ # resolve setting values from 1Password items. Requires the `op` CLI to be installed and
12
+ # authenticated.
13
+ #
14
+ # Item fields are cached in memory after the first fetch for each item.
15
+ #
16
+ # @example
17
+ # # config/settings.rb
18
+ # class Settings < Hanami::Settings
19
+ # op_setting :database_url, item: "MyApp", constructor: Types::String
20
+ # op_setting :api_key, item: "ExternalService", key: :secret_key, constructor: Types::String
21
+ # end
22
+ #
23
+ # # config/app.rb
24
+ # config.settings_store = Hanami::Settings::CompositeStore.new(
25
+ # Hanami::Settings::EnvStore.new,
26
+ # Hanami::Settings::OnePasswordStore.new(vault: "Production")
27
+ # )
28
+ #
29
+ # @api public
30
+ # @since 0.1.0
31
+ class OnePasswordStore
32
+ # DSL extension for {Hanami::Settings} that adds the {#op_setting} method.
33
+ #
34
+ # Automatically included when this file is required.
35
+ #
36
+ # @api public
37
+ # @since 0.1.0
38
+ module Mixin
39
+ # @api private
40
+ module ClassMethods
41
+ # Returns the 1Password mappings for this settings class.
42
+ #
43
+ # @return [Hash{Symbol => Hash}]
44
+ # @api private
45
+ def op_mappings
46
+ @op_mappings ||= {}
47
+ end
48
+
49
+ # Defines a setting backed by a 1Password item field.
50
+ #
51
+ # @param name [Symbol] the setting name
52
+ # @param item [String] the 1Password item name or ID
53
+ # @param key [Symbol, String] the field label (defaults to the setting name)
54
+ # @param options [Hash] additional options passed to `Dry::Configurable#setting`
55
+ #
56
+ # @api public
57
+ # @since 0.1.0
58
+ def op_setting(name, item:, key: name, **options)
59
+ op_mappings[name] = {item: item.to_s, key: key.to_s}
60
+ setting(name, **options)
61
+ end
62
+ end
63
+
64
+ # @api private
65
+ def self.prepended(base)
66
+ base.extend(ClassMethods)
67
+ end
68
+
69
+ # @api private
70
+ def initialize(store = EMPTY_STORE)
71
+ store.op_mappings = self.class.op_mappings if store.respond_to?(:op_mappings=)
72
+ super
73
+ end
74
+ end
75
+
76
+ # @param vault [String] the 1Password vault name or ID
77
+ def initialize(vault:)
78
+ @vault = vault
79
+ @op_mappings = {}
80
+ @cache = {}
81
+ end
82
+
83
+ # Sets the 1Password mappings from the settings class.
84
+ #
85
+ # @param mappings [Hash{Symbol => Hash}]
86
+ # @api private
87
+ attr_writer :op_mappings
88
+
89
+ # Fetches a setting value using its 1Password mapping.
90
+ #
91
+ # @param name [String, Symbol] the setting name
92
+ # @param args [Array] optional default value
93
+ # @yield [name] optional block for default value
94
+ # @return [String] the field value
95
+ # @raise [KeyError] if the field is not found and no default is given
96
+ #
97
+ # @api public
98
+ # @since 0.1.0
99
+ def fetch(name, *args, &block)
100
+ mapping = @op_mappings[name.to_sym]
101
+
102
+ unless mapping
103
+ return args.first unless args.empty?
104
+ return yield(name) if block
105
+
106
+ raise KeyError, "key not found: #{name.inspect}"
107
+ end
108
+
109
+ item_fields(mapping[:item]).fetch(mapping[:key], *args, &block)
110
+ end
111
+
112
+ private
113
+
114
+ # @api private
115
+ def item_fields(item_name)
116
+ @cache[item_name] ||= load_item_fields(item_name)
117
+ end
118
+
119
+ # @api private
120
+ def load_item_fields(item_name)
121
+ stdout, stderr, status = Open3.capture3("op", "item", "get", item_name, "--vault", @vault, "--format", "json")
122
+ raise "1Password CLI error: #{stderr.strip}" unless status.success?
123
+
124
+ item = JSON.parse(stdout)
125
+ item.fetch("fields", []).each_with_object({}) do |field, hash|
126
+ label = field["label"]
127
+ hash[label] = field["value"] unless label.nil? || label.empty?
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ Hanami::Settings.prepend(Hanami::Settings::OnePasswordStore::Mixin)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanami-settings-stores
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Allen
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.2'
19
+ version: '2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.2'
26
+ version: '2'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: zeitwerk
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '2.7'
33
+ version: '2'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '2.7'
40
+ version: '2'
41
41
  description: Pluggable settings stores that fetch secrets from password managers like
42
42
  1Password and Bitwarden for use with Hanami::Settings.
43
43
  email:
@@ -49,7 +49,9 @@ files:
49
49
  - CHANGELOG.md
50
50
  - LICENSE
51
51
  - lib/hanami-settings-stores.rb
52
+ - lib/hanami/settings/bitwarden_store.rb
52
53
  - lib/hanami/settings/composite_store.rb
54
+ - lib/hanami/settings/one_password_store.rb
53
55
  homepage: https://github.com/aaronmallen/hanami-settings-stores
54
56
  licenses:
55
57
  - MIT