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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +92 -1
- data/lib/portage/cli/buy.rb +4 -1
- 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 +219 -0
- metadata +15 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86b0359cf6f3c98f45853a7407dd03e9688f91212ce1ee05e84727b34450e3ad
|
|
4
|
+
data.tar.gz: 94eeec1e819b534d38055bc55c61a74cc61730d0134e5f7bc5da7aef3a257485
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/portage/cli/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: []
|