portage-cli 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 805f3c78c6560bcdb4110d1bb97dcaa0700a7586578cc639fb94595cfd5c09bc
4
- data.tar.gz: f2f4ca834b30fb319563ea10741385150559437bdfd87bc4657051d1f2dc0182
3
+ metadata.gz: 3d34c9ba25977baefa9f1f802e38ae6b2175e455d246f7a0dd3a2833136c46e8
4
+ data.tar.gz: 2c1d701bafd9c914115bdb02ab661906eaddba139e0627a56bfa0cf094e71d06
5
5
  SHA512:
6
- metadata.gz: 3b10afae4ce3260952193fd8c32b7eb8b5a97b8a4292ca78e40848a41fd94f84e3f18490115186296253edd1d8fdf65a1cd728c1eca5e96efc8a21c18563ebcb
7
- data.tar.gz: 03b8c8ba8a3ee6e19116d00f6775c6825e3c70d23f7a4b1864acb44435988edf9ba400a52cdf5548ee9660e0588cd938f0c0eba6ed24afcea0010d2669abddb3
6
+ metadata.gz: ec3b6bf08d832abbe197591050b229f38a3650603feb6e94b47a6e84fbb9d3b9ddf531c51e84d71c8b708131b655e6b501e6a55662908eb6edb60981da4f14a2
7
+ data.tar.gz: 137b718fd1283383aa70879a6f2290a35dc1bc3f3a898ceff6965019b110329caf943dcf3ef39c04c29d1661beee3da6edd3a056877b7a6c3a932debce90c99f
data/CHANGELOG.md CHANGED
@@ -6,6 +6,63 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-09-29
10
+
11
+ - **Human pick and approve** (`docs/plans/human-pick-and-approve.md`, Phases 1-3).
12
+ Loop steps 3 and 5 now have a ready-made interface for a person at a terminal
13
+ and for an agent relaying their answer.
14
+ - **Offer refs.** Each `find` offer carries an `offer_ref` (`of_` and 6 hex
15
+ digits), and the report a `search_id` (`se_` and 8 hex digits). Both are saved
16
+ with the search in history, so `history --json` searches now hold `search_id`
17
+ and `offers[]`. `compare` saves its offers with refs and a `search_id` too.
18
+ `portage buy --offer REF` takes the store, product and query from the saved
19
+ offer; an unknown ref is `offer_not_found`.
20
+ - **Quotes.** A `buy --dry-run` that priced a checkout saves a quote in
21
+ `~/.portage/quotes/` and reports `quote_id`. `buy --quote QUOTE_ID --yes`
22
+ buys exactly that quote, capped at the quoted total: if the real checkout costs
23
+ more (or is in another currency) it refuses with `quote_changed`, carrying
24
+ `quoted_total` and `current_total`, without charging or handing off. Quotes don't
25
+ expire and are used once (`purchased` or any hand-off). `quote_not_found` and
26
+ `quote_used` cover the rest.
27
+ - **`portage pick`.** Loop step 3. `needs_pick` returns `choices[]` (`ref`, `label`,
28
+ `url`, plus the offer's fields) and a last "Compare an offer across stores"
29
+ choice; `--choose REF` relays an answer (`picked`, `by: "agent_relayed"`),
30
+ `--compare REF` runs compare and shows the pick again over its results,
31
+ `--search LAST|SEARCH_ID` picks the search. At a terminal it asks on `/dev/tty`
32
+ (`by: "person"`).
33
+ - **`portage approve QUOTE_ID`.** Loop step 5. `needs_approval` returns a
34
+ `summary` (title, store, qty, total, `total_display`, `url`); `--relayed-yes`
35
+ records `approved_by: "agent_relayed"`; a yes typed at a terminal records
36
+ `"person"`. The quote also keeps `approved`, `approved_by` and `approved_at`.
37
+ - **`--via auto|tty|agent`.** `tty` asks on `/dev/tty` (so it works with stdout
38
+ piped) and returns `no_terminal` when there's none; `agent` asks nobody;
39
+ `auto` is `tty` with a terminal and no `--json`, else `agent`.
40
+ - **`--view REF` / `approve --view` and `v N` / `v` at a prompt** open the product
41
+ page and never count as an answer. Only an `http(s)` URL on the offer's own
42
+ store host is opened; anything else is `view_refused`. `pick`'s compare
43
+ choice uses the proxy settings from env and config (there are no `--proxy`
44
+ flags on `pick`).
45
+ - **`policy set --require-approval person|any|off`** (default `any`, stored as
46
+ `require_approval` in `~/.portage/policy.json`, always shown by `policy show`).
47
+ Lowering it needs a yes typed at a terminal. It raises the bar against an agent
48
+ but isn't a hard guarantee: a process with a shell can edit the policy or quote
49
+ files. Docs: `docs/api/cli-json.md`, `docs/agentic-flow.md`,
50
+ `docs/cli-usage-tutorial.md`.
51
+ - **Upgrade note.** Under the default `any`, a `buy --yes` without an approved
52
+ `--quote` no longer buys: it dry-runs and returns `needs_approval` (exit `0`).
53
+ Restore the old behaviour with `portage policy set --require-approval off`
54
+ from a terminal. `buy --query`'s numbered pick now asks on `/dev/tty` too.
55
+
56
+ - Documentation only, no code change. The README said a
57
+ `webmcp_mapping_unconfirmed` report returns the proposed mapping "for the
58
+ caller to pass back". No flag takes a mapping back, and `Buy` has no
59
+ `tool_names:` keyword. The README now says what each caller can do. From
60
+ the CLI, re-run the command in a real terminal without `--json`
61
+ (`--dry-run` is enough) and answer the prompt; the approved mapping is
62
+ saved to `~/.portage/webmcp_mappings.json`. From Ruby, inject
63
+ `webmcp_mapping_confirm:` or `webmcp_mappings:`, or pass `tool_names:` to
64
+ `WebMcp.connect` yourself.
65
+
9
66
  ## [0.8.0] - 2026-09-29
10
67
 
11
68
  - **Requires `portage-ucp-webmcp` 0.2.0 or newer for WebMCP paths.**
data/README.md CHANGED
@@ -127,10 +127,15 @@ portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id
127
127
  [--handoff-target default|print|profile|agent:NAME]
128
128
  [--decision-backend jev|laya] [--min-confidence N] [--json]
129
129
  [--wait [--wait-timeout DURATION|off]]
130
+ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
131
+ portage buy --quote QUOTE_ID --yes [--json] ...
130
132
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
131
133
  portage find --query "..." [--max-price N] [--limit N] [--json]
132
134
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
133
135
  [--max-price N] [--json]
136
+ portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
137
+ [--choose REF | --compare REF | --view REF]
138
+ portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
134
139
  portage history [list] [--purchases|--searches] [--limit N] [--json]
135
140
  portage history clear [--purchases|--searches]
136
141
  portage payment list [--json]
@@ -145,6 +150,7 @@ portage policy set [--per-transaction-cap N --currency CUR]
145
150
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
146
151
  [--velocity-count N --velocity-window-seconds N]
147
152
  [--allow HOST ...] [--clear-allowlist]
153
+ [--require-approval person|any|off] (lowering asks at a terminal)
148
154
  portage orders reconcile [--checkout ID] [--json]
149
155
  portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
150
156
  portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
@@ -361,6 +367,21 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
361
367
  limits bound to one enrolled card) are set via `portage payment enroll
362
368
  --scope-*` above, not here.
363
369
 
370
+ `portage policy set --require-approval person|any|off` (default `any`) sets what a
371
+ real `buy --yes` needs: `off` is `--yes` alone; `any` needs `--quote QUOTE_ID` for a
372
+ quote approved with `portage approve` (by the person, or relayed by an agent with
373
+ `--relayed-yes`); `person` needs the person's own yes at a terminal. Otherwise the run
374
+ is a dry run that returns `needs_approval`. Lowering the level asks for a yes at a
375
+ terminal. Stored as `require_approval` in `~/.portage/policy.json`. It raises the bar
376
+ against an agent but isn't a hard guarantee: a process with a shell can edit that file
377
+ or the quote files in `~/.portage/quotes/`. The whole flow (`find`, `pick`, `buy
378
+ --offer --dry-run`, `approve`, `buy --quote --yes`) is in the
379
+ [CLI JSON reference](../docs/api/cli-json.md) and the
380
+ [tutorial](../docs/cli-usage-tutorial.md#picking-and-approving-at-the-terminal). Upgrade
381
+ note: under the default `any`, `buy --yes` with no approved `--quote` no longer buys;
382
+ restore the old behaviour with `portage policy set --require-approval off` from a
383
+ terminal.
384
+
364
385
  ### Tiers: how a purchase actually finishes
365
386
 
366
387
  Most stores don't let a third-party agent complete payment. `portage buy`
@@ -737,9 +758,24 @@ hand-off. `token` isn't implemented yet; it reports
737
758
  Against a page whose tools aren't a known platform preset, `Buy` falls back
738
759
  to a schema-matched, shopper-confirmed mapping instead of giving up (see
739
760
  `portage-ucp-webmcp`'s README, "Stores that don't run Portage"). A mutating
740
- match prompts on a real TTY with `--json` off; under `--json` or with no
741
- TTY it stops instead (outcome `webmcp_mapping_unconfirmed`) and returns the
742
- proposed mapping for the caller to pass back.
761
+ match prompts on a real TTY with `--json` off. Under `--json`, or with no
762
+ TTY, it stops instead: outcome `webmcp_mapping_unconfirmed`, with the
763
+ proposal in `tool_names_proposal`.
764
+
765
+ No flag passes a mapping back. From the CLI, re-run the same command in
766
+ your own terminal without `--json` and answer the prompt. `--dry-run` is
767
+ enough, because the mapping is confirmed before the dry-run check. The
768
+ approved mapping is saved to `~/.portage/webmcp_mappings.json`, and later
769
+ runs reuse it with no prompt.
770
+
771
+ `Buy` has no `tool_names:` keyword either. A library caller has two hooks.
772
+ `webmcp_mapping_confirm:` takes any object whose `call(proposal, tools)`
773
+ returns a `tool_names:` hash, or nil to stop with
774
+ `webmcp_mapping_unconfirmed`. `Buy` saves whatever hash it returns to
775
+ `webmcp_mappings:`, the store approved mappings are read from (default: a
776
+ `Portage::Cli::WebmcpMappings` on `~/.portage/webmcp_mappings.json`).
777
+ Outside `Buy`, pass `tool_names:` to `Portage::Ucp::WebMcp.connect`
778
+ yourself.
743
779
 
744
780
  `dry_run: true` against a page whose preset hands off through its own
745
781
  checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
@@ -0,0 +1,56 @@
1
+ require "portage/ucp"
2
+ require_relative "human_prompt"
3
+
4
+ module Portage
5
+ module Cli
6
+ # `portage policy set --require-approval person|any|off`
7
+ # (docs/plans/human-pick-and-approve.md Phase 2, Design § 5): how much
8
+ # approval a real `portage buy --yes` needs before it may charge or hand
9
+ # off.
10
+ #
11
+ # - `off`: `--yes` alone buys (the behaviour before this setting).
12
+ # - `any` (the default): `--yes` needs `--quote` with an approved quote,
13
+ # approved by the person at a tty or relayed by an agent.
14
+ # - `person`: only a quote the person approved at the tty counts — a
15
+ # model with a shell can't type on `/dev/tty`.
16
+ #
17
+ # Stored as `require_approval` in ~/.portage/policy.json (Portage::Ucp::
18
+ # Policy), next to the caps and allowlist it sits beside in `portage
19
+ # policy`. Policy#set takes any key and its file format is documented as
20
+ # private, and PolicyGuard reads only the keys it knows, so this needs
21
+ # no change to the portage-ucp gem. Deliberately no env var or
22
+ # config.json override: either would let an agent lower it without the
23
+ # tty confirmation `policy set` asks for. The gate itself is CLI-only
24
+ # (Cli.approval_gate); Buy's library callers aren't governed by it.
25
+ module ApprovalPolicy
26
+ KEY = "require_approval".freeze
27
+ LEVELS = %w[off any person].freeze
28
+ DEFAULT = "any".freeze
29
+
30
+ module_function
31
+
32
+ # An unrecognised stored value (a hand-edited file) fails closed, to
33
+ # the strictest level.
34
+ # @return [String] one of LEVELS.
35
+ def level(policy = Portage::Ucp::Policy.load)
36
+ stored = policy.to_h[KEY]
37
+ return DEFAULT if stored.nil?
38
+
39
+ LEVELS.include?(stored) ? stored : "person"
40
+ end
41
+
42
+ def configured?(policy) = policy.to_h.key?(KEY)
43
+
44
+ def lowering?(from, to) = LEVELS.index(to) < LEVELS.index(from)
45
+
46
+ # @param quote [Hash, nil] a saved quote (Quotes).
47
+ def satisfied?(quote, level)
48
+ case level
49
+ when "off" then true
50
+ when "any" then [HumanPrompt::BY_PERSON, HumanPrompt::BY_AGENT].include?(quote&.dig("approved_by"))
51
+ else quote&.dig("approved_by") == HumanPrompt::BY_PERSON
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,135 @@
1
+ require_relative "history"
2
+ require_relative "quotes"
3
+ require_relative "human_prompt"
4
+ require_relative "product_page"
5
+ require_relative "money"
6
+ require_relative "approval_policy"
7
+
8
+ module Portage
9
+ module Cli
10
+ # `portage approve QUOTE_ID` — loop step 5, the person says yes to the
11
+ # exact dry-run total (docs/plans/human-pick-and-approve.md Phase 2,
12
+ # Design § 5). Shows title, store, qty and total and asks yes/no.
13
+ #
14
+ # A yes typed on the `tty` surface marks the quote `approved_by:
15
+ # "person"`; `--relayed-yes` marks it `"agent_relayed"`. The `agent`
16
+ # surface asks nobody and returns `needs_approval` with the summary.
17
+ # `--view` only opens the product page. Whether an approval is enough
18
+ # for `buy --quote QUOTE_ID --yes` is ApprovalPolicy's call, at buy time
19
+ # — except under `person`, where a relayed yes could never be enough, so
20
+ # it isn't recorded and the agent is told to hand the question to the
21
+ # person's own terminal instead.
22
+ #
23
+ # Returns a report hash; Cli.run_approve prints it.
24
+ class Approve
25
+ def initialize(quote_id:, prompt:, relayed_yes: false, view: false, quotes: Quotes.new, history: History.new,
26
+ level: ApprovalPolicy.level)
27
+ @quote_id = quote_id
28
+ @prompt = prompt
29
+ @relayed_yes = relayed_yes
30
+ @view = view
31
+ @quotes = quotes
32
+ @history = history
33
+ @level = level
34
+ end
35
+
36
+ def call
37
+ quote = @quotes.find(@quote_id)
38
+ return unusable(quote) if quote.nil? || quote["used_at"]
39
+
40
+ summary = self.class.summary(quote, history: @history)
41
+ return view(summary) if @view
42
+ return relay(summary) if @relayed_yes
43
+ return self.class.needs_approval(summary, level: @level) unless @prompt.tty?
44
+
45
+ ask(summary)
46
+ rescue HumanPrompt::NoTerminal => e
47
+ { outcome: "no_terminal", quote_id: @quote_id, message: e.message }
48
+ end
49
+
50
+ # What the person is asked to approve. Title and url were saved on
51
+ # the quote at dry-run time; a quote saved without them falls back to
52
+ # its offer's (via `offer_ref`, History#offer).
53
+ def self.summary(quote, history: History.new)
54
+ offer = fallback_offer(quote, history)
55
+ { quote_id: quote["quote_id"], title: quote["title"] || offer&.dig("title"), store: quote["store"],
56
+ product_id: quote["product_id"], qty: quote["qty"], total: quote["total"], currency: quote["currency"],
57
+ total_display: quote["total"] ? Money.format_amount(quote["total"], quote["currency"]) : "unknown",
58
+ url: quote["url"] || offer&.dig("url"), approved_by: quote["approved_by"] }
59
+ end
60
+
61
+ def self.fallback_offer(quote, history)
62
+ return nil unless quote["offer_ref"] && (quote["title"].nil? || quote["url"].nil?)
63
+
64
+ history.offer(quote["offer_ref"])
65
+ end
66
+ private_class_method :fallback_offer
67
+
68
+ # Also what a refused `buy --yes` returns (Cli.approval_gate).
69
+ def self.needs_approval(summary, message: nil, level: ApprovalPolicy::DEFAULT)
70
+ { outcome: "needs_approval", quote_id: summary[:quote_id], summary: summary,
71
+ message: message || relay_message(summary, level) }
72
+ end
73
+
74
+ def self.relay_message(summary, level)
75
+ id = summary[:quote_id]
76
+ ask = if level == "person"
77
+ "Ask the person to run `portage approve #{id}` in their own terminal (require_approval: person " \
78
+ "doesn't accept a relayed yes)"
79
+ else
80
+ "Ask the person to approve #{describe(summary)} (show the url as a link), then relay a yes: " \
81
+ "`portage approve #{id} --relayed-yes`"
82
+ end
83
+ "#{ask}, then `portage buy --quote #{id} --yes`."
84
+ end
85
+ private_class_method :relay_message
86
+
87
+ def self.describe(summary)
88
+ "#{summary[:qty]} × #{summary[:title] || summary[:product_id]} from #{summary[:store]} " \
89
+ "for #{summary[:total_display]}"
90
+ end
91
+
92
+ private
93
+
94
+ def ask(summary)
95
+ @prompt.say("#{summary[:title] || summary[:product_id]} — #{summary[:store]}")
96
+ @prompt.say(" qty #{summary[:qty]}, total #{summary[:total_display]}#{" — #{summary[:url]}" if summary[:url]}")
97
+ yes = @prompt.confirm("Buy #{self.class.describe(summary)}?", view: -> { page(summary)[:message] })
98
+ return approve(summary, HumanPrompt::BY_PERSON) if yes
99
+
100
+ { outcome: "cancelled", quote_id: summary[:quote_id], message: "Not approved — nothing will be bought." }
101
+ end
102
+
103
+ def relay(summary)
104
+ return self.class.needs_approval(summary, level: @level) if @level == "person"
105
+
106
+ approve(summary, HumanPrompt::BY_AGENT)
107
+ end
108
+
109
+ def approve(summary, by)
110
+ quote = @quotes.approve(summary[:quote_id], by: by)
111
+ return { outcome: "error", quote_id: @quote_id, message: "Couldn't save the approval." } unless quote
112
+
113
+ { outcome: "approved", quote_id: summary[:quote_id], approved_by: quote["approved_by"],
114
+ summary: summary.merge(approved_by: quote["approved_by"]),
115
+ message: "Approved #{self.class.describe(summary)} — next: " \
116
+ "`portage buy --quote #{summary[:quote_id]} --yes`." }
117
+ end
118
+
119
+ def view(summary) = page(summary).merge(quote_id: summary[:quote_id])
120
+
121
+ def page(summary) = ProductPage.new(url: summary[:url], store: summary[:store]).open
122
+
123
+ # Same outcomes `buy --quote` reports for the same two cases.
124
+ def unusable(quote)
125
+ if quote
126
+ { outcome: "quote_used", quote_id: @quote_id,
127
+ message: "Quote #{@quote_id} has already been used — dry-run again for a new one." }
128
+ else
129
+ { outcome: "quote_not_found", quote_id: @quote_id,
130
+ message: "No saved quote #{@quote_id} — run `portage buy ... --dry-run --json` for a new one." }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,38 @@
1
+ module Portage
2
+ module Cli
3
+ # The one shell-out that opens a URL in the shopper's browser, shared by
4
+ # CheckoutHandoff (a dead-end checkout's auto-open) and ProductPage
5
+ # (docs/plans/human-pick-and-approve.md Phase 2's "view the product
6
+ # page"). Mixed in rather than called as a module function so `system`
7
+ # stays the including object's own — specs stub it per instance.
8
+ #
9
+ # No new gem for the actual open — every other shell-out in this repo
10
+ # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
11
+ # `system` rather than pulling in launchy for something the OS already
12
+ # provides. `system(cmd, url)` (array form, never an interpolated
13
+ # string) so a merchant-controlled URL can't inject into a shell.
14
+ # Whether a URL is safe to open at all is the caller's check.
15
+ module BrowserOpener
16
+ private
17
+
18
+ # @return [Boolean] whether the browser was actually opened.
19
+ def open_browser(url)
20
+ command = platform_command
21
+ return false unless command
22
+
23
+ !!system(command, url)
24
+ rescue StandardError => e
25
+ warn "portage: couldn't open #{url} (#{e.message})"
26
+ false
27
+ end
28
+
29
+ def platform_command
30
+ case RbConfig::CONFIG["host_os"]
31
+ when /darwin/i then "open"
32
+ when /linux|bsd/i then "xdg-open"
33
+ when /mswin|mingw|cygwin/i then "start"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -10,6 +10,7 @@ require_relative "setting"
10
10
  require_relative "decisions"
11
11
  require_relative "confidence_check"
12
12
  require_relative "checkout_handoff"
13
+ require_relative "money"
13
14
  require_relative "notifier"
14
15
  require_relative "handoff_only"
15
16
  require_relative "offer_sources"
@@ -115,12 +116,18 @@ module Portage
115
116
  # default) builds one from the same interactive? posture as Phase
116
117
  # 2's webmcp_mapping_confirm; injectable so a spec can simulate an
117
118
  # interactive "y" without a real terminal.
119
+ # @param quote_total [Integer, nil] minor units — set by `buy --quote`:
120
+ # the total the person was quoted. Every path that would charge or
121
+ # hand off the real checkout first checks its total against this
122
+ # (with `quote_currency:`) and reports `quote_changed` instead if it
123
+ # is higher, in another currency, or missing. See #finish_checkout.
118
124
  # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- all keywords; one per flag, plus
119
125
  # injectable collaborators, each assigned to its own ivar
120
126
  def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
121
127
  auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
122
128
  transaction_log: nil, max_price: nil, webmcp_bridge: nil, webmcp_mappings: nil,
123
- webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false)
129
+ webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false,
130
+ quote_total: nil, quote_currency: nil)
124
131
  # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
125
132
  raw = url.to_s.strip
126
133
  @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
@@ -141,6 +148,8 @@ module Portage
141
148
  @autofill = autofill
142
149
  @webmcp_autofill_confirm = webmcp_autofill_confirm
143
150
  @json = json
151
+ @quote_total = quote_total
152
+ @quote_currency = quote_currency
144
153
  @webmcp_bridge = webmcp_bridge
145
154
  @decisions = {}
146
155
  end
@@ -970,6 +979,8 @@ module Portage
970
979
  end
971
980
 
972
981
  def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
982
+ return quote_changed_report(source, products, checkout, warnings) if quote_exceeded?(checkout)
983
+
973
984
  escalation = decide_escalation(checkout, warnings)
974
985
  return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
975
986
  return dry_run_report(source, products, checkout, warnings) if @dry_run
@@ -979,6 +990,33 @@ module Portage
979
990
  complete(session, source, products, checkout, warnings)
980
991
  end
981
992
 
993
+ # First in #finish_checkout, ahead of the escalation gates: a
994
+ # `quote_changed` refusal never hands off, since that would spend the
995
+ # quote and open a checkout the person never approved. #complete is
996
+ # the only place this file charges, and it is reached only through
997
+ # #finish_checkout.
998
+ def quote_exceeded?(checkout)
999
+ return false unless @quote_total
1000
+
1001
+ total = checkout_total(checkout)
1002
+ total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
1003
+ end
1004
+
1005
+ def quote_changed_report(source, products, checkout, warnings)
1006
+ total = checkout_total(checkout)
1007
+ checkout_report(source, products, checkout, outcome: "quote_changed", warnings: warnings,
1008
+ quoted_total: @quote_total, quoted_currency: @quote_currency,
1009
+ current_total: total, current_currency: checkout["currency"],
1010
+ message: quote_changed_message(total, checkout["currency"]))
1011
+ end
1012
+
1013
+ def quote_changed_message(total, currency)
1014
+ "The price changed since the quote (was #{quoted_amount(@quote_total, @quote_currency)}, " \
1015
+ "now #{quoted_amount(total, currency)}) — nothing was bought."
1016
+ end
1017
+
1018
+ def quoted_amount(amount, currency) = amount ? Money.format_amount(amount, currency) : "unknown"
1019
+
982
1020
  # Hand off vs. keep going is Decisions.escalation's call
983
1021
  # (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
984
1022
  # literal `requires_escalation` status always escalates. A mismatch
@@ -1,6 +1,7 @@
1
1
  require "uri"
2
2
  require_relative "config"
3
3
  require_relative "setting"
4
+ require_relative "browser_opener"
4
5
 
5
6
  module Portage
6
7
  module Cli
@@ -14,13 +15,11 @@ module Portage
14
15
  # --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
15
16
  # beats ~/.portage/config.json's "auto_open_checkout" (Config).
16
17
  #
17
- # No new gem for the actual open — every other shell-out in this repo
18
- # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
19
- # `system` rather than pulling in launchy for something the OS already
20
- # provides. `system(cmd, url)` (array form, never an interpolated
21
- # string) so a merchant-controlled checkout_url can't inject into a
22
- # shell.
18
+ # The open itself is BrowserOpener's array-form `system` shell-out, so
19
+ # a merchant-controlled checkout_url can't inject into a shell.
23
20
  class CheckoutHandoff
21
+ include BrowserOpener
22
+
24
23
  ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
25
24
  CONFIG_KEY = "auto_open_checkout".freeze
26
25
 
@@ -47,24 +46,6 @@ module Portage
47
46
  rescue URI::InvalidURIError
48
47
  false
49
48
  end
50
-
51
- def open_browser(url)
52
- command = platform_command
53
- return false unless command
54
-
55
- !!system(command, url)
56
- rescue StandardError => e
57
- warn "portage: couldn't open #{url} (#{e.message})"
58
- false
59
- end
60
-
61
- def platform_command
62
- case RbConfig::CONFIG["host_os"]
63
- when /darwin/i then "open"
64
- when /linux|bsd/i then "xdg-open"
65
- when /mswin|mingw|cygwin/i then "start"
66
- end
67
- end
68
49
  end
69
50
  end
70
51
  end
@@ -1,4 +1,5 @@
1
1
  require "uri"
2
+ require "securerandom"
2
3
  require "portage/ucp"
3
4
  require "portage/ucp/client"
4
5
 
@@ -62,7 +63,7 @@ module Portage
62
63
  sourced = source_offers
63
64
  return report(candidates: candidates, message: no_candidates_message) if nothing_to_go_on?(candidates, sourced)
64
65
 
65
- offers = rank(sourced + probed.flat_map { |store| offers_for(store) })
66
+ offers = rank(sourced + probed.flat_map { |store| offers_for(store) }).map { |o| with_offer_ref(o) }
66
67
  report(candidates: candidates, stores: store_summaries(stores), offers: offers,
67
68
  message: summary(candidates, stores, offers))
68
69
  rescue Portage::Ucp::Client::MissingAgentProfileError
@@ -78,6 +79,12 @@ module Portage
78
79
  # the complexity budget.
79
80
  def nothing_to_go_on?(candidates, sourced) = candidates.empty? && sourced.empty?
80
81
 
82
+ # A short opaque id `portage buy --offer` resolves from history, so a
83
+ # later step can point at one offer without re-sending its store,
84
+ # product id and query. Added last, after ranking, so it never
85
+ # influences the order.
86
+ def with_offer_ref(offer) = { offer_ref: "of_#{SecureRandom.hex(3)}" }.merge(offer)
87
+
81
88
  # --- Step 1: ask the backends who might sell this ---
82
89
 
83
90
  def candidate_origins
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "fileutils"
3
+ require "securerandom"
3
4
 
4
5
  module Portage
5
6
  module Cli
@@ -40,11 +41,43 @@ module Portage
40
41
 
41
42
  # @param url [String, nil] the store, for a `portage buy` that never
42
43
  # reached a checkout there; nil for a cross-store `portage find`.
43
- def record_search(query:, offer_count:, message:, url: nil)
44
- append("searches", { "query" => query, "url" => url, "offer_count" => offer_count, "message" => message,
44
+ # @param offers [Array<Hash>] a `find`'s (or `compare`'s) offers, kept
45
+ # so `portage buy --offer REF` can resolve an `offer_ref` later (see
46
+ # #offer). A search that keeps offers also gets a `search_id`, which
47
+ # `portage pick --search` names it by (docs/plans/
48
+ # human-pick-and-approve.md Phase 2).
49
+ # @return [Hash] the saved entry.
50
+ def record_search(query:, offer_count:, message:, url: nil, offers: [])
51
+ kept = offers.empty? ? nil : saved_offers(offers)
52
+ append("searches", { "search_id" => kept && "se_#{SecureRandom.hex(4)}", "query" => query, "url" => url,
53
+ "offer_count" => offer_count, "message" => message, "offers" => kept,
45
54
  "at" => @now }.compact)
46
55
  end
47
56
 
57
+ # @param id [String, nil] a `search_id`, or nil/"LAST" for the most
58
+ # recent search that kept offers.
59
+ # @return [Hash, nil]
60
+ def search(id = nil)
61
+ with_offers = store["searches"].select { |search| Array(search["offers"]).any? }
62
+ return with_offers.last if id.nil? || id.casecmp?("last")
63
+
64
+ with_offers.reverse.find { |search| search["search_id"] == id }
65
+ end
66
+
67
+ # @return [Hash, nil] the saved offer for `ref` (store, product_id,
68
+ # title, amount, currency, url, checkout, found_at) plus the catalog
69
+ # `query` to buy it with — the offer's own when it was saved with
70
+ # one (a compare result, searched by the compared product's title),
71
+ # else that of the search that found it — from the most recent
72
+ # search that holds it.
73
+ def offer(ref)
74
+ store["searches"].reverse_each do |search|
75
+ found = Array(search["offers"]).find { |saved| saved["offer_ref"] == ref }
76
+ return { "query" => search["query"] }.merge(found) if found
77
+ end
78
+ nil
79
+ end
80
+
48
81
  def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
49
82
 
50
83
  def searches(limit: MAX_ENTRIES) = store["searches"].last(limit)
@@ -58,6 +91,18 @@ module Portage
58
91
 
59
92
  private
60
93
 
94
+ # Takes a report's symbol-keyed offers or already-saved string-keyed
95
+ # ones (`pick` re-saves the offer it compared alongside the results).
96
+ def saved_offers(offers)
97
+ offers.map do |offer|
98
+ offer = offer.transform_keys(&:to_s)
99
+ saved = { "offer_ref" => offer["offer_ref"], "store" => offer["store"], "product_id" => offer["product_id"],
100
+ "title" => offer["title"], "amount" => offer["amount"], "currency" => offer["currency"],
101
+ "url" => offer["url"], "checkout" => offer["checkout"], "found_at" => offer["found_at"] || @now }
102
+ offer["query"] ? saved.merge("query" => offer["query"]) : saved
103
+ end
104
+ end
105
+
61
106
  def append(kind, entry)
62
107
  store[kind] = (store[kind] + [entry]).last(MAX_ENTRIES)
63
108
  write