portage-cli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/README.md +88 -18
- data/lib/portage/cli/adapter_session.rb +34 -0
- data/lib/portage/cli/browser_import/importer.rb +1 -1
- data/lib/portage/cli/browser_import/plist_xml.rb +5 -12
- data/lib/portage/cli/browser_import/sqlite.rb +19 -25
- data/lib/portage/cli/browser_profile/cdp.rb +0 -9
- data/lib/portage/cli/buy.rb +217 -39
- data/lib/portage/cli/compare.rb +4 -3
- data/lib/portage/cli/confidence_check.rb +27 -7
- data/lib/portage/cli/confidence_state.rb +109 -0
- data/lib/portage/cli/find.rb +66 -9
- data/lib/portage/cli/generate/agent_profile.rb +1 -1
- data/lib/portage/cli/handoff_agents.rb +7 -21
- data/lib/portage/cli/handoff_reconciler.rb +2 -22
- data/lib/portage/cli/homepage_fetch.rb +5 -3
- data/lib/portage/cli/index/builder.rb +2 -11
- data/lib/portage/cli/index/known_cache.rb +9 -2
- data/lib/portage/cli/index/sources/stores_file.rb +2 -11
- data/lib/portage/cli/index/sources/wikidata.rb +2 -11
- data/lib/portage/cli/install_doctor.rb +2 -1
- data/lib/portage/cli/notifier.rb +16 -12
- data/lib/portage/cli/offer_sources.rb +3 -14
- data/lib/portage/cli/payment_methods.rb +7 -30
- data/lib/portage/cli/reconcile_notifier.rb +2 -5
- data/lib/portage/cli/setup_wizard/steps/env_keys_step.rb +40 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +11 -23
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +10 -22
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +5 -19
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +25 -173
- metadata +5 -3
- data/lib/portage/cli/known_stores_url.rb +0 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c58c0040e6d1f58b5cf9dbbe64c91273c198a4cfc8119477e1fcbbf04c4c4dd
|
|
4
|
+
data.tar.gz: ba48da2d241487885a286eb8044e493f6727b7e7d4b96f3b9dc7d748b1644f67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 388d5ea84ba8ba1279862b57b07cf36db2164cec50752638ae3eb2d924fd81c56a569ea2d107994fc56f3d1989ab0d5b567fed3e0a12e9b3e7c65a291b95e198
|
|
7
|
+
data.tar.gz: fd18e9aa2c798935143742e961936c359ff68722a3f8bad2f20489322456df3467f042ef9c653a4f72f167fc4a765a08bd81680e01ec6c2a0b26fb0ce934b477
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,103 @@ pre-1.0, so APIs may still shift between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.13.0] - 2026-10-05
|
|
10
|
+
|
|
11
|
+
- **Fix: `payment_methods` adapter fallback no longer silently fails.** `PaymentMethods#adapter_session` referenced `Buy::PermissiveAuthenticator`, which doesn't exist (the class is `Portage::Cli::PermissiveAuthenticator`); the resulting `NameError` was swallowed by the method's `rescue StandardError`, so enrolling against your own adapter-backed store always reported no session. It now requires and uses the right constant. Patch-level bug fix.
|
|
12
|
+
|
|
13
|
+
- Documentation only, no code change. Comments and changelog entries that cited `docs/plans/homebrew-distribution.md`, which is retired, now cite a permalink at commit d51200f. Patch-level.
|
|
14
|
+
|
|
15
|
+
- **Removed `Portage::Cli::BrowserProfile::Cdp.close_tab`.** Nothing in the CLI called it. It is a public class method, so this is **breaking** for any caller using it directly; minor-level for a pre-1.0 gem.
|
|
16
|
+
|
|
17
|
+
- Internal: the Shipping, SearchKeys and RetailerKeys setup-wizard steps now share one `SetupWizard::Steps::EnvKeysStep` base class instead of three copies of the same prompt-and-save body. No behaviour change.
|
|
18
|
+
|
|
19
|
+
- **`portage browser import` no longer needs the `sqlite3` command-line tool.** It reads the copied Chromium, Firefox and Safari history databases through the `sqlite3` gem, which `portage-cli` already depends on for the local index, instead of shelling out to the system `sqlite3` CLI. The copy-to-tmpdir step (with the `-wal` file) and the UTF-8 scrub are unchanged. `error: "reader_unavailable"` now means a SQLite error reading the copy (for example a corrupt or non-SQLite file) or, for Safari bookmarks, a missing `plutil`; `docs/api/cli-json.md` and the `buy` skill's `references/outcomes.md` say so. `BrowserImport::Sqlite.new` no longer takes a `command:` keyword (it only existed for a spec), which is **breaking** for any caller passing it; minor-level for a pre-1.0 gem.
|
|
20
|
+
|
|
21
|
+
- **Fix: `payment_methods` enroll now finds your own store when its homepage redirects** (for example apex to `www`). The adapter fallback's homepage fetch followed no redirects; it now follows up to five, like `buy`'s, and every hop stays on the `payment` proxy route (forced direct unless a proxy is named for it). Internally, `PaymentMethods` and `HandoffReconciler` now share one `Cli::AdapterSession` for that fallback, and `HomepageFetch.call` takes a `route:` keyword (default `:store`). Patch-level.
|
|
22
|
+
|
|
23
|
+
- Internal: `HandoffAgents::Webhook` and `Notifier` now make their JSON POST through one `Notifier.post_json` class method instead of two copies. Timeouts, the agent webhook's https-only check and the error messages are unchanged, and the agent webhook still never falls back to `PORTAGE_NOTIFY_WEBHOOK_URL`. No behaviour change.
|
|
24
|
+
|
|
25
|
+
- Internal: `BrowserImport::PlistXml` decodes entities with `CGI.unescapeHTML` (from the default `cgi/escape` library) instead of its own table. The same for the five named entities and decimal and hex character references; an uppercase `A` now decodes (it used to become NUL) and an out-of-range code point is left as written (it used to raise `RangeError`). No gemspec change.
|
|
26
|
+
|
|
27
|
+
- Internal: the private `Cli.format_amount` wrapper is gone; its four callers in `cli.rb` call `Cli::Money.format_amount` directly. No behaviour change.
|
|
28
|
+
|
|
29
|
+
## [0.12.0] - 2026-10-01
|
|
30
|
+
|
|
31
|
+
- **Release note: the confidence check wants `portage-ucp-decision` 0.1.2.** `portage-ucp-decision` stays an optional install, not a dependency, but 0.1.2 is the version that rejects a malformed backend answer (see its changelog); the README says so.
|
|
32
|
+
|
|
33
|
+
- **`find --store URL --query Q [--max-price N]` searches one store, live.** The index-search hint, the `buy` skill and the docs already told agents to re-check an index hit this way, but `find` had no `--store`. It now skips the search backends and retailer offer sources and probes and searches only that store's catalogue (read-only: never a cart or checkout), with offers, `offer_ref`, `search_id` and the history entry shaped like any find, so `pick` and `buy --offer` work on them. A hand-off-only host is never fetched and is reported as such; a store without UCP is reported as having no catalogue; a non-http(s) URL is a usage error. The index-search hint now reads `find --store URL --query ...`.
|
|
34
|
+
|
|
35
|
+
- **Security: a priced line nobody asked for is a checkout mismatch.** `buy` requests exactly one
|
|
36
|
+
line, but only checked that line, so a store that added an upsell, a "shipping protection"
|
|
37
|
+
add-on or a second copy of the item (or, on a WebMCP cart, whatever was already in the store's
|
|
38
|
+
cart) still went through. Any other line now stops a real run with `checkout_mismatch` ("Store
|
|
39
|
+
added ... to checkout, which wasn't requested."), and a dry run flags it with
|
|
40
|
+
`checkout_mismatch: true`. An extra line that costs nothing (its own total, or its unit price
|
|
41
|
+
times quantity, is 0) is allowed, so a free gift or $0 sample doesn't block a purchase; one
|
|
42
|
+
whose cost can't be read counts as priced.
|
|
43
|
+
|
|
44
|
+
- **Security: the confidence check sends an allowlisted summary, and compares against the
|
|
45
|
+
approved quote.** The state sent to the decision backend (`PORTAGE_DECISION_BACKEND`, e.g.
|
|
46
|
+
`jev`, TypeSafe's hosted API) used to be a slice of the raw checkout hash, so whatever a store
|
|
47
|
+
nested under `line_items` or `totals` went along with it. It is now built field by field by the
|
|
48
|
+
new `Portage::Cli::ConfidenceState`: the request (query, store host, quantity, picked item id
|
|
49
|
+
and title), on a `buy --quote` run the approved quote (store, product id, title, quantity,
|
|
50
|
+
total, currency), and the checkout's status, currency, per-line item id, title, unit price,
|
|
51
|
+
quantity, totals and whether it's the requested line, the totals (shipping, tax, fees included),
|
|
52
|
+
applied discounts' titles and amounts and the selected shipping option's title and price, plus
|
|
53
|
+
`warnings`. Never the payment token, the address, the buyer's name, phone or email, ids, links,
|
|
54
|
+
discount codes or environment values; store strings are cut to 200 characters. The question
|
|
55
|
+
now asks the model to say yes only when the checkout matches the request and the approved
|
|
56
|
+
quote. `Buy.new` takes `quote_store:` and `quote_title:`, which `buy --quote` passes from the
|
|
57
|
+
saved quote. The check stays additive: it only sees a checkout the mismatch check, the quote
|
|
58
|
+
cap and the spend policy let through, and can hold it but never let through one they stop.
|
|
59
|
+
|
|
60
|
+
- **Security: the non-preset WebMCP hand-off runs the confidence check too.** A page whose WebMCP
|
|
61
|
+
tools build a checkout (no preset, so `buy` ends in `express_stop` through `finish_checkout`)
|
|
62
|
+
applied the quote cap and the mismatch stop but not the opt-in confidence check. With a decision
|
|
63
|
+
backend enabled it now runs before the hand-off, after those two. A hold reports `low_confidence`
|
|
64
|
+
with the store's `/cart` page as `checkout_url`, as the preset flow's does, and nothing is handed
|
|
65
|
+
to the checkout (a `profile` target is not navigated there). A backend error holds the same way.
|
|
66
|
+
No backend enabled: unchanged.
|
|
67
|
+
|
|
68
|
+
- **Security: the WebMCP hand-off flow runs the quote cap and the confidence check.** Against a
|
|
69
|
+
page whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy`
|
|
70
|
+
now checks, before that tool runs and before autofill: a `--quote` run's cap (`quote_changed`,
|
|
71
|
+
never handed off; this flow never checked the quote before), the mismatch check, then, when a
|
|
72
|
+
decision backend is enabled, the confidence check. A hold reports `low_confidence` with the
|
|
73
|
+
store's `/cart` page as `checkout_url`; checkout isn't opened and nothing is autofilled. A
|
|
74
|
+
backend error holds the same way.
|
|
75
|
+
|
|
76
|
+
- **Security: a quote with no total refuses instead of buying uncapped.** `buy --quote` capped the
|
|
77
|
+
checkout at the quote's total only when the quote had one. A quote saved from a dry run with no
|
|
78
|
+
priced total (a WebMCP preset dry run never has one) set no cap at all, so its `--yes` run
|
|
79
|
+
bought at whatever the store asked. It now ends in `quote_changed`, saying the quote has no
|
|
80
|
+
total, and nothing is bought or handed off.
|
|
81
|
+
|
|
82
|
+
- **Security: a checkout mismatch always stops the purchase.** `buy` used to stop on a checkout
|
|
83
|
+
that didn't match the request (the item dropped, another quantity, another unit price) only under
|
|
84
|
+
`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`. Without it the mismatch was a `warnings` entry and the
|
|
85
|
+
purchase went ahead, so a real `--yes` run could pay for the wrong checkout and report
|
|
86
|
+
`purchased` (reported by ClawHub's security audit of the `portage-buy` skill). Now every
|
|
87
|
+
mismatch on a run that isn't `--dry-run` escalates (`decisions.escalation.reason: "mismatch"`)
|
|
88
|
+
and ends in `checkout_mismatch` before the payment token, policy and completion are reached. The
|
|
89
|
+
quote it ran under is spent, so the person dry-runs again for a new one. There is no opt-out:
|
|
90
|
+
`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored, whatever its value. A
|
|
91
|
+
`--dry-run` keeps its `dry_run` outcome and `warnings`, and now adds `checkout_mismatch: true`
|
|
92
|
+
and says in `message` that a real run would stop. The check also compares currency: a checkout
|
|
93
|
+
in another currency than the catalog's price is a mismatch. The quoted total and currency are
|
|
94
|
+
still `--quote`'s `quote_changed` check, unchanged.
|
|
95
|
+
|
|
96
|
+
- **Security: the WebMCP hand-off flow stops on a mismatched cart too.** Against a WebMCP page
|
|
97
|
+
whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy` read
|
|
98
|
+
the cart back and checked it, but only put a mismatch in `warnings`: it still called the hand-off
|
|
99
|
+
tool, sending the browser tab to the store's checkout, and ran autofill there when it was
|
|
100
|
+
approved. Now any mismatch stops the run before that tool, with outcome `checkout_mismatch` and
|
|
101
|
+
`decisions.escalation.reason: "mismatch"`. Nothing is autofilled, and `checkout_url` is the
|
|
102
|
+
store's `/cart` page, so the shopper can look at the cart that was built. A matching cart hands
|
|
103
|
+
off as `express_stop`, as before. The dry run of this flow builds no cart, so it can't check one
|
|
104
|
+
and never carries `checkout_mismatch: true`.
|
|
105
|
+
|
|
9
106
|
## [0.11.0] - 2026-10-01
|
|
10
107
|
|
|
11
108
|
- **Category classification uses the whole taxonomy.** `known-stores/categories.yml` is now generated
|
|
@@ -708,7 +805,7 @@ pre-1.0, so APIs may still shift between minor versions.
|
|
|
708
805
|
## [0.7.4] - 2026-09-25
|
|
709
806
|
|
|
710
807
|
- **`portage doctor` reports how it was installed, and warns when another
|
|
711
|
-
`portage` shadows it** (
|
|
808
|
+
`portage` shadows it** ([plan](https://github.com/tomtom87/Portage/blob/d51200f/docs/plans/homebrew-distribution.md) Phase 4).
|
|
712
809
|
New findings, all offline and without shelling out to `brew`:
|
|
713
810
|
- `install`: `homebrew` (with the Cellar keg) when this gem or its Ruby
|
|
714
811
|
lives under `HOMEBREW_PREFIX/Cellar/portage/` (`$HOMEBREW_PREFIX`,
|
data/README.md
CHANGED
|
@@ -29,6 +29,20 @@ portage find --query "burton snowboard" --max-price 400
|
|
|
29
29
|
|
|
30
30
|
`portage buy` with no URL runs that search and then buys the offer you pick.
|
|
31
31
|
|
|
32
|
+
Already know the store? `portage find --store URL --query "..."` skips the search
|
|
33
|
+
backends and offer sources and searches only that store's catalogue, live and
|
|
34
|
+
read-only (catalogue search only, never a cart or checkout). Use it to re-check
|
|
35
|
+
an index hit or an earlier offer before quoting its price or stock. The report
|
|
36
|
+
has the same shape as a normal find (`offer_ref`, `search_id`, a history entry),
|
|
37
|
+
so `pick` and `buy --offer` work on its offers. A hand-off-only host is never
|
|
38
|
+
fetched and is reported as such; a store that doesn't speak UCP is reported as
|
|
39
|
+
having no catalogue, with exit code 1 (as for any find with no offers). `--store`
|
|
40
|
+
must be an http(s) URL (a bare host is read as https).
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
portage find --store https://shop.example --query "cold brew" --json
|
|
44
|
+
```
|
|
45
|
+
|
|
32
46
|
Already have the item and want to know where else it's sold? `portage compare`
|
|
33
47
|
resolves a product you name by URL + product id, then runs the same
|
|
34
48
|
find pipeline against its title and ranks the results by how confident the
|
|
@@ -131,6 +145,7 @@ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
|
|
|
131
145
|
portage buy --quote QUOTE_ID --yes [--json] ...
|
|
132
146
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
133
147
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
148
|
+
portage find --store URL --query "..." [--max-price N] [--json]
|
|
134
149
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
135
150
|
[--max-price N] [--json]
|
|
136
151
|
portage check <url> [--json]
|
|
@@ -497,7 +512,7 @@ probes per run), throttled, with progress output. Sources:
|
|
|
497
512
|
| --- | --- | --- |
|
|
498
513
|
| `shopify_catalog` | Merchant origins and product identities, one query per top-level taxonomy node, from `catalog.shopify.com`'s open catalog | on |
|
|
499
514
|
| `stores_file` | Your own `~/.portage/stores.yml` | on |
|
|
500
|
-
| `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser |
|
|
515
|
+
| `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | opt-in (`--sources browser`); yields nothing unless you've run `browser import` |
|
|
501
516
|
| `wikidata` | Retailers'/brands' official sites via a public SPARQL query | opt-in (`--sources wikidata`) |
|
|
502
517
|
| `webmcp_sweep` | Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
|
|
503
518
|
| `storefront_products` | Each indexed Shopify store's own `/products.json`: title, brand, handle, URL, first image, options and variant ids, mapped through the UCP `Product` shape with price and availability dropped | opt-in (`--sources storefront_products`, or `index add URL --crawl`) |
|
|
@@ -811,6 +826,30 @@ checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
|
|
|
811
826
|
product search: nothing is added to the store's cart, the tab isn't sent to
|
|
812
827
|
checkout and nothing is autofilled. The `dry_run` report carries a `would:`
|
|
813
828
|
key with the line item, the hand-off tool and whether autofill would run.
|
|
829
|
+
Because it builds no cart, it can't check one, so it never carries
|
|
830
|
+
`checkout_mismatch: true`.
|
|
831
|
+
|
|
832
|
+
A real run against that kind of page reads the cart back after adding to it
|
|
833
|
+
and checks it the same way `buy` checks any checkout (item, quantity, unit
|
|
834
|
+
price, currency, extra lines). Any mismatch stops the run there, with outcome
|
|
835
|
+
`checkout_mismatch` and `decisions.escalation.reason: "mismatch"`: the
|
|
836
|
+
hand-off tool isn't called, so the tab never goes to checkout, and nothing is
|
|
837
|
+
autofilled. The cart is already on the store, so `checkout_url` is the
|
|
838
|
+
store's `/cart` page, for the shopper to look at. Items already sitting in
|
|
839
|
+
the store's cart count as extra lines, so they stop the run too.
|
|
840
|
+
|
|
841
|
+
The same run then applies the other gates a checkout gets before anything
|
|
842
|
+
leaves the cart: a `--quote` run's cap (`quote_changed`), and, when a
|
|
843
|
+
decision backend is enabled, the confidence check (see "Decisions"). A hold
|
|
844
|
+
from the confidence check reports `low_confidence` with the `/cart` page as
|
|
845
|
+
`checkout_url`; again the tab isn't sent to checkout and nothing is
|
|
846
|
+
autofilled.
|
|
847
|
+
|
|
848
|
+
A page whose WebMCP tools build a checkout (no preset) gets the same confidence
|
|
849
|
+
check before its `express_stop` hand-off, after the quote cap and the mismatch
|
|
850
|
+
stop. A hold reports `low_confidence` with the `/cart` page as `checkout_url`
|
|
851
|
+
and no navigation to the checkout, so a `profile` hand-off target is never sent
|
|
852
|
+
there. With no decision backend enabled, nothing changes.
|
|
814
853
|
|
|
815
854
|
Once the flow hands off to the store's own checkout page, the shopper can
|
|
816
855
|
opt into having it pre-filled: `--autofill`, or
|
|
@@ -842,7 +881,9 @@ gem install portage-ucp-decision # only needed for the confidence gate
|
|
|
842
881
|
```
|
|
843
882
|
|
|
844
883
|
The confidence gate is the one feature that needs the gem, because the
|
|
845
|
-
model backends live there.
|
|
884
|
+
model backends live there. Use `portage-ucp-decision` 0.1.2 or newer: it
|
|
885
|
+
rejects a backend answer that isn't a probability between 0 and 1, where
|
|
886
|
+
0.1.1 let an answer above 1 clear any threshold.
|
|
846
887
|
|
|
847
888
|
Every `portage buy` report carries an `outcome`, so a script or agent loop
|
|
848
889
|
can branch on data rather than on the message text. The text output leads
|
|
@@ -852,12 +893,12 @@ with the same value, as `[outcome]`.
|
|
|
852
893
|
| --- | --- | --- |
|
|
853
894
|
| `purchased` | Completed. | no |
|
|
854
895
|
| `needs_confirmation` | Checkout ready; rerun with `--yes`. | no |
|
|
855
|
-
| `dry_run` | Checkout created, `--dry-run` stopped it. | no |
|
|
896
|
+
| `dry_run` | Checkout created, `--dry-run` stopped it. `checkout_mismatch: true` when a real run would stop on a mismatch. | no |
|
|
856
897
|
| `requires_escalation` | The store wants the shopper to finish. | yes |
|
|
857
|
-
| `checkout_mismatch` | Checkout differs from the request (
|
|
898
|
+
| `checkout_mismatch` | Checkout differs from the request (item, quantity, unit price, currency, or a priced line nobody asked for); stopped before payment. | yes |
|
|
858
899
|
| `no_payment_token` | No `--payment-token` and no default payment method. | yes |
|
|
859
900
|
| `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
|
|
860
|
-
| `low_confidence` | The confidence gate held it. | yes |
|
|
901
|
+
| `low_confidence` | The confidence gate held it (before a `--yes` completion, or before a WebMCP hand-off to checkout, preset or not). | yes |
|
|
861
902
|
| `permission_denied` | The store doesn't let this agent complete checkout. | yes |
|
|
862
903
|
| `handoff_only` | Tier C: Amazon or another hand-off-only host. `legal_notice` explains why; see "Hand-off targets and hand-off-only hosts". | yes (built, never fetched) |
|
|
863
904
|
| `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
|
|
@@ -884,9 +925,15 @@ Every verdict has a `reason`: `null` when the gate let the purchase through,
|
|
|
884
925
|
otherwise a string naming why it stopped it.
|
|
885
926
|
|
|
886
927
|
- **escalation** — `Support::Escalation`. A `requires_escalation` checkout
|
|
887
|
-
always escalates (`reason: "requires_escalation"`).
|
|
888
|
-
match the request
|
|
889
|
-
|
|
928
|
+
always escalates (`reason: "requires_escalation"`). So does a checkout that
|
|
929
|
+
doesn't match the request (`reason: "mismatch"`), on every run but
|
|
930
|
+
`--dry-run`, where the mismatch is reported in `warnings` and flagged with
|
|
931
|
+
`checkout_mismatch: true` instead. Nothing turns this off:
|
|
932
|
+
`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored. A mismatch
|
|
933
|
+
is the requested line missing, a different quantity, a different unit
|
|
934
|
+
price or currency than the store's own catalog, or any extra line the
|
|
935
|
+
request didn't include. An extra line that costs nothing (a free gift, a
|
|
936
|
+
$0 sample) is allowed; one whose cost can't be read counts as priced.
|
|
890
937
|
- **policy** — `PolicyGuard`, run on your policy file (see "Policy" above)
|
|
891
938
|
before any `--yes` completion. `reason` is the guard's own
|
|
892
939
|
(`per_transaction_cap_exceeded`, `rolling_spend_cap_exceeded`,
|
|
@@ -901,16 +948,39 @@ otherwise a string naming why it stopped it.
|
|
|
901
948
|
when the store answers that it's purchased.
|
|
902
949
|
- **confidence** — `ConfidenceGate`, off unless `--decision-backend` or
|
|
903
950
|
`PORTAGE_DECISION_BACKEND` names a backend. Right before a `--yes`
|
|
904
|
-
completion
|
|
905
|
-
and
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
951
|
+
completion, and before a WebMCP preset flow sends the browser to the
|
|
952
|
+
store's checkout (and autofills it), it asks the backend whether the
|
|
953
|
+
checkout matches the request, and the approved quote on a `--quote` run,
|
|
954
|
+
and is safe to complete unattended. It is additive only: it runs after
|
|
955
|
+
the mismatch check, the quote cap and the spend policy, on a checkout all
|
|
956
|
+
of them let through, so it can hold a purchase but never let through one
|
|
957
|
+
they would stop. The gate fails closed, so three things hold the
|
|
958
|
+
purchase: a score below the threshold (`reason: "below_threshold"`), a
|
|
959
|
+
backend that can't answer or answers with something that isn't a
|
|
960
|
+
probability (`"backend_error"`), and naming a backend without
|
|
961
|
+
`portage-ucp-decision` installed (`"not_installed"`). For the last two,
|
|
962
|
+
`error` says what went wrong. `jev` needs `JEV_API_KEY`; `laya` needs
|
|
963
|
+
`LAYA_BRIDGE_SCRIPT` (see `portage-ucp-decision`'s README). `portage
|
|
964
|
+
doctor` flags whichever of these is missing for the selected backend.
|
|
965
|
+
|
|
966
|
+
`jev` is TypeSafe's hosted API, so what it's sent is kept to a minimal,
|
|
967
|
+
allowlisted summary (`Portage::Cli::ConfidenceState`); nothing else on
|
|
968
|
+
the checkout is read:
|
|
969
|
+
|
|
970
|
+
- `request`: the search query, the store's host, the quantity, and the
|
|
971
|
+
picked item's id and title.
|
|
972
|
+
- `approved_quote` (`--quote` runs only): the quote's store, product id,
|
|
973
|
+
title, quantity, total and currency.
|
|
974
|
+
- `checkout`: status, currency; per line, item id, title, unit price,
|
|
975
|
+
quantity, line totals and whether it's the requested line; the totals
|
|
976
|
+
(subtotal, shipping, tax, fees, total — whatever the store lists);
|
|
977
|
+
applied discounts' titles and amounts; the selected shipping option's
|
|
978
|
+
title and price.
|
|
979
|
+
- `warnings`.
|
|
980
|
+
|
|
981
|
+
Never sent: the payment token, the shipping address, the buyer's name,
|
|
982
|
+
phone or email, checkout ids, links and URLs, discount codes, or anything
|
|
983
|
+
from your environment. Store-supplied strings are cut to 200 characters.
|
|
914
984
|
- **policy** also denies a checkout with no `total` line as
|
|
915
985
|
`total_unknown` whenever a spend cap is set, rather than skipping the cap.
|
|
916
986
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
require "portage/ucp/client"
|
|
3
|
+
require_relative "homepage_fetch"
|
|
4
|
+
require_relative "permissive_authenticator"
|
|
5
|
+
|
|
6
|
+
module Portage
|
|
7
|
+
module Cli
|
|
8
|
+
# The own-store loopback fallback HandoffReconciler and PaymentMethods
|
|
9
|
+
# share, mirroring Buy's own (#adapter_flow): sniff the platform from
|
|
10
|
+
# the store's homepage and, when that platform's env vars are set,
|
|
11
|
+
# build its adapter and wrap it in a loopback client. A later process
|
|
12
|
+
# must not assume an earlier one's session or credentials still exist,
|
|
13
|
+
# only that the env vars are still set.
|
|
14
|
+
module AdapterSession
|
|
15
|
+
# @param route [Symbol] the Support::Connection route the homepage
|
|
16
|
+
# fetch goes over (PaymentMethods passes :payment).
|
|
17
|
+
# @return [Portage::Ucp::Client::Session, nil] nil when the platform
|
|
18
|
+
# isn't recognised, its env is incomplete, or anything fails.
|
|
19
|
+
def self.call(uri, route: :store)
|
|
20
|
+
body, headers = HomepageFetch.call(uri, route: route)
|
|
21
|
+
platform = body && Portage::Ucp::Resolver.detect_platform(body, headers)
|
|
22
|
+
return nil unless platform
|
|
23
|
+
|
|
24
|
+
env = Portage::Ucp::Resolver.env_for(platform)
|
|
25
|
+
return nil unless Portage::Ucp::Resolver.missing_env(platform, env).empty?
|
|
26
|
+
|
|
27
|
+
adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
|
|
28
|
+
Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -88,7 +88,7 @@ module Portage
|
|
|
88
88
|
# @return [Hash] the proposal: counts, `kept` (domains to save) and
|
|
89
89
|
# `products` (only with include_product_pages) — or `error:` /
|
|
90
90
|
# `message:` when the browser's files couldn't be read (Safari
|
|
91
|
-
# without Full Disk Access, no
|
|
91
|
+
# without Full Disk Access, a SQLite error, no plutil, no profile found).
|
|
92
92
|
def plan(options)
|
|
93
93
|
profiles = Profiles.locate(options.browser, root: options.root)
|
|
94
94
|
return no_profile(options) if profiles.empty?
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "strscan"
|
|
2
|
+
require "cgi/escape"
|
|
2
3
|
|
|
3
4
|
module Portage
|
|
4
5
|
module Cli
|
|
@@ -8,7 +9,8 @@ module Portage
|
|
|
8
9
|
# form into XML. `plutil -convert json` would be simpler but refuses
|
|
9
10
|
# any plist holding a <date> (Safari's Reading List entries carry
|
|
10
11
|
# them), and a real XML library (rexml) isn't a runtime dependency of
|
|
11
|
-
# this gem — this is ~50 lines instead of a new one.
|
|
12
|
+
# this gem — this is ~50 lines instead of a new one. Entities go
|
|
13
|
+
# through CGI.unescapeHTML from `cgi/escape`, a default library.
|
|
12
14
|
#
|
|
13
15
|
# Dates, data and numbers come back as their raw text; bookmark
|
|
14
16
|
# import only ever reads strings out of the tree.
|
|
@@ -16,7 +18,6 @@ module Portage
|
|
|
16
18
|
class ParseError < StandardError; end
|
|
17
19
|
|
|
18
20
|
SCALARS = "string|key|data|date|integer|real".freeze
|
|
19
|
-
ENTITIES = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" }.freeze
|
|
20
21
|
|
|
21
22
|
def self.parse(xml)
|
|
22
23
|
scanner = StringScanner.new(xml.to_s)
|
|
@@ -31,7 +32,7 @@ module Portage
|
|
|
31
32
|
elsif scanner.scan(%r{<(true|false)\s*/>}) then scanner[1] == "true"
|
|
32
33
|
elsif scanner.scan("<dict>") then dict(scanner)
|
|
33
34
|
elsif scanner.scan("<array>") then array(scanner)
|
|
34
|
-
elsif scanner.scan(%r{<(#{SCALARS})>(.*?)</\1>}m) then
|
|
35
|
+
elsif scanner.scan(%r{<(#{SCALARS})>(.*?)</\1>}m) then CGI.unescapeHTML(scanner[2])
|
|
35
36
|
else raise ParseError, "unexpected plist content at #{scanner.pos}"
|
|
36
37
|
end
|
|
37
38
|
end
|
|
@@ -57,15 +58,7 @@ module Portage
|
|
|
57
58
|
{ "dict" => {}, "array" => [] }.fetch(tag, "")
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
text.gsub(/&(#x?)?(\w+);/) do
|
|
62
|
-
if Regexp.last_match(1) == "#x" then [Regexp.last_match(2).to_i(16)].pack("U")
|
|
63
|
-
elsif Regexp.last_match(1) == "#" then [Regexp.last_match(2).to_i].pack("U")
|
|
64
|
-
else ENTITIES.fetch(Regexp.last_match(2), Regexp.last_match(0))
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
private_class_method :value, :dict, :array, :empty, :unescape
|
|
61
|
+
private_class_method :value, :dict, :array, :empty
|
|
69
62
|
end
|
|
70
63
|
end
|
|
71
64
|
end
|
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
require "
|
|
2
|
-
require "open3"
|
|
1
|
+
require "sqlite3"
|
|
3
2
|
require "tmpdir"
|
|
4
3
|
require "fileutils"
|
|
5
4
|
|
|
6
5
|
module Portage
|
|
7
6
|
module Cli
|
|
8
7
|
module BrowserImport
|
|
9
|
-
# Reads a browser's SQLite history database
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
8
|
+
# Reads a browser's SQLite history database: the database (plus its
|
|
9
|
+
# `-wal` file, when the browser left one — it holds the most recent
|
|
10
|
+
# visits until the next checkpoint) is copied into a private tmpdir
|
|
11
|
+
# first, since a running browser holds it locked, and the copy is
|
|
12
|
+
# queried read-only through the sqlite3 gem. The `-shm` index is not
|
|
13
|
+
# copied: SQLite rebuilds it from the `-wal` in the (writable) tmpdir.
|
|
14
|
+
# The copy is deleted before this returns, success or not. The
|
|
15
|
+
# original file is only ever read by the copy.
|
|
16
16
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# anything else.
|
|
17
|
+
# Any SQLite error (a corrupt or non-SQLite file, a schema that
|
|
18
|
+
# doesn't match the query) is Unavailable, never a fallback.
|
|
20
19
|
class Sqlite
|
|
21
20
|
class Unavailable < StandardError; end
|
|
22
21
|
|
|
@@ -25,12 +24,6 @@ module Portage
|
|
|
25
24
|
# around — see Importer's own handling.
|
|
26
25
|
class PermissionDenied < StandardError; end
|
|
27
26
|
|
|
28
|
-
# @param command [String] the sqlite3 executable — injectable so a
|
|
29
|
-
# spec can prove the missing-binary path without uninstalling it.
|
|
30
|
-
def initialize(command: "sqlite3")
|
|
31
|
-
@command = command
|
|
32
|
-
end
|
|
33
|
-
|
|
34
27
|
# Copies `path` once and yields a query proc, so one copy serves
|
|
35
28
|
# several queries (Firefox's history and bookmarks both live in
|
|
36
29
|
# places.sqlite).
|
|
@@ -54,14 +47,15 @@ module Portage
|
|
|
54
47
|
end
|
|
55
48
|
|
|
56
49
|
def run(copy, sql)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
raise Unavailable, "the sqlite3 command isn't installed (it ships with macOS and most Linux distros)"
|
|
50
|
+
db = SQLite3::Database.new(copy, readonly: true, results_as_hash: true)
|
|
51
|
+
db.execute(sql).map { |row| row.transform_values { |v| v.is_a?(String) ? scrub(v) : v } }
|
|
52
|
+
rescue SQLite3::Exception => e
|
|
53
|
+
raise Unavailable, "SQLite error: #{e.message}"
|
|
54
|
+
ensure
|
|
55
|
+
db&.close
|
|
64
56
|
end
|
|
57
|
+
|
|
58
|
+
def scrub(text) = text.dup.force_encoding("UTF-8").scrub
|
|
65
59
|
end
|
|
66
60
|
end
|
|
67
61
|
end
|
|
@@ -34,15 +34,6 @@ module Portage
|
|
|
34
34
|
get(host, port, "/json/new?#{URI.encode_www_form_component(url)}", method: :put)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# `/json/close/<id>` answers plain text ("Target is closing"), not
|
|
38
|
-
# JSON — checked by success status only, never parsed as JSON.
|
|
39
|
-
def self.close_tab(port:, id:, host: HOST)
|
|
40
|
-
uri = URI("http://#{host}:#{port}/json/close/#{id}")
|
|
41
|
-
fetch(uri, :get).is_a?(Net::HTTPSuccess)
|
|
42
|
-
rescue StandardError
|
|
43
|
-
false
|
|
44
|
-
end
|
|
45
|
-
|
|
46
37
|
def self.get(host, port, path, method: :get)
|
|
47
38
|
uri = URI("http://#{host}:#{port}#{path}")
|
|
48
39
|
response = fetch(uri, method)
|