portage-cli 0.7.0 → 0.7.3
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 +107 -0
- data/README.md +146 -1
- data/lib/portage/cli/buy.rb +187 -41
- data/lib/portage/cli/doctor.rb +87 -1
- data/lib/portage/cli/find.rb +2 -1
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +6 -3
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +236 -10
- metadata +36 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 996ddc0acd1ec2676257f402fbb03148e8a38c40af9d5cf4fbe50d176416a334
|
|
4
|
+
data.tar.gz: a451d4e8130b1891fb2630d9b5e9edc1efde9486afda2ca3ff863342943d4a09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd25c1c868d3e70f6ef469975f0d2b24f478c1f96bf83a739d2e4a473c38d2695fbd3586e0243ac21f9d75562b080413ad173c18924024ff1135279cfe8622be
|
|
7
|
+
data.tar.gz: e8578d6935466578e44e9e4f2e2caae61e1b9a1b2b52051934989fd28ec8ea9a163d943e3f68aac3704bdae72557d600f4694c7c81b8e9fe00b16685640a10b6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,113 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
5
|
pre-1.0, so APIs may still shift between minor versions.
|
|
6
6
|
|
|
7
|
+
## [0.7.3] - 2026-09-25
|
|
8
|
+
|
|
9
|
+
- **Proxy support** (`docs/plans/proxy-support.md` Phases 2-3). Every
|
|
10
|
+
network command (`buy`, `find`, `compare`, `doctor`, `payment enroll`)
|
|
11
|
+
takes `--proxy`, `--proxy-mode`, `--proxy-header`, `--no-proxy`,
|
|
12
|
+
`--proxy-route`, `--proxy-chain`, `--proxy-passthrough`, `--proxy-ca` and
|
|
13
|
+
`--no-env-proxy`, resolved per field against `PORTAGE_PROXY*` env vars
|
|
14
|
+
and `~/.portage/config.json`'s `proxy` section into core's
|
|
15
|
+
`Support::ProxyConfig`. `proxy.password_ref` resolves through Keychain or
|
|
16
|
+
Secret Service (service `portage-cli-proxy`). The payment route stays
|
|
17
|
+
direct unless a proxy is named for it explicitly, so payment traffic no
|
|
18
|
+
longer inherits a bare `$http_proxy`/`$https_proxy`. `portage doctor`
|
|
19
|
+
probes each configured proxy through `Support::Connection` and warns on
|
|
20
|
+
plaintext credentials in `config.json`.
|
|
21
|
+
- Requires `portage-ucp` `~> 0.10` (for `Support::Connection` and
|
|
22
|
+
`ProxyConfig`) and `portage-ucp-client` `>= 0.6.3` (for
|
|
23
|
+
`Client::USER_AGENT`, which the default User-Agent is built from). Both
|
|
24
|
+
floors were too low before: against `portage-ucp` 0.9.0 or
|
|
25
|
+
`portage-ucp-client` 0.6.2, `require "portage/cli"` raised `NameError`.
|
|
26
|
+
- `portage --version` (also `-v` and `portage version`) prints the gem's
|
|
27
|
+
version and exits 0. Needed so a packaged install — the Homebrew
|
|
28
|
+
formula's offline `test do` block, or anyone else scripting around a
|
|
29
|
+
release — can confirm which build is on `PATH` without hitting the
|
|
30
|
+
network.
|
|
31
|
+
- **`portage-console` failed to start on Ruby 4.0.** It requires `irb`,
|
|
32
|
+
which stopped being a default gem in Ruby 4.0 (it's a bundled gem now),
|
|
33
|
+
and the gemspec never declared it, so any isolated install
|
|
34
|
+
(Homebrew's, or anything under Bundler) raised `LoadError: cannot load
|
|
35
|
+
such file -- irb`. `irb` is now a runtime dependency.
|
|
36
|
+
- **Did the shopper finish the checkout? `portage orders reconcile`.**
|
|
37
|
+
Every checkout `portage buy` hands to the shopper's own browser
|
|
38
|
+
(`requires_escalation`, `permission_denied`, `no_payment_token`,
|
|
39
|
+
`policy_blocked`, `low_confidence`, `checkout_mismatch`) now reserves a
|
|
40
|
+
pending record in the transaction log (best-effort — a failed write is a
|
|
41
|
+
report warning, never blocks the hand-off). `portage orders reconcile
|
|
42
|
+
[--checkout ID] [--json]` re-fetches each pending checkout from the store
|
|
43
|
+
and settles it: `complete` only on a store-reported `completed` status,
|
|
44
|
+
`failed` on `canceled` or an unanswered expiry, otherwise stays pending.
|
|
45
|
+
Never infers success from a vanished checkout or a plain timeout. Safe to
|
|
46
|
+
run from cron/launchd. See `docs/plans/handoff-reconcile.md`.
|
|
47
|
+
- **`handoff_spend_mode`** (`PORTAGE_HANDOFF_SPEND_MODE`, config.json) —
|
|
48
|
+
whether a reconciled shopper purchase counts toward the buyer's own spend
|
|
49
|
+
cap/velocity limit. `block` (default): counts like any agent purchase.
|
|
50
|
+
`warn`: recorded but excluded from cap math. `precheck`: `block`, plus a
|
|
51
|
+
spend-cap check at hand-off time that suppresses auto-open (never the
|
|
52
|
+
URL) when this checkout would already exceed the cap.
|
|
53
|
+
- Phase 0 of the plan above (a live signal check against a real store) was
|
|
54
|
+
not run before this shipped — see `docs/design-log.md` §44.
|
|
55
|
+
- **`portage buy --wait [--wait-timeout DURATION|off]`.** After a hand-off,
|
|
56
|
+
polls `HandoffReconciler` with backoff (2s → 30s, plus jitter) until the
|
|
57
|
+
checkout settles or its deadline passes — the earlier of
|
|
58
|
+
`handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json;
|
|
59
|
+
default 30m, `off` removes it) and the checkout's own `expires_at`.
|
|
60
|
+
Ctrl-C or the deadline leaves the record pending for a later `portage
|
|
61
|
+
orders reconcile`; it never settles from the wait itself. Under `--wait
|
|
62
|
+
--json`, stdout streams NDJSON (`handoff`, `handoff_status` on each
|
|
63
|
+
store-reported status change, `handoff_settled`) followed by the final
|
|
64
|
+
report object; plain `--json` with no `--wait` is unchanged byte-for-byte.
|
|
65
|
+
- **`reconcile_notify`** (`PORTAGE_RECONCILE_NOTIFY`, config.json) — a comma
|
|
66
|
+
list of channels a settled hand-off notifies on, from `--wait` or `portage
|
|
67
|
+
orders reconcile`. Default `webhook`. Adds `macos` (a native notification,
|
|
68
|
+
merchant/amount escaped into a fixed AppleScript template) and `terminal`
|
|
69
|
+
(a printed line, forced on for a plain-text `--wait` regardless of
|
|
70
|
+
configuration). `journal` is accepted for documentation — the Phase 1
|
|
71
|
+
order-snapshot journal write was already unconditional.
|
|
72
|
+
- **WebMCP outbound, opt-in (Phase 4, partial).** `Buy.new(webmcp_bridge:)`
|
|
73
|
+
takes a `portage-ucp-webmcp` outbound bridge already pointed at a
|
|
74
|
+
navigated page; when given one, `portage-cli` tries it after native-UCP
|
|
75
|
+
discovery finds nothing and before a platform-adapter fallback. Only
|
|
76
|
+
`webmcp_checkout_mode: express_stop` (the default) is implemented: it
|
|
77
|
+
builds the cart/checkout and always hands off (reason `express_stop`),
|
|
78
|
+
feeding Phases 1-3 unchanged, since a WebMCP page doesn't expose
|
|
79
|
+
`complete_checkout` by default anyway. `token` mode reports
|
|
80
|
+
`webmcp_token_unsupported` rather than attempting completion — it still
|
|
81
|
+
needs a `Confirmer`-swap seam in `Mcp::Server.build` and payment-token
|
|
82
|
+
enrollment that don't exist yet. No new hard runtime dependency:
|
|
83
|
+
`portage-ucp-webmcp` is lazily `require`d, same posture as an optional
|
|
84
|
+
platform adapter gem.
|
|
85
|
+
|
|
86
|
+
## [0.7.2] - 2026-09-24
|
|
87
|
+
|
|
88
|
+
- The User-Agent every store-facing request sends (`Cli::UserAgent`, née
|
|
89
|
+
`Cli::USER_AGENT`/`Cli::HTTP_HEADERS`) is now configurable: `PORTAGE_USER_AGENT`
|
|
90
|
+
beats `~/.portage/config.json`'s `user_agent` key, both of which beat the
|
|
91
|
+
`portage-cli/<ver> portage-ucp-client/<ver> (+https://github.com/...)`
|
|
92
|
+
default — same precedence `Notifier`'s webhook URL already uses. `portage
|
|
93
|
+
doctor` (and its `configure`/`setup` aliases) now flags a configured value
|
|
94
|
+
containing a stray newline, since `Net::HTTP` would otherwise raise on it
|
|
95
|
+
mid-checkout instead of at setup time.
|
|
96
|
+
|
|
97
|
+
## [0.7.1] - 2026-09-24
|
|
98
|
+
|
|
99
|
+
- Fix: `buy <url> --max-price` (and `--product-id` with `--max-price`) was
|
|
100
|
+
silently ignored — `add_search_options` only wired `--max-price` into the
|
|
101
|
+
`find` options, not `buy`, so a URL-driven buy could complete over the cap
|
|
102
|
+
the caller set. `Buy#select_product` now filters to products within
|
|
103
|
+
`max_price` first, using a variant's own price where available and
|
|
104
|
+
otherwise the product's lowest price (unpriced products stay eligible,
|
|
105
|
+
same rule `Find` already used). `no_match_message` now names the cap.
|
|
106
|
+
- Every store-facing request (`discover()` calls and the notifier webhook
|
|
107
|
+
POST) now sends `portage-cli/<ver> portage-ucp-client/<ver>
|
|
108
|
+
(+https://github.com/tomtom87/Portage)` instead of Ruby's or Faraday's
|
|
109
|
+
default User-Agent. New `Cli::USER_AGENT`/`Cli::HTTP_HEADERS`
|
|
110
|
+
(`lib/portage/cli/user_agent.rb`) replace the ad-hoc UA strings each of
|
|
111
|
+
`portage-buy`, `portage-find` and `portage-payment-enroll` built on their
|
|
112
|
+
own. Requires `portage-ucp-client >= 0.6.3`.
|
|
113
|
+
|
|
7
114
|
## [0.7.0] - 2026-09-23
|
|
8
115
|
|
|
9
116
|
- **The decision layer is wired in.** `buy`/`find` make their judgment
|
data/README.md
CHANGED
|
@@ -70,6 +70,7 @@ gem install portage-cli
|
|
|
70
70
|
portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
|
|
71
71
|
[--yes] [--dry-run] [--decision-backend jev|laya]
|
|
72
72
|
[--min-confidence N] [--json]
|
|
73
|
+
[--wait [--wait-timeout DURATION|off]]
|
|
73
74
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
74
75
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
75
76
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
@@ -88,8 +89,20 @@ portage policy set [--per-transaction-cap N --currency CUR]
|
|
|
88
89
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
89
90
|
[--velocity-count N --velocity-window-seconds N]
|
|
90
91
|
[--allow HOST ...] [--clear-allowlist]
|
|
92
|
+
portage orders reconcile [--checkout ID] [--json]
|
|
91
93
|
```
|
|
92
94
|
|
|
95
|
+
`buy`/`find`/`compare`/`doctor`/`payment enroll` (the network-touching commands —
|
|
96
|
+
`orders reconcile` doesn't take these) also accept:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
[--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
|
|
100
|
+
[--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
|
|
101
|
+
[--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
See "Proxy" below.
|
|
105
|
+
|
|
93
106
|
- `--query` — search term. Against the store's catalog when you name a store,
|
|
94
107
|
against the search backends when you don't.
|
|
95
108
|
- `--qty` — quantity, default `1`.
|
|
@@ -102,7 +115,10 @@ portage policy set [--per-transaction-cap N --currency CUR]
|
|
|
102
115
|
search ranks first. If the id isn't in the results, nothing is bought.
|
|
103
116
|
- `--store` — name the merchant without giving a full URL; skips the search.
|
|
104
117
|
- `--max-price` — in major units (`400` means 400), compared per offer in that
|
|
105
|
-
offer's own currency. No FX conversion.
|
|
118
|
+
offer's own currency. No FX conversion. Applies per unit, to the search and
|
|
119
|
+
to the store's own catalog once one is settled (a URL, `--store`, or a
|
|
120
|
+
picked offer): nothing priced above it is checked out, even with
|
|
121
|
+
`--product-id`. A product with no price is still eligible.
|
|
106
122
|
- `--limit` — how many candidate stores to probe, capped at 12.
|
|
107
123
|
- `--yes` — skip the confirmation prompt before completing checkout.
|
|
108
124
|
- `--dry-run` — resolve and price the order without completing checkout.
|
|
@@ -270,6 +286,135 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
|
|
|
270
286
|
limits bound to one enrolled card) are set via `portage payment enroll
|
|
271
287
|
--scope-*` above, not here.
|
|
272
288
|
|
|
289
|
+
### Orders reconcile
|
|
290
|
+
|
|
291
|
+
Nearly every real checkout `portage buy` can't finish itself hands the
|
|
292
|
+
shopper a link — `requires_escalation`, `permission_denied`,
|
|
293
|
+
`no_payment_token`, `policy_blocked`, `low_confidence`, or
|
|
294
|
+
`checkout_mismatch`. That's a pending purchase this process knows nothing
|
|
295
|
+
more about until something asks the store. `portage orders reconcile`
|
|
296
|
+
re-fetches each pending hand-off from the store and settles it once the
|
|
297
|
+
store itself reports a terminal status:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
portage orders reconcile # every pending hand-off
|
|
301
|
+
portage orders reconcile --checkout chk_123
|
|
302
|
+
portage orders reconcile --json
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
A `completed` checkout settles `complete` — using the store's own total at
|
|
306
|
+
settle time, not the hand-off-time snapshot — records the order (when one
|
|
307
|
+
exists) and a journal entry, and is counted toward your spend policy's
|
|
308
|
+
rolling cap/velocity per `handoff_spend_mode` below. A `canceled` checkout,
|
|
309
|
+
or one that expires with no answer, settles `failed`. Anything still
|
|
310
|
+
in-progress, or not currently reachable, is left pending for the next run —
|
|
311
|
+
safe to put on a cron/launchd schedule; two overlapping runs never
|
|
312
|
+
double-settle the same record.
|
|
313
|
+
|
|
314
|
+
`handoff_spend_mode` (`PORTAGE_HANDOFF_SPEND_MODE`, config.json's
|
|
315
|
+
`handoff_spend_mode`) controls whether a reconciled shopper purchase counts
|
|
316
|
+
toward the caps `portage policy set` configures:
|
|
317
|
+
|
|
318
|
+
- `block` (default) — counts like any agent-completed purchase.
|
|
319
|
+
- `warn` — recorded, but excluded from the cap/velocity math; still notifies
|
|
320
|
+
when it would have pushed spend over the cap.
|
|
321
|
+
- `precheck` — `block`, plus a spend-cap check at hand-off time
|
|
322
|
+
(`portage buy`, not reconcile): if this checkout's total would already
|
|
323
|
+
exceed your cap, the URL is still printed but auto-open is suppressed.
|
|
324
|
+
|
|
325
|
+
`--wait [--wait-timeout DURATION|off]` on `portage buy` polls the same
|
|
326
|
+
reconciler right after a hand-off, instead of waiting for a separate
|
|
327
|
+
`orders reconcile` run. It backs off (2s → 30s, plus jitter) until the
|
|
328
|
+
checkout settles or its deadline passes — the earlier of
|
|
329
|
+
`handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json; default
|
|
330
|
+
`30m`, `off` removes it) and the checkout's own `expires_at`. Ctrl-C, or
|
|
331
|
+
the deadline, leaves the record pending for a later `portage orders
|
|
332
|
+
reconcile` — it never settles from the wait itself. Under `--wait --json`,
|
|
333
|
+
stdout streams NDJSON — `handoff`, `handoff_status` on each store-reported
|
|
334
|
+
status change, `handoff_settled` — followed by the final report object;
|
|
335
|
+
plain `--json` without `--wait` is unchanged.
|
|
336
|
+
|
|
337
|
+
`reconcile_notify` (`PORTAGE_RECONCILE_NOTIFY`, config.json's
|
|
338
|
+
`reconcile_notify`) is a comma list of channels a settled hand-off notifies
|
|
339
|
+
on, from `--wait` or `orders reconcile`: `webhook` (default), `macos` (a
|
|
340
|
+
native notification), `terminal` (a printed line — forced on for a
|
|
341
|
+
plain-text `--wait` regardless of configuration), and `journal` (the order
|
|
342
|
+
snapshot journal write, already unconditional — listing it just documents
|
|
343
|
+
that).
|
|
344
|
+
|
|
345
|
+
### Proxy
|
|
346
|
+
|
|
347
|
+
`buy`, `find`, `compare`, `doctor`, and `payment enroll` accept the flags below,
|
|
348
|
+
resolved (per field, flag > env > `~/.portage/config.json`) into a
|
|
349
|
+
`Portage::Ucp::Support::ProxyConfig` by `Portage::Cli::ProxySettings` — see
|
|
350
|
+
[`docs/proxy.md`](../docs/proxy.md) for corporate egress, a rotating residential
|
|
351
|
+
pool, an API gateway, mitmproxy for debugging, and nginx/Cloudflare in front of the
|
|
352
|
+
MCP/WebMCP endpoints, worked through end to end.
|
|
353
|
+
|
|
354
|
+
| Flag | Meaning |
|
|
355
|
+
| --- | --- |
|
|
356
|
+
| `--proxy URL` | The default proxy's URL (`http://user:pass@host:port`). Overrides only `proxy.default.url`; every other configured field (`no_proxy`, `routes`, `chains`, ...) stays as set in config.json. |
|
|
357
|
+
| `--proxy-mode forward\|gateway` | The default profile's mode. `forward` (the default) is a standard HTTP proxy; `gateway` is a URL-rewriting gateway — see `docs/proxy.md`. |
|
|
358
|
+
| `--proxy-header "Name: value"` | Repeatable. Sent only to the proxy (on the `CONNECT` request, or the gateway request) — never to the real target. `Authorization`/`User-Agent`/`X-Shopify-*-Access-Token`/`X-Payment-Token` are refused here, always. |
|
|
359
|
+
| `--no-proxy HOSTS` | Comma-separated hostnames/suffixes/CIDRs (or a bare `*`) to always bypass the proxy for, whatever the route resolves to. |
|
|
360
|
+
| `--proxy-route ROUTE=URL\|direct` | Repeatable. Points one fixed route (`store`, `search`, `notify`, `payment`, `platform`, `probe`) at its own URL, or forces it `direct` regardless of `default`. |
|
|
361
|
+
| `--proxy-chain URL,URL,...` | An ad-hoc multi-hop chain for this run — each hop is `forward` unless prefixed `gateway+https://...`. Overrides the whole `default` profile (not just its `url`), since a chain has no single "url" field to merge with config.json's own. |
|
|
362
|
+
| `--proxy-passthrough HEADER` | Repeatable, server commands only. Allowlists an inbound request header to ride along on outbound calls made while serving it — see `docs/proxy.md`'s passthrough section. Refuses a protected header name at parse time. |
|
|
363
|
+
| `--proxy-ca FILE` | A PEM file trusted in addition to the system store — for a TLS-intercepting corporate proxy or a debugging proxy like mitmproxy. |
|
|
364
|
+
| `--no-env-proxy` | Ignore `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` (and their lowercase forms) entirely for this run — otherwise they're still the fallback for any route nothing else configured. |
|
|
365
|
+
|
|
366
|
+
**Env vars** (checked when the matching flag is absent, before config.json):
|
|
367
|
+
|
|
368
|
+
| Var | Matches |
|
|
369
|
+
| --- | --- |
|
|
370
|
+
| `PORTAGE_PROXY` | `--proxy` |
|
|
371
|
+
| `PORTAGE_PROXY_MODE` | `--proxy-mode` |
|
|
372
|
+
| `PORTAGE_NO_PROXY` | `--no-proxy` |
|
|
373
|
+
| `PORTAGE_PROXY_CA` | `--proxy-ca` |
|
|
374
|
+
| `PORTAGE_PROXY_HEADERS` | A JSON object of header name → value, merged under any `--proxy-header` flags (flags win on a name collision). |
|
|
375
|
+
|
|
376
|
+
Below all of the above, the standard `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` (and
|
|
377
|
+
lowercase) variables are still the fallback for any route nothing here configures
|
|
378
|
+
at all — existing environments keep working unless `--no-env-proxy` is set. The
|
|
379
|
+
**`payment` route is always forced `direct`** unless a proxy is named for it
|
|
380
|
+
explicitly (`--proxy-route payment=...` or config.json's `routes.payment`) — it
|
|
381
|
+
never inherits a bare `default`/env proxy the way every other route does, so an
|
|
382
|
+
egress proxy nobody meant to hand payment tokens to never sees them by accident.
|
|
383
|
+
|
|
384
|
+
`${ENV_VAR}` inside a config.json header value (`proxy_headers`, `forward_headers.add`)
|
|
385
|
+
is expanded from the process environment at resolve time, so a secret can live in
|
|
386
|
+
the environment rather than the file itself. A proxy password can also live in
|
|
387
|
+
`password_ref` (resolved through the same macOS Keychain/Linux Secret Service tiers
|
|
388
|
+
`portage payment` uses, under its own `portage-cli-proxy` service name) instead of
|
|
389
|
+
plaintext in the URL — `portage doctor` warns when it finds a plaintext one anyway.
|
|
390
|
+
|
|
391
|
+
`portage doctor` reports, per route, what's effectively configured (credentials
|
|
392
|
+
redacted), whether each configured proxy/gateway is actually reachable, and flags
|
|
393
|
+
plaintext proxy credentials and an intercepting proxy (`ca_file` or `gateway` mode)
|
|
394
|
+
sitting on the `payment` route.
|
|
395
|
+
|
|
396
|
+
### WebMCP (library use, opt-in)
|
|
397
|
+
|
|
398
|
+
`portage buy` from the shell has no browser of its own, so there's no CLI
|
|
399
|
+
flag for this — it's for a caller embedding `Portage::Cli::Buy` directly
|
|
400
|
+
alongside its own browser automation:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
Portage::Cli::Buy.new(url: "shop.example", query: "mug",
|
|
404
|
+
webmcp_bridge: my_portage_ucp_webmcp_bridge).call
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Given a `portage-ucp-webmcp` outbound bridge already pointed at a navigated
|
|
408
|
+
page, `Buy` tries it after native-UCP discovery finds nothing at that URL
|
|
409
|
+
and before falling back to a platform adapter. `webmcp_checkout_mode`
|
|
410
|
+
(`PORTAGE_WEBMCP_CHECKOUT_MODE`, config.json's `webmcp_checkout_mode`)
|
|
411
|
+
controls how it finishes: `express_stop` (default) builds the cart/checkout
|
|
412
|
+
and always hands off — reason `express_stop`, so `--wait`/`orders
|
|
413
|
+
reconcile`/`handoff_spend_mode` all apply exactly as they do to any other
|
|
414
|
+
hand-off. `token` isn't implemented yet; it reports
|
|
415
|
+
`webmcp_token_unsupported` rather than attempting completion. Requires
|
|
416
|
+
`gem install portage-ucp-webmcp` — not a hard dependency of `portage-cli`.
|
|
417
|
+
|
|
273
418
|
### Decisions
|
|
274
419
|
|
|
275
420
|
`portage buy` and `portage find` make their judgment calls
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -10,6 +10,13 @@ require_relative "decisions"
|
|
|
10
10
|
require_relative "confidence_check"
|
|
11
11
|
require_relative "checkout_handoff"
|
|
12
12
|
require_relative "notifier"
|
|
13
|
+
require_relative "user_agent"
|
|
14
|
+
require_relative "homepage_fetch"
|
|
15
|
+
require_relative "permissive_authenticator"
|
|
16
|
+
require_relative "handoff_reconciler"
|
|
17
|
+
require_relative "handoff_spend_mode"
|
|
18
|
+
require_relative "webmcp"
|
|
19
|
+
require_relative "webmcp_checkout_mode"
|
|
13
20
|
|
|
14
21
|
module Portage
|
|
15
22
|
module Cli
|
|
@@ -49,13 +56,26 @@ module Portage
|
|
|
49
56
|
# rolling cap and velocity limit count. nil (the default) is the
|
|
50
57
|
# real ~/.portage/transactions.json, the file Dispatcher writes for
|
|
51
58
|
# own-store purchases.
|
|
52
|
-
#
|
|
59
|
+
# @param max_price [Integer, nil] the most one unit may cost, in minor
|
|
60
|
+
# units (same as Find's). A product priced above it is never picked,
|
|
61
|
+
# even with a --product-id; one with no price to go on still is, as
|
|
62
|
+
# in Find, and the checkout's own total then meets the spend policy.
|
|
63
|
+
# @param webmcp_bridge [#list_tools, #execute_tool, nil] docs/plans/
|
|
64
|
+
# handoff-reconcile.md Phase 4 — a `portage-ucp-webmcp` outbound
|
|
65
|
+
# Bridge (already pointed at a navigated page) an embedding caller
|
|
66
|
+
# already holds. nil (the default, and the only option from the
|
|
67
|
+
# `portage buy` CLI, which has no browser of its own) skips WebMCP
|
|
68
|
+
# entirely — zero behavior change from before this parameter
|
|
69
|
+
# existed. Given one, attempted after native-UCP discovery finds
|
|
70
|
+
# nothing at this URL and before a platform-adapter fallback (see
|
|
71
|
+
# #webmcp_flow).
|
|
72
|
+
# rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus injectable collaborators
|
|
53
73
|
def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
|
|
54
|
-
auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil
|
|
74
|
+
auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil,
|
|
75
|
+
max_price: nil, webmcp_bridge: nil)
|
|
55
76
|
# rubocop:enable Metrics/ParameterLists
|
|
56
77
|
raw = url.to_s.strip
|
|
57
|
-
|
|
58
|
-
@uri = URI.parse(raw)
|
|
78
|
+
@uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
|
|
59
79
|
@query = query
|
|
60
80
|
@qty = qty
|
|
61
81
|
@payment_token = payment_token
|
|
@@ -66,6 +86,8 @@ module Portage
|
|
|
66
86
|
@notify_webhook = notify_webhook
|
|
67
87
|
@confidence_check = confidence_check
|
|
68
88
|
@transaction_log = transaction_log
|
|
89
|
+
@max_price = max_price
|
|
90
|
+
@webmcp_bridge = webmcp_bridge
|
|
69
91
|
@decisions = {}
|
|
70
92
|
end
|
|
71
93
|
|
|
@@ -84,6 +106,9 @@ module Portage
|
|
|
84
106
|
return native_flow(session) if session
|
|
85
107
|
end
|
|
86
108
|
|
|
109
|
+
webmcp_result = webmcp_flow
|
|
110
|
+
return webmcp_result if webmcp_result
|
|
111
|
+
|
|
87
112
|
platform = Portage::Ucp::Resolver.detect_platform(body, headers)
|
|
88
113
|
adapter_flow(platform) || dead_end
|
|
89
114
|
end
|
|
@@ -93,7 +118,7 @@ module Portage
|
|
|
93
118
|
# --- Step 1: native UCP manifest ---
|
|
94
119
|
|
|
95
120
|
def discover(url)
|
|
96
|
-
Portage::Ucp::Client.discover(url.to_s)
|
|
121
|
+
Portage::Ucp::Client.discover(url.to_s, headers: UserAgent.headers)
|
|
97
122
|
rescue Portage::Ucp::Client::ManifestShapeError => e
|
|
98
123
|
# The store *is* running UCP — this client just couldn't parse its
|
|
99
124
|
# manifest. Distinct from a genuine 404/unreachable host below:
|
|
@@ -200,6 +225,64 @@ module Portage
|
|
|
200
225
|
match && URI.join(@uri, match[1])
|
|
201
226
|
end
|
|
202
227
|
|
|
228
|
+
# --- Step 1b: WebMCP outbound (docs/plans/handoff-reconcile.md Phase 4) ---
|
|
229
|
+
#
|
|
230
|
+
# Opt-in only: nil unless a caller passed `webmcp_bridge:` (see
|
|
231
|
+
# #initialize), so `portage buy` from the shell — with no browser of
|
|
232
|
+
# its own — sees no behavior change here at all. Attempted after
|
|
233
|
+
# native-UCP discovery finds nothing at this URL and before falling
|
|
234
|
+
# back to a platform adapter, since a WebMCP page, like native UCP,
|
|
235
|
+
# works without this process holding the store's own credentials.
|
|
236
|
+
#
|
|
237
|
+
# `express_stop` (the only implemented mode) builds the cart and
|
|
238
|
+
# checkout, then always hands off rather than attempting
|
|
239
|
+
# #complete_checkout — which a WebMCP page doesn't expose by default
|
|
240
|
+
# in the first place (ToolCatalog leaves it out: it needs a
|
|
241
|
+
# server-side Confirmer swap this gem doesn't have a seam for yet).
|
|
242
|
+
# That hand-off feeds Phases 1-3 completely unchanged: reason
|
|
243
|
+
# `express_stop` reserves a pending record, notifies, and later
|
|
244
|
+
# reconciles exactly like any other hand-off.
|
|
245
|
+
def webmcp_flow
|
|
246
|
+
return nil unless @webmcp_bridge
|
|
247
|
+
return webmcp_not_installed_report unless Portage::Cli::Webmcp.available?
|
|
248
|
+
|
|
249
|
+
session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge)
|
|
250
|
+
return nil unless session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
|
|
251
|
+
|
|
252
|
+
return webmcp_token_unsupported_report if webmcp_checkout_mode == "token"
|
|
253
|
+
|
|
254
|
+
full_buy(session, source: "webmcp", force_handoff: true)
|
|
255
|
+
rescue Portage::Ucp::WebMcp::BridgeError, Portage::Ucp::WebMcp::ToolNotFoundError,
|
|
256
|
+
Portage::Ucp::Client::ServerError => e
|
|
257
|
+
build_report(source: "webmcp", outcome: "webmcp_error", browse: false, checkout: false,
|
|
258
|
+
message: "WebMCP checkout failed: #{e.message}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def webmcp_checkout_mode
|
|
262
|
+
Portage::Cli::WebmcpCheckoutMode.resolve
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def webmcp_not_installed_report
|
|
266
|
+
build_report(source: "webmcp", outcome: "webmcp_not_installed", browse: false, checkout: false,
|
|
267
|
+
message: "A WebMCP bridge was given but portage-ucp-webmcp isn't installed — " \
|
|
268
|
+
"`gem install portage-ucp-webmcp`.")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# `token` mode isn't implemented: it needs `Mcp::Server.build` to swap
|
|
272
|
+
# in a payment-token Confirmer for a WebMCP session (see
|
|
273
|
+
# portage-ucp-webmcp's README, "complete_checkout"), plus PayPal/Stripe
|
|
274
|
+
# agent-token enrollment in `portage payment enroll` — neither exists
|
|
275
|
+
# yet (docs/plans/handoff-reconcile.md Phase 4 is explicitly a sketch
|
|
276
|
+
# pending that design). Fails loudly here rather than silently
|
|
277
|
+
# behaving like `express_stop`, so a caller who configured `token`
|
|
278
|
+
# notices instead of getting a hand-off they didn't ask for.
|
|
279
|
+
def webmcp_token_unsupported_report
|
|
280
|
+
build_report(source: "webmcp", outcome: "webmcp_token_unsupported", browse: false, checkout: false,
|
|
281
|
+
message: "webmcp_checkout_mode=token isn't supported yet — see " \
|
|
282
|
+
"docs/plans/handoff-reconcile.md Phase 4. Use express_stop (the default), or " \
|
|
283
|
+
"complete the checkout via the page's own payment button.")
|
|
284
|
+
end
|
|
285
|
+
|
|
203
286
|
# --- Step 2/3: platform detection + adapter fallback ---
|
|
204
287
|
|
|
205
288
|
def adapter_flow(platform)
|
|
@@ -296,7 +379,11 @@ module Portage
|
|
|
296
379
|
# remote store, so shipping-address/rate selection stays loopback-only
|
|
297
380
|
# for now rather than guessing at a wire shape no real UCP server has
|
|
298
381
|
# confirmed (see docs/design-log.md).
|
|
299
|
-
|
|
382
|
+
# @param force_handoff [Boolean] docs/plans/handoff-reconcile.md Phase
|
|
383
|
+
# 4's `express_stop` — skips escalation/confirmation/completion
|
|
384
|
+
# entirely and always hands the built checkout off, once past the
|
|
385
|
+
# `--dry-run` check. Only #webmcp_flow ever sets this.
|
|
386
|
+
def full_buy(session, source:, fulfillment_adapter: nil, force_handoff: false)
|
|
300
387
|
products = safe_search(session)
|
|
301
388
|
product = select_product(products)
|
|
302
389
|
unless product
|
|
@@ -310,7 +397,7 @@ module Portage
|
|
|
310
397
|
checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
|
|
311
398
|
|
|
312
399
|
warnings = reconcile_checkout(product, checkout)
|
|
313
|
-
finish_checkout(session, source, products, checkout, warnings)
|
|
400
|
+
finish_checkout(session, source, products, checkout, warnings, force_handoff: force_handoff)
|
|
314
401
|
end
|
|
315
402
|
|
|
316
403
|
# A store can silently drop the requested line, change its quantity, or
|
|
@@ -406,9 +493,10 @@ module Portage
|
|
|
406
493
|
end
|
|
407
494
|
|
|
408
495
|
def no_match_message
|
|
409
|
-
|
|
496
|
+
budget = @max_price ? " at or under #{@max_price} minor units" : ""
|
|
497
|
+
return "No product matched \"#{@query}\"#{budget}." unless @product_id
|
|
410
498
|
|
|
411
|
-
"Product #{@product_id} isn't in this store's results for \"#{@query}\"."
|
|
499
|
+
"Product #{@product_id} isn't in this store's results for \"#{@query}\"#{budget}."
|
|
412
500
|
end
|
|
413
501
|
|
|
414
502
|
# With a --product-id, that exact product or nothing: falling back to the
|
|
@@ -420,12 +508,28 @@ module Portage
|
|
|
420
508
|
# when an in-stock match sits right below it (confirmed live
|
|
421
509
|
# 2026-09-23 on allbirds.com and billabong.com). Falls back to the top
|
|
422
510
|
# hit when nothing reports stock, so the store still gets to answer.
|
|
511
|
+
#
|
|
512
|
+
# Either way, only among products within --max-price: before it
|
|
513
|
+
# reached here, `buy <url> --max-price` checked out whatever the store
|
|
514
|
+
# ranked first (confirmed live 2026-09-24: a $679.95 board on
|
|
515
|
+
# burton.com under --max-price 600).
|
|
423
516
|
def select_product(products)
|
|
517
|
+
products = products.select { |product| within_max_price?(product) }
|
|
424
518
|
return products.find { |product| available?(product) } || products.first unless @product_id
|
|
425
519
|
|
|
426
520
|
products.find { |product| product_id_of(product) == @product_id }
|
|
427
521
|
end
|
|
428
522
|
|
|
523
|
+
# Priced the way #reconcile_checkout expects the store to charge: the
|
|
524
|
+
# variant #line_item_id_of would check out, else the product's lowest
|
|
525
|
+
# price.
|
|
526
|
+
def within_max_price?(product)
|
|
527
|
+
return true unless @max_price
|
|
528
|
+
|
|
529
|
+
price = expected_unit_price(product, line_item_id_of(product))
|
|
530
|
+
price.nil? || price <= @max_price
|
|
531
|
+
end
|
|
532
|
+
|
|
429
533
|
# A product with no variant availability to go on counts as buyable —
|
|
430
534
|
# only an explicit `available: false` on every variant rules it out.
|
|
431
535
|
def available?(product)
|
|
@@ -462,10 +566,11 @@ module Portage
|
|
|
462
566
|
product_id_of(product)
|
|
463
567
|
end
|
|
464
568
|
|
|
465
|
-
def finish_checkout(session, source, products, checkout, warnings = [])
|
|
569
|
+
def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
|
|
466
570
|
escalation = decide_escalation(checkout, warnings)
|
|
467
571
|
return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
|
|
468
572
|
return dry_run_report(source, products, checkout, warnings) if @dry_run
|
|
573
|
+
return webmcp_handoff_report(source, products, checkout, warnings) if force_handoff
|
|
469
574
|
return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
|
|
470
575
|
|
|
471
576
|
complete(session, source, products, checkout, warnings)
|
|
@@ -670,10 +775,23 @@ module Portage
|
|
|
670
775
|
# relay and an agent loop branch on the same value.
|
|
671
776
|
def handoff_report(source, products, checkout, warnings, outcome:, message:)
|
|
672
777
|
handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
|
|
673
|
-
checkout_report(source, products, checkout, outcome: outcome,
|
|
778
|
+
checkout_report(source, products, checkout, outcome: outcome, message: message,
|
|
779
|
+
warnings: warnings + Array(@pending_handoff_warning),
|
|
674
780
|
checkout_url: checkout_url_of(checkout), handoff: handoff)
|
|
675
781
|
end
|
|
676
782
|
|
|
783
|
+
# docs/plans/handoff-reconcile.md Phase 4 — #webmcp_flow's
|
|
784
|
+
# `express_stop` mode. Reuses #handoff_report as-is: reason
|
|
785
|
+
# `express_stop` reserves a pending shopper record and fires
|
|
786
|
+
# auto-open/notify exactly like any other hand-off (Phases 1-3 apply
|
|
787
|
+
# unchanged), even though it got here because a mode setting chose to
|
|
788
|
+
# stop, not because anything was denied or escalated.
|
|
789
|
+
def webmcp_handoff_report(source, products, checkout, warnings)
|
|
790
|
+
message = "Cart and checkout are built — finish payment with the store's own express-pay button " \
|
|
791
|
+
"on the page."
|
|
792
|
+
handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message)
|
|
793
|
+
end
|
|
794
|
+
|
|
677
795
|
# Never fires on --dry-run (a dry run creates a real checkout but never
|
|
678
796
|
# attempts completion — auto-opening/notifying over a preview run would
|
|
679
797
|
# be actively wrong), and never fires without a checkout_url to hand
|
|
@@ -685,15 +803,67 @@ module Portage
|
|
|
685
803
|
# The webhook body carries the report's own `message`, plus the store
|
|
686
804
|
# and the shopper's query, so a Slack/Zapier relay can post it as-is
|
|
687
805
|
# without a lookup back into this process.
|
|
806
|
+
#
|
|
807
|
+
# Also where docs/plans/handoff-reconcile.md Phase 1 records a pending
|
|
808
|
+
# `settled_by: "shopper"` TransactionLog row (best-effort — a failed
|
|
809
|
+
# write surfaces as a warning, never blocks the hand-off itself), and
|
|
810
|
+
# where Phase 2's `precheck` spend mode suppresses auto-open when this
|
|
811
|
+
# checkout's total would already exceed the buyer's own spend cap.
|
|
688
812
|
def hand_off(checkout, reason:, source:, message:, warnings:)
|
|
689
813
|
url = checkout_url_of(checkout)
|
|
690
814
|
return nil if @dry_run || url.nil?
|
|
691
815
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
816
|
+
over_cap = precheck_mode? && over_spend_cap?(checkout)
|
|
817
|
+
record_pending_handoff(checkout, reason: reason)
|
|
818
|
+
|
|
819
|
+
opened = over_cap ? false : CheckoutHandoff.new(auto_open: @auto_open).call(url)
|
|
820
|
+
error = notifier.call(handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
|
|
821
|
+
warnings: warnings, over_cap: over_cap))
|
|
822
|
+
result = { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
|
|
823
|
+
over_cap ? result.merge(over_cap: true) : result
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def handoff_notify_payload(checkout, url, reason:, source:, message:, warnings:, over_cap:)
|
|
827
|
+
payload = { event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
|
|
828
|
+
query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
|
|
829
|
+
totals: checkout["totals"], warnings: warnings }
|
|
830
|
+
over_cap ? payload.merge(over_cap: true) : payload
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# Never raises: a record that can't be written is a warning on the
|
|
834
|
+
# report (surfaced via @pending_handoff_warning, see #handoff_report),
|
|
835
|
+
# not a reason to fail the hand-off itself — same posture as
|
|
836
|
+
# #settle_transaction's own @unrecorded_warning.
|
|
837
|
+
def record_pending_handoff(checkout, reason:)
|
|
838
|
+
transaction_log.reserve(idempotency_key: handoff_transaction_key(checkout), checkout_id: checkout["id"],
|
|
839
|
+
shop: @uri.host, payment_token_ref: token_ref, amount: checkout_total(checkout),
|
|
840
|
+
currency: checkout["currency"], settled_by: "shopper", handoff_reason: reason,
|
|
841
|
+
store_url: @uri.to_s, expires_at: checkout["expires_at"])
|
|
842
|
+
rescue StandardError => e
|
|
843
|
+
@pending_handoff_warning = "Couldn't save this hand-off for later reconcile (#{e.message}) — " \
|
|
844
|
+
"run `portage orders reconcile --checkout #{checkout['id']}` once fixed, " \
|
|
845
|
+
"or it'll never be picked up automatically."
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def handoff_transaction_key(checkout)
|
|
849
|
+
"portage-buy:#{@uri.host}:#{checkout['id']}"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def precheck_mode?
|
|
853
|
+
Portage::Cli::HandoffSpendMode.resolve == "precheck"
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Same PolicyGuard the buyer's own spend cap already goes through
|
|
857
|
+
# (#decide_policy) — reused here so a hand-off that never reached
|
|
858
|
+
# #decide_policy at all (escalation, permission_denied, no_payment_token,
|
|
859
|
+
# checkout_mismatch) still gets the buyer a heads-up that finishing
|
|
860
|
+
# this checkout by hand would blow the cap, before they do.
|
|
861
|
+
def over_spend_cap?(checkout)
|
|
862
|
+
Portage::Ucp::PolicyGuard.check!(amount: checkout_total(checkout), currency: checkout["currency"],
|
|
863
|
+
merchant: @uri.host, token_ref: token_ref, transaction_log: transaction_log)
|
|
864
|
+
false
|
|
865
|
+
rescue Portage::Ucp::PolicyViolationError
|
|
866
|
+
true
|
|
697
867
|
end
|
|
698
868
|
|
|
699
869
|
def notifier
|
|
@@ -796,23 +966,7 @@ module Portage
|
|
|
796
966
|
# catalog-only-native adapter-checkout-fallback path) ---
|
|
797
967
|
|
|
798
968
|
def fetch_homepage(uri, limit = REDIRECT_LIMIT)
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
802
|
-
open_timeout: 5, read_timeout: 5) do |http|
|
|
803
|
-
http.get(uri.request_uri, { "User-Agent" => "portage-buy" })
|
|
804
|
-
end
|
|
805
|
-
|
|
806
|
-
case response
|
|
807
|
-
when Net::HTTPRedirection
|
|
808
|
-
fetch_homepage(URI.join(uri, response["location"]), limit - 1)
|
|
809
|
-
when Net::HTTPSuccess
|
|
810
|
-
[response.body, response.to_hash]
|
|
811
|
-
else
|
|
812
|
-
[nil, {}]
|
|
813
|
-
end
|
|
814
|
-
rescue StandardError
|
|
815
|
-
[nil, {}]
|
|
969
|
+
HomepageFetch.call(uri, limit: limit)
|
|
816
970
|
end
|
|
817
971
|
|
|
818
972
|
def dead_end
|
|
@@ -823,14 +977,6 @@ module Portage
|
|
|
823
977
|
def build_report(**fields)
|
|
824
978
|
{ url: @uri.to_s, checkout_url: nil, products: [], warnings: [] }.merge(fields)
|
|
825
979
|
end
|
|
826
|
-
|
|
827
|
-
# Loopback buy against your own store needs *some* authenticator (§9
|
|
828
|
-
# rejects anonymous mutation by default) — since this process already
|
|
829
|
-
# has this platform's own credentials (that's the gate to even reach
|
|
830
|
-
# here), authenticating this local CLI session is reasonable.
|
|
831
|
-
class PermissiveAuthenticator < Portage::Ucp::Authenticator
|
|
832
|
-
def call(_server_context) = :local_cli
|
|
833
|
-
end
|
|
834
980
|
end
|
|
835
981
|
end
|
|
836
982
|
end
|