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.
@@ -0,0 +1,65 @@
1
+ require_relative "notifier"
2
+ require_relative "macos_notifier"
3
+ require_relative "reconcile_notify"
4
+
5
+ module Portage
6
+ module Cli
7
+ # docs/plans/handoff-reconcile.md Phase 3 — fans a settle event
8
+ # (`checkout_reconciled`, from HandoffReconciler#notify) out to whichever
9
+ # channels `ReconcileNotify` names. The default `HandoffReconciler`
10
+ # notifier for both `portage buy --wait` and `portage orders reconcile`,
11
+ # replacing the plain webhook-only `Notifier` those used through Phase 1.
12
+ #
13
+ # Every channel is best-effort, same posture as `Notifier#call` itself —
14
+ # one channel failing (a bad webhook URL, no `osascript` on this host)
15
+ # never stops another from firing, and this never raises out of
16
+ # `HandoffReconciler#notify`, which already wraps the whole call in its
17
+ # own rescue.
18
+ class ReconcileNotifier
19
+ def initialize(channels: ReconcileNotify.resolve, webhook: Notifier.new, macos: MacosNotifier.new)
20
+ @channels = channels
21
+ @webhook = webhook
22
+ @macos = macos
23
+ end
24
+
25
+ def enabled?(channel) = @channels.include?(channel)
26
+
27
+ # @return [String, nil] the webhook channel's own failure message, same
28
+ # shape `Notifier#call` always returned — the other channels have no
29
+ # return-value contract to preserve, since nothing read theirs before
30
+ # this class existed.
31
+ def call(payload)
32
+ error = @webhook.call(payload) if enabled?("webhook")
33
+ notify_macos(payload) if enabled?("macos")
34
+ puts terminal_line(payload) if enabled?("terminal")
35
+ error
36
+ end
37
+
38
+ private
39
+
40
+ def notify_macos(payload)
41
+ @macos.call(title: "Portage checkout #{payload[:result]}", message: macos_message(payload))
42
+ end
43
+
44
+ def macos_message(payload)
45
+ parts = [payload[:shop], format_amount(payload[:amount], payload[:currency])].compact
46
+ parts.empty? ? "checkout #{payload[:checkout_id]}" : parts.join(" — ")
47
+ end
48
+
49
+ def terminal_line(payload)
50
+ parts = ["[reconcile] #{payload[:checkout_id]}: #{payload[:result]}"]
51
+ parts << "resolution: #{payload[:resolution]}" if payload[:resolution]
52
+ parts << "order: #{payload[:order_id]}" if payload[:order_id]
53
+ amount = format_amount(payload[:amount], payload[:currency])
54
+ parts << amount if amount
55
+ parts.join(" — ")
56
+ end
57
+
58
+ def format_amount(amount, currency)
59
+ return nil unless amount
60
+
61
+ "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 3 — which channels fire when a
7
+ # handoff settles, from `portage buy --wait` or `portage orders
8
+ # reconcile` (see ReconcileNotifier). A comma list, same Setting
9
+ # precedence as every other standing preference here: an explicit
10
+ # `override:` beats PORTAGE_RECONCILE_NOTIFY, which beats config.json's
11
+ # "reconcile_notify". Unset is `webhook` only.
12
+ #
13
+ # "journal" is accepted but drives nothing here: the Phase 1 order
14
+ # snapshot journal write already happens unconditionally in
15
+ # HandoffReconciler#record_journal whenever a completed checkout carries
16
+ # an order. Naming it in `reconcile_notify` documents that as always-on
17
+ # rather than gating a second write path.
18
+ module ReconcileNotify
19
+ ENV_VAR = "PORTAGE_RECONCILE_NOTIFY".freeze
20
+ CONFIG_KEY = "reconcile_notify".freeze
21
+ CHANNELS = %w[webhook journal macos terminal].freeze
22
+ DEFAULT = %w[webhook].freeze
23
+
24
+ module_function
25
+
26
+ # @param extra [Array<String>] channels to force on regardless of
27
+ # configuration — `portage buy --wait` forces `terminal` in plain
28
+ # (non-`--json`) mode, so the shopper sees a line when it settles
29
+ # even with nothing configured.
30
+ # @return [Array<String>]
31
+ def resolve(override: nil, config: Config.load, extra: [])
32
+ raw = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
33
+ configured = raw.nil? ? DEFAULT : raw.to_s.split(",").map(&:strip)
34
+ ((configured & CHANNELS) + extra).uniq
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,6 +2,9 @@ require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
4
  require "yaml"
5
+ require "portage/ucp"
6
+ require "portage/ucp/support/connection"
7
+ require_relative "user_agent"
5
8
 
6
9
  module Portage
7
10
  module Cli
@@ -18,7 +21,6 @@ module Portage
18
21
  module SearchBackends
19
22
  OPEN_TIMEOUT = 5
20
23
  READ_TIMEOUT = 5
21
- USER_AGENT = "portage-find".freeze
22
24
 
23
25
  # Reference works and marketplaces-of-links that a search backend will
24
26
  # happily return for a product query but that are never themselves a UCP
@@ -45,9 +47,9 @@ module Portage
45
47
  end
46
48
 
47
49
  def self.request(uri, headers)
48
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
49
- open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
50
- http.get(uri.request_uri, { "User-Agent" => USER_AGENT }.merge(headers))
50
+ Portage::Ucp::Support::Connection.start(uri, route: :search, open_timeout: OPEN_TIMEOUT,
51
+ read_timeout: READ_TIMEOUT) do |http|
52
+ http.get(uri.request_uri, UserAgent.headers.merge(headers))
51
53
  end
52
54
  end
53
55
  private_class_method :request
@@ -0,0 +1,39 @@
1
+ require "portage/ucp/client"
2
+ require_relative "version"
3
+ require_relative "config"
4
+ require_relative "setting"
5
+
6
+ module Portage
7
+ module Cli
8
+ # Every request this CLI makes — to a store, a search backend, a
9
+ # payment handler, or a notify webhook — names itself, then the client
10
+ # gem it speaks UCP through, so a merchant reading its logs sees
11
+ # "portage-cli/... portage-ucp-client/... (+https://github.com/...)"
12
+ # rather than Ruby's default and knows where to look.
13
+ #
14
+ # A caller running many agents behind one IP (or one that just wants
15
+ # its own contact info in the string a merchant might grep for) can
16
+ # override it — same Setting precedence as Notifier's webhook URL:
17
+ # PORTAGE_USER_AGENT beats ~/.portage/config.json's "user_agent" key,
18
+ # both of which beat this default. Resolved fresh on every call rather
19
+ # than frozen at load time, so a config.json edit or an ENV change
20
+ # takes effect without restarting whatever's driving this CLI.
21
+ module UserAgent
22
+ DEFAULT = "portage-cli/#{VERSION} #{Portage::Ucp::Client::USER_AGENT}".freeze
23
+ ENV_VAR = "PORTAGE_USER_AGENT".freeze
24
+ CONFIG_KEY = "user_agent".freeze
25
+
26
+ module_function
27
+
28
+ # @param config [Config]
29
+ # @return [String] never blank — falls back to DEFAULT.
30
+ def value(config: Config.load)
31
+ Setting.resolve(env: ENV_VAR, config: config, config_key: CONFIG_KEY) || DEFAULT
32
+ end
33
+
34
+ # @param config [Config]
35
+ # @return [Hash] a fresh header hash, safe for a caller to #merge into.
36
+ def headers(config: Config.load) = { "User-Agent" => value(config: config) }
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.7.0".freeze
3
+ VERSION = "0.7.3".freeze
4
4
  end
5
5
  end
@@ -0,0 +1,25 @@
1
+ module Portage
2
+ module Cli
3
+ # docs/plans/handoff-reconcile.md Phase 4 — `portage-ucp-webmcp` is
4
+ # optional at runtime, not in portage-cli's gemspec, same posture as
5
+ # `Decisions.available?` for `portage-ucp-decision` and
6
+ # `Resolver.build_adapter` for a platform adapter gem: `require`, then
7
+ # rescue LoadError, so `gem install portage-cli` stays light and only a
8
+ # caller who actually passes `Buy.new(webmcp_bridge:)` ever needs it
9
+ # installed.
10
+ module Webmcp
11
+ # @return [Boolean] whether portage-ucp-webmcp could be loaded.
12
+ # Memoized: `require` runs once per process.
13
+ def self.available?
14
+ return @available unless @available.nil?
15
+
16
+ @available = begin
17
+ require "portage/ucp/webmcp"
18
+ true
19
+ rescue LoadError
20
+ false
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/handoff-reconcile.md Phase 4 — how `Buy`'s optional WebMCP
7
+ # path (see Webmcp, Buy#webmcp_flow) finishes a checkout it built in the
8
+ # browser. Same Setting precedence as HandoffSpendMode: no CLI flag (a
9
+ # WebMCP bridge is an injected collaborator, not something a shell
10
+ # invocation can name), so just PORTAGE_WEBMCP_CHECKOUT_MODE >
11
+ # config.json's "webmcp_checkout_mode". Unset, or anything not in
12
+ # MODES, is `express_stop`, the only mode actually implemented — see
13
+ # Buy#webmcp_token_unsupported_report for why `token` isn't yet.
14
+ module WebmcpCheckoutMode
15
+ ENV_VAR = "PORTAGE_WEBMCP_CHECKOUT_MODE".freeze
16
+ CONFIG_KEY = "webmcp_checkout_mode".freeze
17
+ MODES = %w[express_stop token].freeze
18
+ DEFAULT = "express_stop".freeze
19
+
20
+ module_function
21
+
22
+ def resolve(override: nil, config: Config.load)
23
+ mode = Setting.resolve(override: override, env: ENV_VAR, config: config, config_key: CONFIG_KEY)
24
+ MODES.include?(mode) ? mode : DEFAULT
25
+ end
26
+ end
27
+ end
28
+ end
data/lib/portage/cli.rb CHANGED
@@ -2,6 +2,7 @@ require "optparse"
2
2
  require "json"
3
3
 
4
4
  require_relative "cli/version"
5
+ require_relative "cli/user_agent"
5
6
  require_relative "cli/shipping_profile"
6
7
  require_relative "cli/buyer_context"
7
8
  require_relative "cli/catalog_products"
@@ -10,7 +11,12 @@ require_relative "cli/find"
10
11
  require_relative "cli/compare"
11
12
  require_relative "cli/history"
12
13
  require_relative "cli/payment_methods"
14
+ require_relative "cli/proxy_settings"
13
15
  require_relative "cli/doctor"
16
+ require_relative "cli/handoff_reconciler"
17
+ require_relative "cli/handoff_waiter"
18
+ require_relative "cli/reconcile_notifier"
19
+ require_relative "cli/reconcile_notify"
14
20
  require_relative "cli/generate/adapter"
15
21
  require_relative "cli/generate/agent_profile"
16
22
 
@@ -25,6 +31,7 @@ module Portage
25
31
  [--product-id ID] [--yes] [--dry-run]
26
32
  [--auto-open|--no-auto-open] [--notify-webhook URL]
27
33
  [--decision-backend jev|laya] [--min-confidence N] [--json]
34
+ [--wait [--wait-timeout DURATION|off]]
28
35
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
29
36
  portage find --query "..." [--max-price N] [--limit N] [--json]
30
37
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -43,28 +50,66 @@ module Portage
43
50
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
44
51
  [--velocity-count N --velocity-window-seconds N]
45
52
  [--allow HOST ...] [--clear-allowlist]
53
+ portage orders reconcile [--checkout ID] [--json]
46
54
  portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
47
55
  portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
48
56
  portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
49
57
  portage generate adapter NAME [--dir DIR]
50
58
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
59
+ portage --version
60
+
61
+ proxy flags (buy/find/compare/doctor/payment enroll):
62
+ [--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
63
+ [--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
64
+ [--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
51
65
  USAGE
52
66
 
53
67
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
54
68
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
55
- "doctor" => :run_doctor, "configure" => :run_doctor, "setup" => :run_doctor,
56
- "generate" => :run_generate }.freeze
69
+ "orders" => :run_orders, "doctor" => :run_doctor, "configure" => :run_doctor,
70
+ "setup" => :run_doctor, "generate" => :run_generate }.freeze
71
+
72
+ VERSION_FLAGS = %w[--version -v version].freeze
57
73
 
58
74
  # @param argv [Array<String>]
59
75
  # @return [Integer] process exit code
60
76
  def self.run(argv)
61
77
  command, *rest = argv
78
+ return run_version if VERSION_FLAGS.include?(command)
62
79
  return send(COMMANDS[command], rest) if COMMANDS.key?(command)
63
80
 
64
81
  warn USAGE
65
82
  1
66
83
  end
67
84
 
85
+ # So a packaged install (Homebrew's `test do` block, `portage doctor`)
86
+ # can confirm which release it's running without hitting the network.
87
+ def self.run_version
88
+ puts VERSION
89
+ 0
90
+ end
91
+ private_class_method :run_version
92
+
93
+ # --- proxy (docs/plans/proxy-support.md Phase 2) ---
94
+
95
+ # Resolves this command's `--proxy*` flags/env/config.json into a
96
+ # ProxyConfig and installs it as the process-wide default every
97
+ # Support::Connection.start call (buy/find/compare/payment/doctor alike)
98
+ # already reads unless it's handed its own `proxy:` — see ProxySettings'
99
+ # own comment for why. A bad flag or a malformed/protected-header
100
+ # config.json surfaces here as a clean message, never a raw core
101
+ # exception.
102
+ # @return [ProxySettings, nil] nil on a config error (already reported).
103
+ def self.apply_proxy_settings(proxy_flags)
104
+ settings = ProxySettings.new(flags: proxy_flags || {})
105
+ Portage::Ucp::Support::ProxyConfig.current = settings.resolve
106
+ settings
107
+ rescue ProxySettings::ConfigError => e
108
+ warn "portage: #{e.message}"
109
+ nil
110
+ end
111
+ private_class_method :apply_proxy_settings
112
+
68
113
  # --- find ---
69
114
 
70
115
  def self.run_find(argv)
@@ -72,6 +117,8 @@ module Portage
72
117
  return 1 unless options
73
118
 
74
119
  json = options.delete(:json)
120
+ return 1 unless apply_proxy_settings(options.delete(:proxy))
121
+
75
122
  report = Find.new(**options).call
76
123
  record_find(report)
77
124
  puts json ? JSON.pretty_generate(report) : format_find(report)
@@ -98,11 +145,13 @@ module Portage
98
145
  private_class_method :parse_find_options
99
146
 
100
147
  def self.find_option_parser(opts)
148
+ opts[:proxy] = {}
101
149
  OptionParser.new do |parser|
102
150
  parser.on("--query QUERY") { |v| opts[:query] = v }
103
151
  parser.on("--limit N", Integer) { |v| opts[:limit] = v }
104
152
  parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
105
153
  parser.on("--json") { opts[:json] = true }
154
+ ProxySettings.add_options(parser, opts[:proxy])
106
155
  end
107
156
  end
108
157
  private_class_method :find_option_parser
@@ -121,6 +170,8 @@ module Portage
121
170
 
122
171
  json = options.delete(:json)
123
172
  url = options.delete(:url)
173
+ return 1 unless apply_proxy_settings(options.delete(:proxy))
174
+
124
175
  report = Compare.new(origin_url: url, **options).call
125
176
  # Recorded as a search, not a purchase — compare never checks out. The
126
177
  # query string names the compare so `portage history list` doesn't
@@ -147,12 +198,14 @@ module Portage
147
198
  private_class_method :parse_compare_options
148
199
 
149
200
  def self.compare_option_parser(opts)
201
+ opts[:proxy] = {}
150
202
  OptionParser.new do |parser|
151
203
  parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
152
204
  parser.on("--id VALUE") { |v| opts[:identity] << v }
153
205
  parser.on("--results N", Integer) { |v| opts[:results] = v }
154
206
  parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
155
207
  parser.on("--json") { opts[:json] = true }
208
+ ProxySettings.add_options(parser, opts[:proxy])
156
209
  end
157
210
  end
158
211
  private_class_method :compare_option_parser
@@ -162,6 +215,7 @@ module Portage
162
215
  def self.run_buy(argv)
163
216
  parsed = parse_buy_options(argv)
164
217
  return 1 unless parsed
218
+ return 1 unless apply_proxy_settings(parsed[:proxy])
165
219
 
166
220
  url = parsed[:buy][:url] || parsed[:store]
167
221
  parsed[:confidence_check] = confidence_check(parsed, url)
@@ -210,11 +264,88 @@ module Portage
210
264
  options[:product_id] ||= product_id
211
265
  report = Buy.new(**options).call
212
266
  record_buy(report, options[:query])
213
- puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
267
+ result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
268
+ print_buy_report(report, result, parsed[:json])
214
269
  report[:checkout] || report[:browse] ? 0 : 1
215
270
  end
216
271
  private_class_method :execute_buy
217
272
 
273
+ # docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`. A
274
+ # no-op (returns nil) whenever there's nothing to wait on: --dry-run
275
+ # never hands off at all, and a completed/browse-only/dead-end report
276
+ # has no pending shopper record either. Otherwise polls
277
+ # `HandoffReconciler` through `HandoffWaiter` until it settles or its
278
+ # deadline passes.
279
+ #
280
+ # Under --json, every event streams to stdout as it happens (NDJSON) —
281
+ # the initial hand-off, then a line per checkout-status change, then the
282
+ # settle, so a calling agent reading stdout live never has to guess
283
+ # whether it's still waiting. Under plain output, nothing streams here:
284
+ # the forced "terminal" notify channel (see #wait_notifier) prints its
285
+ # own line when it settles, and the final report follows exactly as it
286
+ # does without --wait.
287
+ def self.wait_for_handoff(report, parsed)
288
+ return nil unless report[:handoff] && report[:checkout_id]
289
+
290
+ transaction_log = Portage::Ucp::Support::TransactionLog.new
291
+ record = pending_shopper_record(report[:checkout_id], transaction_log)
292
+ return nil unless record
293
+
294
+ json = parsed[:json]
295
+ emit_ndjson(handoff_event(report)) if json
296
+ reconciler = HandoffReconciler.new(transaction_log: transaction_log, notifier: wait_notifier(json))
297
+ waiter = HandoffWaiter.new(reconciler: reconciler, transaction_log: transaction_log,
298
+ wait_timeout_override: parsed[:wait_timeout])
299
+ waiter.call(record) { |event, result| emit_wait_event(event, result, json) }
300
+ end
301
+ private_class_method :wait_for_handoff
302
+
303
+ def self.pending_shopper_record(checkout_id, transaction_log)
304
+ transaction_log.each_record.find { |r| r["checkout_id"] == checkout_id && r["settled_by"] == "shopper" }
305
+ end
306
+ private_class_method :pending_shopper_record
307
+
308
+ # `terminal` is forced on for a plain-text wait (see ReconcileNotify) so
309
+ # the shopper sees a line the moment it settles even with nothing
310
+ # configured. Never forced under --json: that channel prints plain text,
311
+ # which would corrupt the NDJSON stream this method's caller is also
312
+ # writing to the same stdout.
313
+ def self.wait_notifier(json)
314
+ ReconcileNotifier.new(channels: ReconcileNotify.resolve(extra: json ? [] : ["terminal"]))
315
+ end
316
+ private_class_method :wait_notifier
317
+
318
+ def self.handoff_event(report)
319
+ { event: "handoff", checkout_id: report[:checkout_id], checkout_url: report[:checkout_url],
320
+ reason: report[:outcome] }
321
+ end
322
+ private_class_method :handoff_event
323
+
324
+ # Only `:settled` ever reaches stdout as `handoff_settled` — a timeout or
325
+ # an interrupted wait leaves `result.settled` false, and that's already
326
+ # visible in the final report object this event stream ends with, so a
327
+ # second, redundant "gave up" event isn't needed.
328
+ def self.emit_wait_event(event, result, json)
329
+ return unless json
330
+
331
+ case event
332
+ when :status then emit_ndjson({ event: "handoff_status", status: result.checkout_status })
333
+ when :settled
334
+ emit_ndjson({ event: "handoff_settled", result: result.status, resolution: result.resolution,
335
+ order_id: result.order_id, amount: result.amount, currency: result.currency }.compact)
336
+ end
337
+ end
338
+ private_class_method :emit_wait_event
339
+
340
+ def self.emit_ndjson(payload) = puts JSON.generate(payload)
341
+ private_class_method :emit_ndjson
342
+
343
+ def self.print_buy_report(report, result, json)
344
+ report = report.merge(reconcile: result.to_h) if result
345
+ puts json ? JSON.pretty_generate(report) : format_report(report)
346
+ end
347
+ private_class_method :print_buy_report
348
+
218
349
  # Built before the buy starts (and before the search, when there's no
219
350
  # URL), so a bad --min-confidence, or a bad PORTAGE_MIN_CONFIDENCE with
220
351
  # a backend enabled, stops the run up front rather than after a
@@ -276,7 +407,10 @@ module Portage
276
407
  buy = { url: url, qty: 1, yes: false, dry_run: false }
277
408
  parsed = { buy: buy, find: {}, confidence: {} }
278
409
  json = argv.include?("--json")
279
- buy_option_parser(buy, parsed).parse!(argv)
410
+ parsed[:proxy] = {}
411
+ parser = buy_option_parser(buy, parsed)
412
+ ProxySettings.add_options(parser, parsed[:proxy])
413
+ parser.parse!(argv)
280
414
  buy[:query] ||= ""
281
415
  return parsed if url || !buy[:query].strip.empty?
282
416
 
@@ -294,9 +428,9 @@ module Portage
294
428
  parser.on("--product-id ID") { |v| buy[:product_id] = v }
295
429
  parser.on("--yes") { buy[:yes] = true }
296
430
  parser.on("--dry-run") { buy[:dry_run] = true }
297
- parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
298
- parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
299
431
  parser.on("--json") { parsed[:json] = true }
432
+ add_handoff_options(parser, buy)
433
+ add_wait_options(parser, parsed)
300
434
  add_search_options(parser, buy, parsed)
301
435
  add_confidence_options(parser, parsed[:confidence])
302
436
  end
@@ -311,6 +445,22 @@ module Portage
311
445
  end
312
446
  private_class_method :add_confidence_options
313
447
 
448
+ def self.add_handoff_options(parser, buy)
449
+ parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
450
+ parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
451
+ end
452
+ private_class_method :add_handoff_options
453
+
454
+ # docs/plans/handoff-reconcile.md Phase 3 — `--wait`/`--wait-timeout`
455
+ # land on `parsed`, never on `buy`: they're consumed by
456
+ # `#wait_for_handoff` after `Buy#call` returns, and `Buy.new` has no
457
+ # `wait:`/`wait_timeout:` keyword to accidentally receive them.
458
+ def self.add_wait_options(parser, parsed)
459
+ parser.on("--wait") { parsed[:wait] = true }
460
+ parser.on("--wait-timeout DURATION") { |v| parsed[:wait_timeout] = v }
461
+ end
462
+ private_class_method :add_wait_options
463
+
314
464
  # `--query` feeds both halves: it's the store search when there's no URL
315
465
  # and the catalog search once a store is settled, so it's registered once
316
466
  # here rather than twice on the same parser.
@@ -318,7 +468,7 @@ module Portage
318
468
  parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
319
469
  parser.on("--store URL") { |v| parsed[:store] = v }
320
470
  parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
321
- parser.on("--max-price N", Float) { |v| parsed[:find][:max_price] = to_minor_units(v) }
471
+ parser.on("--max-price N", Float) { |v| parsed[:find][:max_price] = buy[:max_price] = to_minor_units(v) }
322
472
  end
323
473
  private_class_method :add_search_options
324
474
 
@@ -455,13 +605,14 @@ module Portage
455
605
  private_class_method :run_payment_mutate
456
606
 
457
607
  def self.parse_payment_enroll_options(argv)
458
- opts = { scope_merchants: [] }
608
+ opts = { scope_merchants: [], proxy: {} }
459
609
  OptionParser.new do |parser|
460
610
  parser.on("--label NAME") { |v| opts[:label] = v }
461
611
  parser.on("--json") { opts[:json] = true }
462
612
  parser.on("--scope-merchant HOST") { |v| opts[:scope_merchants] << v }
463
613
  parser.on("--scope-max-amount N", Integer) { |v| opts[:scope_max_amount] = v }
464
614
  parser.on("--scope-currency CUR") { |v| opts[:scope_currency] = v }
615
+ ProxySettings.add_options(parser, opts[:proxy])
465
616
  end.parse!(argv)
466
617
  opts[:url] = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
467
618
  opts
@@ -486,6 +637,7 @@ module Portage
486
637
  warn USAGE
487
638
  return 1
488
639
  end
640
+ return 1 unless apply_proxy_settings(opts[:proxy])
489
641
 
490
642
  result = PaymentMethods.new.enroll(opts[:url], label: opts[:label],
491
643
  scope: payment_enroll_scope(opts)) do |setup_url|
@@ -610,14 +762,86 @@ module Portage
610
762
  end
611
763
  private_class_method :format_payment_enroll
612
764
 
765
+ # --- orders ---
766
+
767
+ # docs/plans/handoff-reconcile.md Phase 1 — resolves every pending
768
+ # `settled_by: "shopper"` TransactionLog record (or just the one named
769
+ # by `--checkout`) by re-fetching that checkout from the store. Safe to
770
+ # run from cron/launchd: a record that's already terminal, or that
771
+ # doesn't belong to this run (a `--checkout` for a different id, or
772
+ # `settled_by: nil` dispatcher crash-evidence), is a no-op result, not
773
+ # an error, so a scheduled run never needs its own filtering logic.
774
+ def self.run_orders(argv)
775
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
776
+ return run_orders_reconcile(argv) if sub == "reconcile"
777
+
778
+ warn USAGE
779
+ 1
780
+ end
781
+ private_class_method :run_orders
782
+
783
+ def self.run_orders_reconcile(argv)
784
+ opts = {}
785
+ OptionParser.new do |parser|
786
+ parser.on("--checkout ID") { |v| opts[:checkout] = v }
787
+ parser.on("--json") { opts[:json] = true }
788
+ end.parse!(argv)
789
+
790
+ transaction_log = Portage::Ucp::Support::TransactionLog.new
791
+ reconciler = HandoffReconciler.new(transaction_log: transaction_log)
792
+ results = reconcile_records(opts[:checkout], transaction_log, reconciler)
793
+
794
+ puts opts[:json] ? JSON.pretty_generate(results.map(&:to_h)) : format_reconcile(results)
795
+ 0
796
+ end
797
+ private_class_method :run_orders_reconcile
798
+
799
+ def self.reconcile_records(checkout_id, transaction_log, reconciler)
800
+ return reconcile_one_checkout(checkout_id, transaction_log, reconciler) if checkout_id
801
+
802
+ HandoffReconciler.each_pending_shopper_record(transaction_log).map { |record| reconciler.call(record) }
803
+ end
804
+ private_class_method :reconcile_records
805
+
806
+ # `--checkout ID` reconciles the one named record whatever its
807
+ # `settled_by`/status — #call itself still refuses to settle anything
808
+ # that isn't a pending shopper record, this just skips the "iterate
809
+ # every pending record" step when the caller already knows which one.
810
+ def self.reconcile_one_checkout(checkout_id, transaction_log, reconciler)
811
+ key = transaction_log.each_record.find { |r| r["checkout_id"] == checkout_id }&.fetch("idempotency_key", nil)
812
+ return [] unless key
813
+
814
+ [reconciler.call(transaction_log.find(key))]
815
+ end
816
+ private_class_method :reconcile_one_checkout
817
+
818
+ def self.format_reconcile(results)
819
+ return "(nothing to reconcile)" if results.empty?
820
+
821
+ results.map { |r| format_reconcile_result(r) }.join("\n")
822
+ end
823
+ private_class_method :format_reconcile
824
+
825
+ def self.format_reconcile_result(result)
826
+ return "#{result.idempotency_key}: #{result.note}" unless result.settled
827
+
828
+ parts = ["#{result.idempotency_key}: #{result.status}"]
829
+ parts << "resolution: #{result.resolution}" if result.resolution
830
+ parts << "order: #{result.order_id}" if result.order_id
831
+ parts << format_amount(result.amount, result.currency) if result.amount
832
+ parts.join(" — ")
833
+ end
834
+ private_class_method :format_reconcile_result
835
+
613
836
  # --- doctor ---
614
837
 
615
838
  def self.parse_doctor_options(argv)
616
- opts = {}
839
+ opts = { proxy: {} }
617
840
  OptionParser.new do |parser|
618
841
  parser.on("--require FILE") { |v| opts[:require] = v }
619
842
  parser.on("--adapter CLASS_NAME") { |v| opts[:adapter] = v }
620
843
  parser.on("--json") { opts[:json] = true }
844
+ ProxySettings.add_options(parser, opts[:proxy])
621
845
  end.parse!(argv)
622
846
  opts
623
847
  end
@@ -627,8 +851,10 @@ module Portage
627
851
  opts = parse_doctor_options(argv)
628
852
  require File.expand_path(opts[:require]) if opts[:require]
629
853
  adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
854
+ proxy_settings = apply_proxy_settings(opts[:proxy])
855
+ return 1 unless proxy_settings
630
856
 
631
- findings = Doctor.new(adapter_class: adapter_class).call
857
+ findings = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call
632
858
  puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
633
859
  findings.empty? ? 0 : 1
634
860
  end