portage-cli 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +172 -1
- data/lib/portage/cli/buy.rb +12 -8
- data/lib/portage/cli/catalog_products.rb +29 -0
- data/lib/portage/cli/compare.rb +160 -0
- data/lib/portage/cli/find.rb +8 -10
- data/lib/portage/cli/history.rb +72 -0
- data/lib/portage/cli/payment_methods/env_backend.rb +28 -0
- data/lib/portage/cli/payment_methods/keychain_backend.rb +42 -0
- data/lib/portage/cli/payment_methods/secret_service_backend.rb +39 -0
- data/lib/portage/cli/payment_methods.rb +265 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +367 -0
- metadata +18 -7
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
class PaymentMethods
|
|
6
|
+
# macOS Keychain, via the `security` CLI (Open3 — array args, never an
|
|
7
|
+
# interpolated shell string, so an id/token containing shell metachars
|
|
8
|
+
# can't inject). The token is the generic-password's own secret; `id`
|
|
9
|
+
# is the account name, always scoped to SERVICE so it never collides
|
|
10
|
+
# with an unrelated Keychain entry.
|
|
11
|
+
class KeychainBackend
|
|
12
|
+
SERVICE = "portage-cli-payment".freeze
|
|
13
|
+
|
|
14
|
+
def self.available?
|
|
15
|
+
RUBY_PLATFORM.include?("darwin") && Portage::Cli::PaymentMethods.executable?("security")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# `-U` upserts rather than erroring on a pre-existing account, so
|
|
19
|
+
# re-enrolling under the same id just replaces the secret.
|
|
20
|
+
def write(id, token)
|
|
21
|
+
run("add-generic-password", "-a", id, "-s", SERVICE, "-w", token, "-U")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read(id)
|
|
25
|
+
out, status = Open3.capture2("security", "find-generic-password", "-a", id, "-s", SERVICE, "-w")
|
|
26
|
+
status.success? ? out.chomp : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(id)
|
|
30
|
+
run("delete-generic-password", "-a", id, "-s", SERVICE)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def run(*)
|
|
36
|
+
Open3.capture2("security", *)
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
class PaymentMethods
|
|
6
|
+
# Linux Secret Service (GNOME Keyring/KWallet via D-Bus), through the
|
|
7
|
+
# `secret-tool` CLI. Only reachable with a live D-Bus session — the
|
|
8
|
+
# headless/no-session case falls through to EnvBackend instead (see
|
|
9
|
+
# PaymentMethods.detect_backend), never a homegrown fallback store.
|
|
10
|
+
class SecretServiceBackend
|
|
11
|
+
SERVICE = "portage-cli-payment".freeze
|
|
12
|
+
|
|
13
|
+
def self.available?
|
|
14
|
+
ENV["DBUS_SESSION_BUS_ADDRESS"].to_s != "" && Portage::Cli::PaymentMethods.executable?("secret-tool")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `secret-tool store` reads the secret from stdin rather than argv,
|
|
18
|
+
# so it never shows up in `ps`/shell history.
|
|
19
|
+
def write(id, token)
|
|
20
|
+
Open3.capture2(
|
|
21
|
+
"secret-tool", "store", "--label=Portage payment method #{id}",
|
|
22
|
+
"service", SERVICE, "account", id, stdin_data: token
|
|
23
|
+
)
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read(id)
|
|
28
|
+
out, status = Open3.capture2("secret-tool", "lookup", "service", SERVICE, "account", id)
|
|
29
|
+
status.success? ? out.chomp : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete(id)
|
|
33
|
+
Open3.capture2("secret-tool", "clear", "service", SERVICE, "account", id)
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "portage/ucp"
|
|
7
|
+
require "portage/ucp/client"
|
|
8
|
+
|
|
9
|
+
# Buy::PermissiveAuthenticator (adapter-loopback auth) is used by
|
|
10
|
+
# #adapter_session below — not require_relative'd here to avoid a load
|
|
11
|
+
# cycle (buy.rb will require this file too, for PaymentMethods.default);
|
|
12
|
+
# cli.rb requires "cli/buy" before "cli/payment_methods", so it's already
|
|
13
|
+
# loaded by the time #enroll actually runs.
|
|
14
|
+
require_relative "payment_methods/keychain_backend"
|
|
15
|
+
require_relative "payment_methods/secret_service_backend"
|
|
16
|
+
require_relative "payment_methods/env_backend"
|
|
17
|
+
|
|
18
|
+
module Portage
|
|
19
|
+
module Cli
|
|
20
|
+
# Card-on-file store for `portage buy`'s `--payment-token` dead-end
|
|
21
|
+
# (buy.rb) — three backend tiers, picked once per process by
|
|
22
|
+
# .detect_backend, no homegrown crypto or fallback file store of our
|
|
23
|
+
# own (docs/plans/agentic-payments.md Phase 1):
|
|
24
|
+
#
|
|
25
|
+
# 1. macOS Keychain (KeychainBackend, shells out to `security`)
|
|
26
|
+
# 2. Linux Secret Service (SecretServiceBackend, shells out to
|
|
27
|
+
# `secret-tool`) — only when a D-Bus session is actually live
|
|
28
|
+
# 3. Headless (EnvBackend) — no local storage; the token comes
|
|
29
|
+
# straight from PORTAGE_PAYMENT_TOKEN
|
|
30
|
+
#
|
|
31
|
+
# The secret itself lives in the backend; this class only keeps
|
|
32
|
+
# non-secret bookkeeping (label/default/frozen) in
|
|
33
|
+
# ~/.portage/payment_methods.json — irrelevant for the headless tier,
|
|
34
|
+
# which has no ids to track at all.
|
|
35
|
+
#
|
|
36
|
+
# Local policy guards agent mistakes, not a compromised agent: anyone
|
|
37
|
+
# running as the local user can edit payment_methods.json directly, so
|
|
38
|
+
# the real backstop against a rogue/compromised agent is an issuer-side
|
|
39
|
+
# limit (a virtual card via Stripe Issuing, Privacy.com, etc.), not this
|
|
40
|
+
# file.
|
|
41
|
+
class PaymentMethods
|
|
42
|
+
PATH = File.join(Dir.home, ".portage", "payment_methods.json").freeze
|
|
43
|
+
|
|
44
|
+
class UnknownMethodError < StandardError; end
|
|
45
|
+
|
|
46
|
+
def self.executable?(name)
|
|
47
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
48
|
+
File.executable?(File.join(dir, name)) && !File.directory?(File.join(dir, name))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.detect_backend
|
|
53
|
+
return KeychainBackend.new if KeychainBackend.available?
|
|
54
|
+
return SecretServiceBackend.new if SecretServiceBackend.available?
|
|
55
|
+
|
|
56
|
+
EnvBackend.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.default(path: PATH, backend: detect_backend) = new(path: path, backend: backend).default
|
|
60
|
+
|
|
61
|
+
def initialize(path: PATH, backend: self.class.detect_backend)
|
|
62
|
+
@path = path
|
|
63
|
+
@backend = backend
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String, nil] the token `portage buy` should use when
|
|
67
|
+
# `--payment-token` was omitted, or nil if there isn't one it can
|
|
68
|
+
# safely use (nothing enrolled, or the only default is frozen).
|
|
69
|
+
def default
|
|
70
|
+
return @backend.read(nil) if headless?
|
|
71
|
+
|
|
72
|
+
entry = store["methods"].find { |m| m["default"] }
|
|
73
|
+
return nil if entry.nil? || entry["frozen"]
|
|
74
|
+
|
|
75
|
+
@backend.read(entry["id"])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Array<Hash>] non-secret metadata only — never the token.
|
|
79
|
+
def list
|
|
80
|
+
return [] if headless?
|
|
81
|
+
|
|
82
|
+
store["methods"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def make_default(id)
|
|
86
|
+
entry = find!(id)
|
|
87
|
+
store["methods"].each { |m| m["default"] = (m["id"] == id) }
|
|
88
|
+
write
|
|
89
|
+
entry
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `remove` and `revoke` are the same hard delete (metadata entry +
|
|
93
|
+
# backend secret, both gone) — the plan names them separately, but
|
|
94
|
+
# with no processor-side "invalidate this token" capability to defer
|
|
95
|
+
# to, there's no real distinction to invent between "remove" and
|
|
96
|
+
# "delete the token" beyond the name. Both exist as CLI subcommands so
|
|
97
|
+
# either reads naturally in the moment ("get rid of this" vs. "this
|
|
98
|
+
# card's compromised, kill it").
|
|
99
|
+
def remove(id)
|
|
100
|
+
entry = find!(id)
|
|
101
|
+
store["methods"].reject! { |m| m["id"] == id }
|
|
102
|
+
write
|
|
103
|
+
@backend.delete(id)
|
|
104
|
+
entry
|
|
105
|
+
end
|
|
106
|
+
alias revoke remove
|
|
107
|
+
|
|
108
|
+
# Blocks spend without forgetting the enrollment — `default` returns
|
|
109
|
+
# nil for a frozen default, but the metadata entry (and backend
|
|
110
|
+
# secret) stay put. No "unfreeze" — the plan draws the line at v1
|
|
111
|
+
# only needing revoke to fully undo an enrollment.
|
|
112
|
+
def freeze_method(id)
|
|
113
|
+
entry = find!(id)
|
|
114
|
+
entry["frozen"] = true
|
|
115
|
+
write
|
|
116
|
+
entry
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Starts a browser-handoff enrollment against `url` (native UCP
|
|
120
|
+
# manifest, or the own-store adapter loopback — same discovery
|
|
121
|
+
# buy.rb#call uses) and blocks polling
|
|
122
|
+
# app.portage-ucp.payment_enrollment until the gateway-hosted setup
|
|
123
|
+
# page resolves to a token or `timeout` elapses. Prints nothing
|
|
124
|
+
# itself — callers (Cli.run_payment_enroll) own presentation.
|
|
125
|
+
#
|
|
126
|
+
# Yields the gateway-hosted `setup_url` to the given block as soon as
|
|
127
|
+
# it's known (before polling starts) — the caller's one chance to show
|
|
128
|
+
# it, since this method otherwise doesn't return until "complete" or
|
|
129
|
+
# `timeout` elapses.
|
|
130
|
+
#
|
|
131
|
+
# @return [Hash] {status:, setup_url:, id:, label:} — status is
|
|
132
|
+
# "complete", "pending" (timed out — the CLI can re-poll later
|
|
133
|
+
# against the same enrollment id), or "unsupported" (nothing at
|
|
134
|
+
# `url` advertises payment enrollment).
|
|
135
|
+
# @param scope [Hash, nil] Phase 2 per-token policy scope, bound at
|
|
136
|
+
# enrollment time (docs/plans/agentic-payments.md) — e.g.
|
|
137
|
+
# `{merchants: ["shop.example.com"], max_amount: 5000, currency: "USD"}`.
|
|
138
|
+
# Written to Portage::Ucp::Policy keyed by the same token_ref
|
|
139
|
+
# PolicyGuard derives from the token at charge time, never to
|
|
140
|
+
# payment_methods.json — policy config is portage-ucp's file, not
|
|
141
|
+
# this gem's.
|
|
142
|
+
def enroll(url, label: nil, scope: nil, poll_interval: 3, timeout: 300, sleeper: ->(s) { sleep(s) })
|
|
143
|
+
raise NotSupportedError, "headless mode has no local storage — set PORTAGE_PAYMENT_TOKEN instead" if headless?
|
|
144
|
+
|
|
145
|
+
session = discover_session(url)
|
|
146
|
+
return { status: "unsupported" } unless session && payment_enrollment_advertised?(session)
|
|
147
|
+
|
|
148
|
+
enrollment = session.create_payment_enrollment
|
|
149
|
+
yield enrollment["setup_url"] if block_given?
|
|
150
|
+
poll_until_resolved(session, enrollment, label, scope, poll_interval, timeout, sleeper)
|
|
151
|
+
rescue Portage::Ucp::Client::Error
|
|
152
|
+
# Capability not actually there despite #advertises? being nil
|
|
153
|
+
# (own-store adapter loopback doesn't know capabilities upfront —
|
|
154
|
+
# see Session#advertises?) — the adapter simply never registered the
|
|
155
|
+
# tool, surfaced as a client-side error rather than a Ruby NoMethodError.
|
|
156
|
+
{ status: "unsupported" }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def headless?
|
|
162
|
+
@backend.is_a?(EnvBackend)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def poll_until_resolved(session, enrollment, label, scope, poll_interval, timeout, sleeper)
|
|
166
|
+
deadline = Time.now + timeout
|
|
167
|
+
current = enrollment
|
|
168
|
+
until current["status"] == "complete" || Time.now >= deadline
|
|
169
|
+
sleeper.call(poll_interval)
|
|
170
|
+
current = session.get_payment_enrollment(enrollment_id: current["id"])
|
|
171
|
+
return { status: "unsupported" } unless current
|
|
172
|
+
end
|
|
173
|
+
return { status: "pending", setup_url: enrollment["setup_url"], id: enrollment["id"] } unless
|
|
174
|
+
current["status"] == "complete"
|
|
175
|
+
|
|
176
|
+
{ status: "complete", **enroll_locally(current["payment_token"], label, scope) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def enroll_locally(token, label, scope)
|
|
180
|
+
id = SecureRandom.uuid
|
|
181
|
+
@backend.write(id, token)
|
|
182
|
+
entry = { "id" => id, "label" => label || id, "frozen" => false,
|
|
183
|
+
"default" => store["methods"].empty?, "created_at" => Time.now.utc.iso8601 }
|
|
184
|
+
store["methods"] << entry
|
|
185
|
+
write
|
|
186
|
+
set_token_scope(token, scope) if scope
|
|
187
|
+
{ id: id, label: entry["label"] }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def set_token_scope(token, scope)
|
|
191
|
+
token_ref = Portage::Ucp::Support::TokenRef.for(token)
|
|
192
|
+
Portage::Ucp::Policy.load.set_token_scope(token_ref, stringify_keys(scope))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def stringify_keys(hash) = hash.transform_keys(&:to_s)
|
|
196
|
+
|
|
197
|
+
# Same native-manifest-first, own-store-adapter-fallback discovery as
|
|
198
|
+
# Buy#call — duplicated rather than extracted since Buy's version is
|
|
199
|
+
# entangled with cart/checkout-specific branching this only needs the
|
|
200
|
+
# session object from.
|
|
201
|
+
def discover_session(url)
|
|
202
|
+
native = Portage::Ucp::Client.discover(url)
|
|
203
|
+
native if native
|
|
204
|
+
rescue Portage::Ucp::Client::DiscoveryError
|
|
205
|
+
adapter_session(url)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def adapter_session(url)
|
|
209
|
+
uri = URI.parse(url.to_s =~ %r{\Ahttps?://}i ? url.to_s : "https://#{url}")
|
|
210
|
+
body, headers = fetch_homepage(uri)
|
|
211
|
+
platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
|
|
212
|
+
return nil unless platform
|
|
213
|
+
|
|
214
|
+
env = Portage::Ucp::Resolver.env_for(platform)
|
|
215
|
+
return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
|
|
216
|
+
|
|
217
|
+
adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
|
|
218
|
+
Portage::Ucp::Client.for_adapter(adapter, authenticator: Buy::PermissiveAuthenticator.new)
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
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" })
|
|
227
|
+
end
|
|
228
|
+
response.is_a?(Net::HTTPSuccess) ? [response.body, response.to_hash] : [nil, {}]
|
|
229
|
+
rescue StandardError
|
|
230
|
+
[nil, {}]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def payment_enrollment_advertised?(session)
|
|
234
|
+
session.advertises?("app.portage-ucp.payment_enrollment") != false
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def find!(id)
|
|
238
|
+
store["methods"].find { |m| m["id"] == id } || raise(UnknownMethodError, id)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def store
|
|
242
|
+
@store ||= read
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def read
|
|
246
|
+
parsed = File.readable?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
247
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
248
|
+
{ "methods" => Array(parsed["methods"]) }
|
|
249
|
+
rescue StandardError
|
|
250
|
+
{ "methods" => [] }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Unlike History#write, a failed write on the payment path is fatal
|
|
254
|
+
# (docs/plans/agentic-payments.md's "transaction log writes are fatal
|
|
255
|
+
# on the payment path" applies here too — a swallowed write here would
|
|
256
|
+
# silently un-set/re-set a default or forget a freeze) — raises
|
|
257
|
+
# rather than the `rescue StandardError; nil` pattern history.rb uses.
|
|
258
|
+
def write
|
|
259
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
260
|
+
File.write(@path, JSON.generate(@store))
|
|
261
|
+
File.chmod(0o600, @path)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED