portage-cli 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4a3c3b0ce94430985a4660b316c7fafb711d8814bc9497bba70d866bf927dc1
4
- data.tar.gz: ac8bbf8f8f08ca0eda4962adfa8d240dae05e54b571a40b8aed68a8b3dd3ea03
3
+ metadata.gz: 86b0359cf6f3c98f45853a7407dd03e9688f91212ce1ee05e84727b34450e3ad
4
+ data.tar.gz: 94eeec1e819b534d38055bc55c61a74cc61730d0134e5f7bc5da7aef3a257485
5
5
  SHA512:
6
- metadata.gz: 7484fb5d07dc6c4c9bcfbfb01f84909b6c0c551d3e117bb4854ca5e67049779657272ea3c5c904847d2381780b11ffd839efa0e689c9eb20d2db8bd903126737
7
- data.tar.gz: 70e0a2624e6e20714c2898069e53440cb620044c513a2644f12576b3da96bd0b2be5cc6603a2774a4f229eb8e19d5724889b9353ef3fab628ec6c06587a03a00
6
+ metadata.gz: b8f1f2b0c0a9addba9154c3951c90ec4c3dcd1b8b765e03eca8c13ed17f80c23b7999efaa993c4173f045f12c7ad724705667e00164d5f3a87905caab216197a
7
+ data.tar.gz: 26e138751551e630c86de1ccd2e701f9cf287ef0cf6ed78e852bae7ed8c43922257d3f7a3840a3f8735de0306c4d39e01f40bf9348388041a9516c65bd3dbbd8
data/CHANGELOG.md CHANGED
@@ -6,6 +6,36 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.1] - 2026-09-15
10
+
11
+ - No behavior change — widens the `portage-ucp` dependency pin to `~> 0.6`
12
+ so this gem can install alongside `portage-ucp` 0.6.0 (the pessimistic
13
+ `~> 0.5` pin published with 0.4.0 excludes it).
14
+
15
+ ## [0.4.0] - 2026-09-14
16
+
17
+ - Added `portage payment list/enroll/set-default/remove/freeze/revoke` —
18
+ card-on-file storage so `buy`'s `--payment-token` dead-end can fall back to
19
+ a stored default (`@payment_token ||= PaymentMethods.default`) instead of
20
+ requiring a fresh token on every call (docs/plans/agentic-payments.md
21
+ Phase 1). Storage picks macOS Keychain / Linux Secret Service (`secret-tool`,
22
+ D-Bus session required) / a headless `PORTAGE_PAYMENT_TOKEN`-only tier, in
23
+ that order, with no homegrown fallback store. `enroll` is a browser handoff
24
+ to a gateway-hosted setup page (the new `app.portage-ucp.payment_enrollment`
25
+ capability in `portage-ucp`) — no raw card number ever reaches this
26
+ process.
27
+ - Added `portage payment enroll --scope-merchant/--scope-max-amount/
28
+ --scope-currency` — binds a Phase 2 per-token policy scope at enrollment
29
+ time, written to `Portage::Ucp::Policy` keyed by the same `token_ref`
30
+ `PolicyGuard` derives from the token at charge time.
31
+ - Added `portage policy show/set` — manages the Phase 2 policy file's
32
+ top-level caps/velocity/allowlist (`Portage::Ucp::Policy`), checked by
33
+ `PolicyGuard` on every `complete_checkout`.
34
+ - Widened the `portage-ucp` dependency pin from `~> 0.4` to `~> 0.5` and the
35
+ `portage-ucp-client` pin from `~> 0.2` to `~> 0.3` — this release's
36
+ `Policy`/`PolicyGuard`/`TokenRef` and `Session#create_payment_enrollment`
37
+ calls only exist from those versions on.
38
+
9
39
  ## [0.3.0] - 2026-08-28
10
40
 
11
41
  - Fixed: `find` and `buy` were treating `search_catalog`'s wire envelope
data/README.md CHANGED
@@ -73,6 +73,18 @@ portage compare <url> --product-id ID [--id VALUE ...] [--results N]
73
73
  [--max-price N] [--json]
74
74
  portage history [list] [--purchases|--searches] [--limit N] [--json]
75
75
  portage history clear [--purchases|--searches]
76
+ portage payment list [--json]
77
+ portage payment enroll <url> [--label NAME] [--json]
78
+ [--scope-merchant HOST ...] [--scope-max-amount N] [--scope-currency CUR]
79
+ portage payment set-default <id>
80
+ portage payment remove <id>
81
+ portage payment freeze <id>
82
+ portage payment revoke <id>
83
+ portage policy show [--json]
84
+ portage policy set [--per-transaction-cap N --currency CUR]
85
+ [--rolling-cap N --rolling-window-seconds N --currency CUR]
86
+ [--velocity-count N --velocity-window-seconds N]
87
+ [--allow HOST ...] [--clear-allowlist]
76
88
  ```
77
89
 
78
90
  - `--query` — search term. Against the store's catalog when you name a store,
@@ -80,7 +92,9 @@ portage history clear [--purchases|--searches]
80
92
  - `--qty` — quantity, default `1`.
81
93
  - `--payment-token` — a tokenized payment credential (never a raw card number —
82
94
  `PaymentTokenGuard` in the core gem rejects those before they reach the wire).
83
- Omit for `--dry-run` or to just browse.
95
+ Omit for `--dry-run` or to just browse, or to fall back to whatever
96
+ `portage payment` has on file as the default (see "Payment" below) — the
97
+ flag always wins when both are present.
84
98
  - `--product-id` — buy exactly this product rather than whatever the catalog
85
99
  search ranks first. If the id isn't in the results, nothing is bought.
86
100
  - `--store` — name the merchant without giving a full URL; skips the search.
@@ -162,6 +176,83 @@ portage history clear --purchases # wipe just one
162
176
  This is a local convenience cache, not an audit log — `portage history clear`
163
177
  deletes it outright, and there's no server-side record.
164
178
 
179
+ ### Payment
180
+
181
+ Card-on-file storage for `--payment-token`, so an autonomous agent can
182
+ complete a checkout without a human handing over a fresh token every time
183
+ (docs/plans/agentic-payments.md Phase 1). No raw card number ever touches
184
+ this process — enrollment is a browser handoff to the gateway's own hosted
185
+ setup page:
186
+
187
+ ```bash
188
+ portage payment enroll https://your-shop.example --label "Ops card"
189
+ # → prints a setup_url; visit it, enter the card there, this process polls
190
+ # until the gateway hands back a token, then stores it.
191
+
192
+ portage payment list
193
+ portage payment set-default <id>
194
+ portage payment freeze <id> # blocks spend, keeps the enrollment
195
+ portage payment revoke <id> # deletes the token and the enrollment
196
+ portage payment remove <id> # same as revoke — no processor-side
197
+ # "invalidate this token" call to differ by
198
+ ```
199
+
200
+ `--scope-merchant`/`--scope-max-amount`/`--scope-currency` bind a Phase 2
201
+ policy scope to the token at enrollment time, rather than after the fact:
202
+
203
+ ```bash
204
+ portage payment enroll https://your-shop.example --label "Ops card" \
205
+ --scope-merchant your-shop.example --scope-max-amount 5000 --scope-currency USD
206
+ ```
207
+
208
+ Written to `Policy` keyed by the same `token_ref` `PolicyGuard` derives from
209
+ the token at charge time — enrollment is the only place a scope gets
210
+ attached to a specific token; `portage policy set` below only touches the
211
+ global caps/velocity/allowlist, not per-token scopes.
212
+
213
+ Storage picks the strongest tier your platform actually has, in order, with
214
+ no homegrown fallback store of its own:
215
+
216
+ 1. **macOS Keychain**, via the `security` CLI.
217
+ 2. **Linux Secret Service** (GNOME Keyring/KWallet), via `secret-tool` — only
218
+ when a D-Bus session is actually live.
219
+ 3. **Headless** — no local storage at all. The token *is*
220
+ `PORTAGE_PAYMENT_TOKEN`; `list`/`enroll`/`freeze`/etc. don't apply, since
221
+ there's nothing local to manage.
222
+
223
+ **Local policy guards agent mistakes, not a compromised agent.** Anyone
224
+ running as the local user can read/edit `~/.portage/payment_methods.json` or
225
+ the Keychain/Secret Service entry directly — this is a convenience store, not
226
+ a security boundary. The real backstop against a rogue or compromised agent
227
+ is an issuer-side limit (a virtual card via Stripe Issuing, Privacy.com,
228
+ etc.), not anything in this gem.
229
+
230
+ ### Policy
231
+
232
+ `portage policy show`/`set` manage the Phase 2 policy file
233
+ (`Portage::Ucp::Policy`, checked by `PolicyGuard` on every `complete_checkout`)
234
+ — top-level caps, velocity, and a merchant allowlist that apply regardless of
235
+ which token is spending:
236
+
237
+ ```bash
238
+ portage policy show
239
+ portage policy show --json
240
+
241
+ portage policy set --per-transaction-cap 10000 --currency USD
242
+ portage policy set --rolling-cap 50000 --rolling-window-seconds 86400 --currency USD
243
+ portage policy set --velocity-count 5 --velocity-window-seconds 3600
244
+ portage policy set --allow shop.example.com --allow other-shop.example.com
245
+ portage policy set --clear-allowlist
246
+ ```
247
+
248
+ Each `--*` group is applied independently — `portage policy set --allow
249
+ shop.example.com` touches only the allowlist, leaving caps/velocity as they
250
+ were, so caps and the allowlist can be configured in separate invocations.
251
+ An empty policy (nothing ever set) means every check passes; this is an
252
+ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
253
+ limits bound to one enrolled card) are set via `portage payment enroll
254
+ --scope-*` above, not here.
255
+
165
256
  ### Shipping address (own-store checkouts only)
166
257
 
167
258
  When buying against your own store (`portage buy`'s step 2 adapter-credentials
@@ -3,6 +3,7 @@ require "uri"
3
3
  require "json"
4
4
  require "portage/ucp"
5
5
  require "portage/ucp/client"
6
+ require_relative "payment_methods"
6
7
 
7
8
  module Portage
8
9
  module Cli
@@ -262,9 +263,11 @@ module Portage
262
263
  end
263
264
 
264
265
  def complete(session, source, products, checkout)
266
+ @payment_token ||= PaymentMethods.default
265
267
  unless @payment_token
266
268
  return checkout_report(source, products, checkout,
267
- message: "No --payment-token given can't complete the purchase.")
269
+ message: "No --payment-token given, and no default payment method on file — " \
270
+ "run `portage payment enroll` or pass --payment-token.")
268
271
  end
269
272
 
270
273
  completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
@@ -0,0 +1,28 @@
1
+ module Portage
2
+ module Cli
3
+ class PaymentMethods
4
+ # Headless tier (no D-Bus session — the common case for a server-
5
+ # deployed agent): no local storage at all, consistent with how
6
+ # Resolver.env_for already resolves platform credentials. The token
7
+ # *is* PORTAGE_PAYMENT_TOKEN — there's no id/enrollment/freeze/revoke
8
+ # bookkeeping to do, since there's nowhere local to keep it.
9
+ class EnvBackend
10
+ VAR = "PORTAGE_PAYMENT_TOKEN".freeze
11
+
12
+ def self.available? = true
13
+
14
+ def read(_id) = ENV.fetch(VAR, nil)
15
+
16
+ def write(_id, _token)
17
+ raise NotSupportedError, "headless mode has no local storage — set #{VAR} instead"
18
+ end
19
+
20
+ def delete(_id)
21
+ raise NotSupportedError, "headless mode has no local storage — unset #{VAR} instead"
22
+ end
23
+ end
24
+
25
+ class NotSupportedError < StandardError; end
26
+ end
27
+ end
28
+ end
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.3.0".freeze
3
+ VERSION = "0.4.1".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "cli/buy"
8
8
  require_relative "cli/find"
9
9
  require_relative "cli/compare"
10
10
  require_relative "cli/history"
11
+ require_relative "cli/payment_methods"
11
12
 
12
13
  module Portage
13
14
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -24,6 +25,18 @@ module Portage
24
25
  [--max-price N] [--json]
25
26
  portage history [list] [--purchases|--searches] [--limit N] [--json]
26
27
  portage history clear [--purchases|--searches]
28
+ portage payment list [--json]
29
+ portage payment enroll <url> [--label NAME] [--json]
30
+ [--scope-merchant HOST ...] [--scope-max-amount N] [--scope-currency CUR]
31
+ portage payment set-default <id>
32
+ portage payment remove <id>
33
+ portage payment freeze <id>
34
+ portage payment revoke <id>
35
+ portage policy show [--json]
36
+ portage policy set [--per-transaction-cap N --currency CUR]
37
+ [--rolling-cap N --rolling-window-seconds N --currency CUR]
38
+ [--velocity-count N --velocity-window-seconds N]
39
+ [--allow HOST ...] [--clear-allowlist]
27
40
  USAGE
28
41
 
29
42
  # @param argv [Array<String>]
@@ -35,6 +48,8 @@ module Portage
35
48
  when "find" then run_find(rest)
36
49
  when "compare" then run_compare(rest)
37
50
  when "history" then run_history(rest)
51
+ when "payment" then run_payment(rest)
52
+ when "policy" then run_policy(rest)
38
53
  else
39
54
  warn USAGE
40
55
  1
@@ -298,6 +313,210 @@ module Portage
298
313
  end
299
314
  private_class_method :history_search_line
300
315
 
316
+ # --- payment ---
317
+
318
+ # Maps each single-id subcommand to the PaymentMethods method it calls —
319
+ # collapsing what would otherwise be four near-identical `when` branches
320
+ # (each just yielding a different method to #run_payment_mutate) into one
321
+ # table lookup.
322
+ PAYMENT_MUTATIONS = { "set-default" => :make_default, "remove" => :remove,
323
+ "freeze" => :freeze_method, "revoke" => :revoke }.freeze
324
+
325
+ def self.run_payment(argv)
326
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
327
+ return run_payment_list(argv) if sub == "list"
328
+ return run_payment_enroll(argv) if sub == "enroll"
329
+ return run_payment_mutate(argv, PAYMENT_MUTATIONS[sub]) if PAYMENT_MUTATIONS.key?(sub)
330
+
331
+ warn USAGE
332
+ 1
333
+ end
334
+ private_class_method :run_payment
335
+
336
+ def self.run_payment_list(argv)
337
+ json = false
338
+ OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
339
+ methods = PaymentMethods.new.list
340
+ puts json ? JSON.pretty_generate(methods) : format_payment_list(methods)
341
+ 0
342
+ end
343
+ private_class_method :run_payment_list
344
+
345
+ # Shared by set-default/remove/freeze/revoke — each takes exactly one
346
+ # `<id>` positional arg and reports the (now-updated) entry, or fails
347
+ # cleanly for an id that isn't enrolled.
348
+ def self.run_payment_mutate(argv, method_name)
349
+ id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
350
+ unless id
351
+ warn USAGE
352
+ return 1
353
+ end
354
+
355
+ entry = PaymentMethods.new.public_send(method_name, id)
356
+ puts "#{entry['label']} (#{entry['id']}) — default: #{entry['default']}, frozen: #{entry['frozen']}"
357
+ 0
358
+ rescue PaymentMethods::UnknownMethodError
359
+ warn "No payment method enrolled with id #{id}."
360
+ 1
361
+ end
362
+ private_class_method :run_payment_mutate
363
+
364
+ def self.parse_payment_enroll_options(argv)
365
+ opts = { scope_merchants: [] }
366
+ OptionParser.new do |parser|
367
+ parser.on("--label NAME") { |v| opts[:label] = v }
368
+ parser.on("--json") { opts[:json] = true }
369
+ parser.on("--scope-merchant HOST") { |v| opts[:scope_merchants] << v }
370
+ parser.on("--scope-max-amount N", Integer) { |v| opts[:scope_max_amount] = v }
371
+ parser.on("--scope-currency CUR") { |v| opts[:scope_currency] = v }
372
+ end.parse!(argv)
373
+ opts[:url] = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
374
+ opts
375
+ end
376
+ private_class_method :parse_payment_enroll_options
377
+
378
+ # nil (not `{}`) when no --scope-* flag was given at all, so
379
+ # PaymentMethods#enroll's `scope:` default of no-scope-written stays the
380
+ # behavior for a plain `portage payment enroll` — Phase 2's per-token
381
+ # scope is opt-in.
382
+ def self.payment_enroll_scope(opts)
383
+ return nil if opts[:scope_merchants].empty? && !opts[:scope_max_amount]
384
+
385
+ { merchants: opts[:scope_merchants], max_amount: opts[:scope_max_amount], currency: opts[:scope_currency] }
386
+ .compact
387
+ end
388
+ private_class_method :payment_enroll_scope
389
+
390
+ def self.run_payment_enroll(argv)
391
+ opts = parse_payment_enroll_options(argv)
392
+ unless opts[:url]
393
+ warn USAGE
394
+ return 1
395
+ end
396
+
397
+ result = PaymentMethods.new.enroll(opts[:url], label: opts[:label],
398
+ scope: payment_enroll_scope(opts)) do |setup_url|
399
+ puts "Visit this link to add a card, then wait — polling for completion:\n #{setup_url}"
400
+ end
401
+ puts opts[:json] ? JSON.pretty_generate(result) : format_payment_enroll(result)
402
+ result[:status] == "complete" ? 0 : 1
403
+ rescue PaymentMethods::NotSupportedError => e
404
+ warn e.message
405
+ 1
406
+ end
407
+ private_class_method :run_payment_enroll
408
+
409
+ # --- policy ---
410
+
411
+ def self.run_policy(argv)
412
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
413
+ case sub
414
+ when "show" then run_policy_show(argv)
415
+ when "set" then run_policy_set(argv)
416
+ else
417
+ warn USAGE
418
+ 1
419
+ end
420
+ end
421
+ private_class_method :run_policy
422
+
423
+ def self.run_policy_show(argv)
424
+ json = false
425
+ OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
426
+ policy = Portage::Ucp::Policy.load.to_h
427
+ puts json ? JSON.pretty_generate(policy) : format_policy(policy)
428
+ 0
429
+ end
430
+ private_class_method :run_policy_show
431
+
432
+ def self.format_policy(policy)
433
+ return "(no policy configured — every check passes)" if policy.empty?
434
+
435
+ JSON.pretty_generate(policy)
436
+ end
437
+ private_class_method :format_policy
438
+
439
+ def self.parse_policy_set_options(argv)
440
+ opts = { allow: [] }
441
+ OptionParser.new do |parser|
442
+ parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
443
+ parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
444
+ parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
445
+ parser.on("--currency CUR") { |v| opts[:currency] = v }
446
+ parser.on("--velocity-count N", Integer) { |v| opts[:velocity_count] = v }
447
+ parser.on("--velocity-window-seconds N", Integer) { |v| opts[:velocity_window_seconds] = v }
448
+ parser.on("--allow HOST") { |v| opts[:allow] << v }
449
+ parser.on("--clear-allowlist") { opts[:clear_allowlist] = true }
450
+ end.parse!(argv)
451
+ opts
452
+ end
453
+ private_class_method :parse_policy_set_options
454
+
455
+ # Each `--*` group is applied independently and only when its required
456
+ # fields are present — `portage policy set --allow shop.example.com`
457
+ # touches only the allowlist, leaving caps/velocity untouched, so caps
458
+ # and the allowlist can be configured in separate invocations.
459
+ def self.run_policy_set(argv)
460
+ opts = parse_policy_set_options(argv)
461
+ policy = Portage::Ucp::Policy.load
462
+ set_policy_cap(policy, opts)
463
+ set_policy_velocity(policy, opts)
464
+ set_policy_allowlist(policy, opts)
465
+ puts format_policy(policy.to_h)
466
+ 0
467
+ end
468
+ private_class_method :run_policy_set
469
+
470
+ def self.set_policy_cap(policy, opts)
471
+ if opts[:per_transaction_cap]
472
+ policy.set("per_transaction_cap",
473
+ { "amount" => opts[:per_transaction_cap], "currency" => require_currency!(opts) })
474
+ end
475
+ return unless opts[:rolling_cap] && opts[:rolling_window_seconds]
476
+
477
+ policy.set("rolling_cap", { "amount" => opts[:rolling_cap], "currency" => require_currency!(opts),
478
+ "window_seconds" => opts[:rolling_window_seconds] })
479
+ end
480
+ private_class_method :set_policy_cap
481
+
482
+ def self.set_policy_velocity(policy, opts)
483
+ return unless opts[:velocity_count] && opts[:velocity_window_seconds]
484
+
485
+ policy.set("velocity", { "count" => opts[:velocity_count], "window_seconds" => opts[:velocity_window_seconds] })
486
+ end
487
+ private_class_method :set_policy_velocity
488
+
489
+ def self.set_policy_allowlist(policy, opts)
490
+ return policy.set("merchant_allowlist", []) if opts[:clear_allowlist]
491
+ return if opts[:allow].empty?
492
+
493
+ policy.set("merchant_allowlist", (policy.merchant_allowlist + opts[:allow]).uniq)
494
+ end
495
+ private_class_method :set_policy_allowlist
496
+
497
+ def self.require_currency!(opts)
498
+ opts[:currency] || raise(ArgumentError, "--currency is required alongside a cap")
499
+ end
500
+ private_class_method :require_currency!
501
+
502
+ def self.format_payment_list(methods)
503
+ return "(no payment methods enrolled)" if methods.empty?
504
+
505
+ methods.map { |m| "#{m['label']} (#{m['id']}) — default: #{m['default']}, frozen: #{m['frozen']}" }.join("\n")
506
+ end
507
+ private_class_method :format_payment_list
508
+
509
+ def self.format_payment_enroll(result)
510
+ case result[:status]
511
+ when "complete" then "Enrolled #{result[:label]} (#{result[:id]})."
512
+ when "pending"
513
+ "Timed out waiting for enrollment — finish it at #{result[:setup_url]}, then run " \
514
+ "`portage payment enroll` again."
515
+ else "This store doesn't support payment enrollment."
516
+ end
517
+ end
518
+ private_class_method :format_payment_enroll
519
+
301
520
  # --- output ---
302
521
 
303
522
  def self.format_report(report)
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: portage-ucp
@@ -15,28 +16,28 @@ dependencies:
15
16
  requirements:
16
17
  - - "~>"
17
18
  - !ruby/object:Gem::Version
18
- version: '0.4'
19
+ version: '0.6'
19
20
  type: :runtime
20
21
  prerelease: false
21
22
  version_requirements: !ruby/object:Gem::Requirement
22
23
  requirements:
23
24
  - - "~>"
24
25
  - !ruby/object:Gem::Version
25
- version: '0.4'
26
+ version: '0.6'
26
27
  - !ruby/object:Gem::Dependency
27
28
  name: portage-ucp-client
28
29
  requirement: !ruby/object:Gem::Requirement
29
30
  requirements:
30
31
  - - "~>"
31
32
  - !ruby/object:Gem::Version
32
- version: '0.2'
33
+ version: '0.3'
33
34
  type: :runtime
34
35
  prerelease: false
35
36
  version_requirements: !ruby/object:Gem::Requirement
36
37
  requirements:
37
38
  - - "~>"
38
39
  - !ruby/object:Gem::Version
39
- version: '0.2'
40
+ version: '0.3'
40
41
  - !ruby/object:Gem::Dependency
41
42
  name: rspec
42
43
  requirement: !ruby/object:Gem::Requirement
@@ -103,6 +104,7 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
103
104
  ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
104
105
  no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
105
106
  portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
107
+ email:
106
108
  executables:
107
109
  - portage
108
110
  extensions: []
@@ -118,6 +120,10 @@ files:
118
120
  - lib/portage/cli/compare.rb
119
121
  - lib/portage/cli/find.rb
120
122
  - lib/portage/cli/history.rb
123
+ - lib/portage/cli/payment_methods.rb
124
+ - lib/portage/cli/payment_methods/env_backend.rb
125
+ - lib/portage/cli/payment_methods/keychain_backend.rb
126
+ - lib/portage/cli/payment_methods/secret_service_backend.rb
121
127
  - lib/portage/cli/probe_cache.rb
122
128
  - lib/portage/cli/search_backends.rb
123
129
  - lib/portage/cli/shipping_profile.rb
@@ -129,6 +135,7 @@ metadata:
129
135
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
130
136
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
131
137
  rubygems_mfa_required: 'true'
138
+ post_install_message:
132
139
  rdoc_options: []
133
140
  require_paths:
134
141
  - lib
@@ -143,7 +150,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
143
150
  - !ruby/object:Gem::Version
144
151
  version: '0'
145
152
  requirements: []
146
- rubygems_version: 3.6.9
153
+ rubygems_version: 3.5.22
154
+ signing_key:
147
155
  specification_version: 4
148
156
  summary: portage — one CLI command to buy from any store, native UCP or not
149
157
  test_files: []