portage-cli 0.7.0 → 0.7.4

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.
@@ -0,0 +1,239 @@
1
+ require "rbconfig"
2
+ require_relative "version"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Phase 4 of docs/plans/homebrew-distribution.md's `doctor` checks: how
7
+ # this copy of portage-cli was installed (Homebrew formula or plain
8
+ # `gem install`), which Ruby it runs on, which first-party adapter gems
9
+ # it can load, and whether the `portage` a shell would actually run is
10
+ # this one.
11
+ #
12
+ # That last check is the one that matters. The formula installs into its
13
+ # own Cellar keg, and a `gem install portage-cli` copy in a mise/rbenv/
14
+ # asdf/rvm Ruby's bin usually comes earlier on PATH, so `portage` in a
15
+ # shell silently keeps running the old gem after `brew install` or
16
+ # `brew upgrade` (Homebrew's own caveat hit exactly this on the
17
+ # maintainer's machine). Homebrew's `bin/portage` is a symlink into the
18
+ # Cellar, whose env_script wrapper execs the libexec binary, so every
19
+ # comparison here is between canonical Cellar locations, never raw PATH
20
+ # entries.
21
+ #
22
+ # Everything is read from the filesystem and the process: no `brew`
23
+ # shell-out, no network, so doctor stays fast and works offline (the
24
+ # formula's `test do` block runs it in Homebrew's sandbox).
25
+ class InstallDoctor
26
+ DEFAULT_HOMEBREW_PREFIXES = %w[/opt/homebrew /usr/local /home/linuxbrew/.linuxbrew].freeze
27
+ ADAPTER_GEMS = %w[shopify wix woocommerce bigcommerce magento etsy instagram webmcp decision]
28
+ .map { |name| "portage-ucp-#{name}" }.freeze
29
+ EXECUTABLE = "portage".freeze
30
+
31
+ def self.homebrew_prefixes
32
+ [ENV.fetch("HOMEBREW_PREFIX", nil), *DEFAULT_HOMEBREW_PREFIXES].reject { |p| p.to_s.empty? }.uniq
33
+ end
34
+
35
+ # @param path [String] PATH to search for `portage` executables.
36
+ # @param homebrew_prefixes [Array<String>] candidate HOMEBREW_PREFIXes.
37
+ # @param gem_dir [String] this gem's own directory (its `lib/`'s parent).
38
+ # @param ruby [String] the running Ruby's executable path.
39
+ # @param adapter_probe [#call] gem name -> { installed:, loadable:, version:, error: }.
40
+ def initialize(path: ENV.fetch("PATH", ""), homebrew_prefixes: self.class.homebrew_prefixes,
41
+ gem_dir: File.expand_path("../../..", __dir__), ruby: RbConfig.ruby,
42
+ adapter_probe: method(:probe_adapter))
43
+ @path = path
44
+ @homebrew_prefixes = homebrew_prefixes.map { |prefix| canonical(prefix) }
45
+ @gem_dir = canonical(gem_dir)
46
+ @ruby = ruby
47
+ @adapter_probe = adapter_probe
48
+ end
49
+
50
+ def findings
51
+ [install_finding, runtime_finding, adapters_finding, path_finding].compact
52
+ end
53
+
54
+ # @return [String, nil] the HOMEBREW_PREFIX this copy runs from, nil for a gem install.
55
+ def homebrew_prefix
56
+ return @homebrew_prefix if defined?(@homebrew_prefix)
57
+
58
+ @homebrew_prefix = @homebrew_prefixes.find do |prefix|
59
+ [@gem_dir, canonical(@ruby)].any? { |dir| under?(dir, keg_root(prefix)) }
60
+ end
61
+ end
62
+
63
+ def homebrew? = !homebrew_prefix.nil?
64
+
65
+ private
66
+
67
+ # --- install method -------------------------------------------------
68
+
69
+ def install_finding
70
+ method = homebrew? ? "homebrew" : "gem"
71
+ path = homebrew? ? installed_keg : @gem_dir
72
+ details = { method: method, path: path }
73
+ details[:prefix] = homebrew_prefix if homebrew?
74
+ info("install", "#{method} (#{path})", details)
75
+ end
76
+
77
+ def keg_root(prefix) = File.join(prefix, "Cellar", "portage")
78
+
79
+ # `<prefix>/Cellar/portage/<version>`, the keg this copy lives in.
80
+ def installed_keg
81
+ root = keg_root(homebrew_prefix)
82
+ version = @gem_dir.delete_prefix("#{root}/").split("/").first
83
+ File.join(root, version)
84
+ end
85
+
86
+ # --- runtime --------------------------------------------------------
87
+
88
+ def runtime_finding
89
+ info("runtime", "Ruby #{RUBY_VERSION} (#{@ruby}), portage-cli #{VERSION}",
90
+ { ruby_version: RUBY_VERSION, ruby_path: @ruby, portage_cli_version: VERSION })
91
+ end
92
+
93
+ # --- adapters -------------------------------------------------------
94
+
95
+ # A gem install without adapters is normal: `gem install portage-cli`
96
+ # pulls in none of them, and you add the ones you use. The formula
97
+ # bundles every one, so a Homebrew install missing one is broken.
98
+ def adapters_finding
99
+ report = AdapterReport.new(ADAPTER_GEMS.map { |name| { name: name }.merge(safe_probe(name)) })
100
+ details = { adapters: report.adapters }
101
+ return warning("adapters", report.homebrew_missing_message, details) if homebrew? && report.missing?
102
+
103
+ info("adapters", report.summary, details)
104
+ end
105
+
106
+ def safe_probe(name)
107
+ @adapter_probe.call(name)
108
+ rescue StandardError, ScriptError => e
109
+ { installed: true, loadable: false, version: nil, error: "#{e.class}: #{e.message}" }
110
+ end
111
+
112
+ def probe_adapter(name)
113
+ specs = Gem::Specification.find_all_by_name(name)
114
+ return { installed: false, loadable: false, version: nil } if specs.empty?
115
+
116
+ require "portage/ucp/#{name.delete_prefix('portage-ucp-')}"
117
+ spec = Gem.loaded_specs[name] || specs.max_by(&:version)
118
+ { installed: true, loadable: true, version: spec.version.to_s }
119
+ end
120
+
121
+ # --- PATH shadowing -------------------------------------------------
122
+
123
+ def path_finding
124
+ candidates = path_candidates
125
+ return if candidates.empty?
126
+
127
+ details = { first: candidates.first[:path], candidates: candidates }
128
+ message = shadow_message(candidates.first)
129
+ return warning("path", message, details) if message
130
+
131
+ # Only a Homebrew keg can be matched to this copy for certain; a gem
132
+ # install's binstub can sit behind a mise/rbenv shim.
133
+ suffix = homebrew? ? " (this install)" : ""
134
+ info("path", "`portage` on PATH is #{candidates.first[:path]}#{suffix}", details)
135
+ end
136
+
137
+ # Every `portage` on PATH, first match first, one entry per real file:
138
+ # the same Homebrew bin can sit on PATH twice, and `bin/portage` and
139
+ # `opt/portage/bin/portage` both resolve to one keg.
140
+ def path_candidates
141
+ candidates = @path.split(File::PATH_SEPARATOR).reject(&:empty?).filter_map { |dir| candidate(dir) }
142
+ candidates.uniq { |c| c[:realpath] }
143
+ end
144
+
145
+ def candidate(dir)
146
+ file = File.join(dir, EXECUTABLE)
147
+ return unless File.file?(file) && File.executable?(file)
148
+
149
+ real = canonical(file)
150
+ { path: file, realpath: real, kind: homebrew_keg?(real) ? "homebrew" : "other" }
151
+ end
152
+
153
+ def shadow_message(first)
154
+ if homebrew?
155
+ return if under?(first[:realpath], keg_root(homebrew_prefix))
156
+
157
+ homebrew_shadowed_message(first)
158
+ elsif first[:kind] == "homebrew"
159
+ gem_shadowed_message(first)
160
+ end
161
+ end
162
+
163
+ def homebrew_shadowed_message(first)
164
+ brew_bin = File.join(homebrew_prefix, "bin")
165
+ "`portage` on PATH runs #{first[:path]}, not this Homebrew install (#{brew_bin}/portage), " \
166
+ "so `brew upgrade portage` won't change what your shell runs. Check with `which -a portage`. " \
167
+ "Fix: if that's a `gem install` copy, run `gem uninstall portage-cli` with that Ruby active; " \
168
+ "or put #{brew_bin} ahead of #{File.dirname(first[:path])} in PATH."
169
+ end
170
+
171
+ def gem_shadowed_message(first)
172
+ "`portage` on PATH runs the Homebrew install (#{first[:path]}), not this gem install " \
173
+ "(#{@gem_dir}). Check with `which -a portage`. Fix: keep one — `brew uninstall portage` " \
174
+ "to use the gem, or `gem uninstall portage-cli` to use Homebrew; or reorder PATH."
175
+ end
176
+
177
+ def homebrew_keg?(realpath)
178
+ @homebrew_prefixes.any? { |prefix| under?(realpath, keg_root(prefix)) }
179
+ end
180
+
181
+ # --- helpers --------------------------------------------------------
182
+
183
+ def under?(path, root) = path.start_with?("#{root}/")
184
+
185
+ def canonical(path)
186
+ File.realpath(path)
187
+ rescue SystemCallError
188
+ File.expand_path(path)
189
+ end
190
+
191
+ def info(check, message, details) = finding(check, message, details, "info")
192
+ def warning(check, message, details) = finding(check, message, details, "warning")
193
+
194
+ def finding(check, message, details, level)
195
+ Doctor::Finding.new(check: check, message: message, level: level, details: details)
196
+ end
197
+ end
198
+
199
+ # The adapters line of InstallDoctor's report, one probe result per gem:
200
+ # `{ name:, installed:, loadable:, version:, error: }`.
201
+ class InstallDoctor
202
+ class AdapterReport
203
+ attr_reader :adapters
204
+
205
+ def initialize(adapters)
206
+ @adapters = adapters
207
+ end
208
+
209
+ def missing = @adapters.reject { |a| a[:loadable] }
210
+ def missing? = missing.any?
211
+
212
+ def summary
213
+ loaded, broken, absent = partitioned
214
+ parts = [loaded.empty? ? "none loadable" : loaded.map { |a| "#{short(a)} #{a[:version]}" }.join(", ")]
215
+ parts << "failed to load: #{names(broken)}" unless broken.empty?
216
+ parts << "not installed: #{names(absent)}" unless absent.empty?
217
+ parts.join("; ")
218
+ end
219
+
220
+ def homebrew_missing_message
221
+ described = missing.map { |a| a[:error] ? "#{short(a)} (#{a[:error]})" : short(a) }
222
+ "This Homebrew install bundles every adapter, but #{described.join(', ')} can't be loaded — " \
223
+ "reinstall with `brew reinstall portage`."
224
+ end
225
+
226
+ private
227
+
228
+ def partitioned
229
+ loaded, rest = @adapters.partition { |a| a[:loadable] }
230
+ broken, absent = rest.partition { |a| a[:installed] }
231
+ [loaded, broken, absent]
232
+ end
233
+
234
+ def names(adapters) = adapters.map { |a| short(a) }.join(", ")
235
+ def short(adapter) = adapter[:name].delete_prefix("portage-ucp-")
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,37 @@
1
+ module Portage
2
+ module Cli
3
+ # docs/plans/handoff-reconcile.md Phase 3's "macos" notify channel — a
4
+ # native notification banner for a settled handoff. Opt-in (see
5
+ # ReconcileNotify), and a no-op off Darwin.
6
+ #
7
+ # `system("osascript", "-e", script)` in array form, never an
8
+ # interpolated shell string — same posture as CheckoutHandoff's
9
+ # `open`/`xdg-open`. The injection surface that remains is the
10
+ # AppleScript source itself: `title`/`message` come from a merchant
11
+ # name and an amount, both untrusted, so they're never spliced into the
12
+ # script raw. #applescript_string escapes backslashes and double quotes
13
+ # before interpolating, so a merchant name like `foo" & do shell script
14
+ # "rm -rf ~" & "` can't break out of its quoted string and run as
15
+ # AppleScript.
16
+ class MacosNotifier
17
+ def call(title:, message:)
18
+ return false unless RbConfig::CONFIG["host_os"] =~ /darwin/i
19
+
20
+ script = "display notification #{applescript_string(message)} with title #{applescript_string(title)}"
21
+ !!system("osascript", "-e", script)
22
+ rescue StandardError
23
+ false
24
+ end
25
+
26
+ private
27
+
28
+ def applescript_string(text)
29
+ # Block form, not a replacement string: gsub treats "\\" in a
30
+ # replacement *string* as a backreference escape, which would
31
+ # collapse a literal backslash right back down to one.
32
+ escaped = text.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\"' }
33
+ "\"#{escaped}\""
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,8 +1,11 @@
1
1
  require "net/http"
2
2
  require "json"
3
3
  require "uri"
4
+ require "portage/ucp"
5
+ require "portage/ucp/support/connection"
4
6
  require_relative "config"
5
7
  require_relative "setting"
8
+ require_relative "user_agent"
6
9
 
7
10
  module Portage
8
11
  module Cli
@@ -63,9 +66,9 @@ module Portage
63
66
  private
64
67
 
65
68
  def post(uri, body)
66
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
67
- open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
68
- http.post(uri.request_uri, body, "Content-Type" => "application/json")
69
+ Portage::Ucp::Support::Connection.start(uri, route: :notify, open_timeout: TIMEOUT,
70
+ read_timeout: TIMEOUT) do |http|
71
+ http.post(uri.request_uri, body, UserAgent.headers.merge("Content-Type" => "application/json"))
69
72
  end
70
73
  end
71
74
  end
@@ -5,6 +5,7 @@ require "uri"
5
5
  require "net/http"
6
6
  require "portage/ucp"
7
7
  require "portage/ucp/client"
8
+ require "portage/ucp/support/connection"
8
9
 
9
10
  # Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
10
11
  # #adapter_session below — not require_relative'd here to avoid a load
@@ -14,6 +15,7 @@ require "portage/ucp/client"
14
15
  require_relative "payment_methods/keychain_backend"
15
16
  require_relative "payment_methods/secret_service_backend"
16
17
  require_relative "payment_methods/env_backend"
18
+ require_relative "user_agent"
17
19
 
18
20
  module Portage
19
21
  module Cli
@@ -199,7 +201,7 @@ module Portage
199
201
  # entangled with cart/checkout-specific branching this only needs the
200
202
  # session object from.
201
203
  def discover_session(url)
202
- native = Portage::Ucp::Client.discover(url)
204
+ native = Portage::Ucp::Client.discover(url, headers: UserAgent.headers)
203
205
  native if native
204
206
  rescue Portage::Ucp::Client::DiscoveryError
205
207
  adapter_session(url)
@@ -221,9 +223,9 @@ module Portage
221
223
  end
222
224
 
223
225
  def fetch_homepage(uri)
224
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
225
- open_timeout: 5, read_timeout: 5) do |http|
226
- http.get(uri.request_uri, { "User-Agent" => "portage-payment-enroll" })
226
+ response = Portage::Ucp::Support::Connection.start(uri, route: :payment, open_timeout: 5,
227
+ read_timeout: 5) do |http|
228
+ http.get(uri.request_uri, UserAgent.headers)
227
229
  end
228
230
  response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
229
231
  rescue StandardError
@@ -0,0 +1,16 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # Loopback buy against your own store needs *some* authenticator (§9
6
+ # rejects anonymous mutation by default) — since this process already
7
+ # has this platform's own credentials (that's the gate to even reach the
8
+ # adapter-fallback path, in both Buy and HandoffReconciler), authenticating
9
+ # this local CLI session is reasonable. Shared by both rather than
10
+ # defined once as a nested class of Buy, since HandoffReconciler's own
11
+ # reconnect needs it too and shouldn't reach into Buy's internals for it.
12
+ class PermissiveAuthenticator < Portage::Ucp::Authenticator
13
+ def call(_server_context) = :local_cli
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,149 @@
1
+ require "uri"
2
+ require_relative "proxy_settings"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Phase 2 of docs/plans/proxy-support.md's `doctor` checks for whatever
7
+ # `ProxySettings` resolved: each configured proxy's own reachability (a
8
+ # real CONNECT/GET through it, via Support::Connection — same code path
9
+ # a real request would use, not a re-implementation), whether a
10
+ # configured gateway answers at all, and the two warnings the plan calls
11
+ # out by name (plaintext credentials sitting in config.json, an
12
+ # intercepting proxy on the payment route). Every message redacts
13
+ # credentials the same way Support::Connection.redact does — never the
14
+ # real userinfo, in any finding this produces.
15
+ #
16
+ # Stays entirely separate from Doctor's own Phase 0 `proxy_finding`
17
+ # (the raw $http_proxy/$https_proxy env report) rather than replacing
18
+ # it — that one is still accurate and still worth showing for anyone who
19
+ # hasn't touched Phase 2 flags/config.json at all.
20
+ class ProxyDoctor
21
+ PROBE_URI = URI("https://example.com/").freeze
22
+ ROUTE_NAMES = %w[store search notify payment platform probe].freeze
23
+
24
+ def initialize(proxy_settings:, config: Config.load, timeout: 5, probe_uri: PROBE_URI)
25
+ @proxy_settings = proxy_settings
26
+ @config = config
27
+ @timeout = timeout
28
+ @probe_uri = probe_uri
29
+ end
30
+
31
+ def findings
32
+ [*route_summary_findings, *reachability_findings, *plaintext_credential_findings,
33
+ *payment_intercept_findings].compact
34
+ end
35
+
36
+ private
37
+
38
+ def routes = @proxy_settings.routes
39
+
40
+ # --- effective routing, credentials redacted -----------------------
41
+
42
+ def route_summary_findings
43
+ return [] unless anything_configured?
44
+
45
+ lines = ROUTE_NAMES.map { |name| "#{name}: #{effective_description(name)}" }
46
+ [Doctor::Finding.new(check: "proxy_routes", message: lines.join("; "))]
47
+ end
48
+
49
+ def direct_profile = Portage::Ucp::Support::ProxyConfig::Profile::DIRECT
50
+
51
+ def anything_configured?
52
+ routes.any? { |name, spec| name != "payment" || spec != direct_profile }
53
+ end
54
+
55
+ def effective_description(name)
56
+ return describe(routes[name]) if routes.key?(name)
57
+ return describe(routes["default"]) if routes.key?("default")
58
+
59
+ "unconfigured (falls back to HTTP_PROXY/HTTPS_PROXY env, or direct)"
60
+ end
61
+
62
+ def describe(spec)
63
+ spec.is_a?(Array) ? spec.map { |hop| describe_hop(hop) }.join(" -> ") : describe_hop(spec)
64
+ end
65
+
66
+ def describe_hop(hop)
67
+ return "direct" if hop.direct?
68
+
69
+ "#{hop.mode} #{redact(hop.url)}"
70
+ end
71
+
72
+ def redact(url)
73
+ return "(none)" unless url
74
+
75
+ uri = URI(url)
76
+ userinfo = uri.userinfo ? "***@" : ""
77
+ "#{uri.scheme}://#{userinfo}#{uri.host}:#{uri.port}"
78
+ rescue URI::InvalidURIError
79
+ "(unparseable)"
80
+ end
81
+
82
+ # --- reachability ---------------------------------------------------
83
+
84
+ def reachability_findings
85
+ collect_profiles.filter_map { |profile| reachability_finding(profile) }
86
+ end
87
+
88
+ def collect_profiles
89
+ hops = routes.values.flat_map { |spec| spec.is_a?(Array) ? spec : [spec] }
90
+ hops.reject(&:direct?).uniq(&:url)
91
+ end
92
+
93
+ def reachability_finding(profile)
94
+ probe(profile)
95
+ nil
96
+ rescue StandardError => e
97
+ Doctor::Finding.new(check: "proxy_reachability",
98
+ message: "#{profile.mode} proxy #{redact(profile.url)} is not reachable: " \
99
+ "#{e.class}: #{e.message}")
100
+ end
101
+
102
+ def probe(profile)
103
+ synthetic = Portage::Ucp::Support::ProxyConfig.new(routes: { "probe" => profile })
104
+ Portage::Ucp::Support::Connection.start(@probe_uri, route: "probe", proxy: synthetic,
105
+ open_timeout: @timeout, read_timeout: @timeout) do |http|
106
+ http.get(@probe_uri.request_uri, {})
107
+ end
108
+ end
109
+
110
+ # --- warnings ---------------------------------------------------
111
+
112
+ def plaintext_credential_findings
113
+ offenders = credential_bearing_profiles.select { |profile| plaintext_password?(profile) }
114
+ return [] if offenders.empty?
115
+
116
+ [Doctor::Finding.new(check: "proxy_credentials",
117
+ message: "#{offenders.length} proxy profile(s) in config.json carry a plaintext " \
118
+ "password in their url — use password_ref (Keychain/Secret Service) or " \
119
+ "${ENV} instead.")]
120
+ end
121
+
122
+ def credential_bearing_profiles
123
+ raw = @config.get("proxy") || {}
124
+ [raw["default"], *raw.fetch("chains", {}).values.flatten, *raw.fetch("routes", {}).values].grep(Hash)
125
+ end
126
+
127
+ def plaintext_password?(profile)
128
+ uri = URI(profile["url"].to_s)
129
+ !uri.password.to_s.empty?
130
+ rescue URI::InvalidURIError
131
+ false
132
+ end
133
+
134
+ def payment_intercept_findings
135
+ spec = routes["payment"]
136
+ return [] unless intercepting?(spec)
137
+
138
+ [Doctor::Finding.new(check: "proxy_payment_intercept",
139
+ message: "the payment route is routed through a proxy that can see plaintext payment " \
140
+ "data (ca_file set, or gateway mode): #{describe(spec)}.")]
141
+ end
142
+
143
+ def intercepting?(spec)
144
+ hops = spec.is_a?(Array) ? spec : [spec]
145
+ hops.any? { |hop| hop.gateway? || hop.ca_file }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,48 @@
1
+ require "open3"
2
+ require_relative "../payment_methods"
3
+
4
+ module Portage
5
+ module Cli
6
+ class ProxySettings
7
+ # Resolves `proxy.password_ref` (and any per-hop `password_ref`) the
8
+ # same three-tier way PaymentMethods.detect_backend picks a payment
9
+ # secret store: macOS Keychain, then Linux Secret Service, then
10
+ # nothing in headless mode. Deliberately its own service name rather
11
+ # than PaymentMethods::KeychainBackend/SecretServiceBackend directly —
12
+ # those are scoped to "portage-cli-payment", and their headless tier
13
+ # (EnvBackend#read(_id)) answers *every* id with PORTAGE_PAYMENT_TOKEN
14
+ # regardless of what was asked for, which would make a misconfigured
15
+ # proxy.password_ref silently leak the payment token as a proxy
16
+ # password. A ref that can't be resolved here just comes back nil —
17
+ # ProxySettings turns that into a clear config error rather than
18
+ # sending a proxy request with a literal "password_ref" placeholder.
19
+ module PasswordRef
20
+ SERVICE = "portage-cli-proxy".freeze
21
+
22
+ module_function
23
+
24
+ def resolve(ref)
25
+ return nil if ref.to_s.strip.empty?
26
+ return keychain(ref) if macos? && Portage::Cli::PaymentMethods.executable?("security")
27
+ return secret_service(ref) if linux_session? && Portage::Cli::PaymentMethods.executable?("secret-tool")
28
+
29
+ nil
30
+ end
31
+
32
+ def macos? = RUBY_PLATFORM.include?("darwin")
33
+
34
+ def linux_session? = ENV["DBUS_SESSION_BUS_ADDRESS"].to_s != ""
35
+
36
+ def keychain(ref)
37
+ out, status = Open3.capture2("security", "find-generic-password", "-a", ref, "-s", SERVICE, "-w")
38
+ status.success? ? out.chomp : nil
39
+ end
40
+
41
+ def secret_service(ref)
42
+ out, status = Open3.capture2("secret-tool", "lookup", "service", SERVICE, "account", ref)
43
+ status.success? ? out.chomp : nil
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end