portage-cli 0.6.4 → 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.
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
 
@@ -23,7 +29,9 @@ module Portage
23
29
  USAGE = <<~USAGE.freeze
24
30
  usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
25
31
  [--product-id ID] [--yes] [--dry-run]
26
- [--auto-open|--no-auto-open] [--notify-webhook URL] [--json]
32
+ [--auto-open|--no-auto-open] [--notify-webhook URL]
33
+ [--decision-backend jev|laya] [--min-confidence N] [--json]
34
+ [--wait [--wait-timeout DURATION|off]]
27
35
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
28
36
  portage find --query "..." [--max-price N] [--limit N] [--json]
29
37
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -42,25 +50,66 @@ module Portage
42
50
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
43
51
  [--velocity-count N --velocity-window-seconds N]
44
52
  [--allow HOST ...] [--clear-allowlist]
53
+ portage orders reconcile [--checkout ID] [--json]
45
54
  portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
55
+ portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
56
+ portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
46
57
  portage generate adapter NAME [--dir DIR]
47
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]
48
65
  USAGE
49
66
 
50
67
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
51
68
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
52
- "doctor" => :run_doctor, "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
53
73
 
54
74
  # @param argv [Array<String>]
55
75
  # @return [Integer] process exit code
56
76
  def self.run(argv)
57
77
  command, *rest = argv
78
+ return run_version if VERSION_FLAGS.include?(command)
58
79
  return send(COMMANDS[command], rest) if COMMANDS.key?(command)
59
80
 
60
81
  warn USAGE
61
82
  1
62
83
  end
63
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
+
64
113
  # --- find ---
65
114
 
66
115
  def self.run_find(argv)
@@ -68,14 +117,21 @@ module Portage
68
117
  return 1 unless options
69
118
 
70
119
  json = options.delete(:json)
120
+ return 1 unless apply_proxy_settings(options.delete(:proxy))
121
+
71
122
  report = Find.new(**options).call
72
- History.new.record_search(query: report[:query], offer_count: report[:offers].length,
73
- message: report[:message])
123
+ record_find(report)
74
124
  puts json ? JSON.pretty_generate(report) : format_find(report)
75
125
  report[:offers].any? ? 0 : 1
76
126
  end
77
127
  private_class_method :run_find
78
128
 
129
+ def self.record_find(report)
130
+ History.new.record_search(query: report[:query], offer_count: report[:offers].length,
131
+ message: report[:message])
132
+ end
133
+ private_class_method :record_find
134
+
79
135
  def self.parse_find_options(argv)
80
136
  opts = {}
81
137
  find_option_parser(opts).parse!(argv)
@@ -89,11 +145,13 @@ module Portage
89
145
  private_class_method :parse_find_options
90
146
 
91
147
  def self.find_option_parser(opts)
148
+ opts[:proxy] = {}
92
149
  OptionParser.new do |parser|
93
150
  parser.on("--query QUERY") { |v| opts[:query] = v }
94
151
  parser.on("--limit N", Integer) { |v| opts[:limit] = v }
95
152
  parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
96
153
  parser.on("--json") { opts[:json] = true }
154
+ ProxySettings.add_options(parser, opts[:proxy])
97
155
  end
98
156
  end
99
157
  private_class_method :find_option_parser
@@ -112,6 +170,8 @@ module Portage
112
170
 
113
171
  json = options.delete(:json)
114
172
  url = options.delete(:url)
173
+ return 1 unless apply_proxy_settings(options.delete(:proxy))
174
+
115
175
  report = Compare.new(origin_url: url, **options).call
116
176
  # Recorded as a search, not a purchase — compare never checks out. The
117
177
  # query string names the compare so `portage history list` doesn't
@@ -138,12 +198,14 @@ module Portage
138
198
  private_class_method :parse_compare_options
139
199
 
140
200
  def self.compare_option_parser(opts)
201
+ opts[:proxy] = {}
141
202
  OptionParser.new do |parser|
142
203
  parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
143
204
  parser.on("--id VALUE") { |v| opts[:identity] << v }
144
205
  parser.on("--results N", Integer) { |v| opts[:results] = v }
145
206
  parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
146
207
  parser.on("--json") { opts[:json] = true }
208
+ ProxySettings.add_options(parser, opts[:proxy])
147
209
  end
148
210
  end
149
211
  private_class_method :compare_option_parser
@@ -153,8 +215,11 @@ module Portage
153
215
  def self.run_buy(argv)
154
216
  parsed = parse_buy_options(argv)
155
217
  return 1 unless parsed
218
+ return 1 unless apply_proxy_settings(parsed[:proxy])
156
219
 
157
220
  url = parsed[:buy][:url] || parsed[:store]
221
+ parsed[:confidence_check] = confidence_check(parsed, url)
222
+ return 1 unless parsed[:confidence_check]
158
223
  return execute_buy(parsed, url) if url
159
224
 
160
225
  buy_from_search(parsed)
@@ -168,6 +233,7 @@ module Portage
168
233
  # interactive pick has to name it. Piped/CI runs list the offers and stop.
169
234
  def self.buy_from_search(parsed)
170
235
  report = Find.new(**parsed[:find]).call
236
+ record_find(report)
171
237
  offer = pick_offer(report, parsed[:json])
172
238
  return report[:offers].any? ? 0 : 1 unless offer
173
239
 
@@ -194,36 +260,164 @@ module Portage
194
260
  private_class_method :prompt_for_offer
195
261
 
196
262
  def self.execute_buy(parsed, url, product_id: nil)
197
- options = parsed[:buy].merge(url: url)
263
+ options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check])
198
264
  options[:product_id] ||= product_id
199
265
  report = Buy.new(**options).call
200
- record_purchase(report, options[:query]) if report[:checkout]
201
- puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
266
+ record_buy(report, options[:query])
267
+ result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
268
+ print_buy_report(report, result, parsed[:json])
202
269
  report[:checkout] || report[:browse] ? 0 : 1
203
270
  end
204
271
  private_class_method :execute_buy
205
272
 
206
- # Only checkout attempts land here — a browse-only report never reached a
207
- # checkout, so it belongs to search history, not purchase history.
208
- def self.record_purchase(report, query)
209
- History.new.record_purchase(
210
- url: report[:url], query: query, checkout: report[:checkout],
211
- checkout_status: report[:checkout_status], message: report[:message],
212
- products: report[:products].map { |p| product_line(p) }
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
+
349
+ # Built before the buy starts (and before the search, when there's no
350
+ # URL), so a bad --min-confidence, or a bad PORTAGE_MIN_CONFIDENCE with
351
+ # a backend enabled, stops the run up front rather than after a
352
+ # checkout already exists.
353
+ def self.confidence_check(parsed, url)
354
+ ConfidenceCheck.new(**parsed[:confidence])
355
+ rescue ArgumentError => e
356
+ invalid_buy_option(e.message, url: url, json: parsed[:json])
357
+ end
358
+ private_class_method :confidence_check
359
+
360
+ # A buy refused before it started. Under --json that's a report like
361
+ # any other, with outcome `invalid_option`, so an agent loop reading
362
+ # stdout gets JSON rather than nothing and a line on stderr.
363
+ # @return [nil]
364
+ def self.invalid_buy_option(message, url:, json:)
365
+ unless json
366
+ warn message
367
+ return nil
368
+ end
369
+
370
+ puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
371
+ outcome: "invalid_option", browse: false, checkout: false, message: message)
372
+ nil
373
+ end
374
+ private_class_method :invalid_buy_option
375
+
376
+ # A buy that created a checkout is a purchase entry, whatever its
377
+ # outcome. One that never got that far (no match, browse-only, dead end,
378
+ # a store or adapter error) is a search at that store, so "what did I
379
+ # already buy" never lists a checkout that doesn't exist.
380
+ def self.record_buy(report, query)
381
+ history = History.new
382
+ unless report[:checkout_id]
383
+ return history.record_search(query: query, url: report[:url], offer_count: report[:products].length,
384
+ message: report[:message])
385
+ end
386
+
387
+ history.record_purchase(
388
+ url: report[:url], query: query, outcome: report[:outcome], source: report[:source],
389
+ checkout_id: report[:checkout_id], checkout_status: report[:checkout_status],
390
+ checkout_url: report[:checkout_url], total: report_total(report), currency: report[:currency],
391
+ items: Array(report[:items]).map { |item| item.transform_keys(&:to_s) }, message: report[:message]
213
392
  )
214
393
  end
215
- private_class_method :record_purchase
394
+ private_class_method :record_buy
216
395
 
396
+ def self.report_total(report)
397
+ Portage::Ucp::Support::Totals.amount(report[:totals])
398
+ end
399
+ private_class_method :report_total
400
+
401
+ # A flag OptionParser can't read (`--min-confidence high`, `--qty two`,
402
+ # an unknown flag) is refused the same way as an out-of-range
403
+ # threshold. `--json` is looked for up front, since parsing stops at
404
+ # the bad flag and may never reach it.
217
405
  def self.parse_buy_options(argv)
218
406
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
219
407
  buy = { url: url, qty: 1, yes: false, dry_run: false }
220
- parsed = { buy: buy, find: {} }
221
- buy_option_parser(buy, parsed).parse!(argv)
408
+ parsed = { buy: buy, find: {}, confidence: {} }
409
+ json = argv.include?("--json")
410
+ parsed[:proxy] = {}
411
+ parser = buy_option_parser(buy, parsed)
412
+ ProxySettings.add_options(parser, parsed[:proxy])
413
+ parser.parse!(argv)
222
414
  buy[:query] ||= ""
223
415
  return parsed if url || !buy[:query].strip.empty?
224
416
 
225
417
  warn USAGE
226
418
  nil
419
+ rescue OptionParser::ParseError => e
420
+ invalid_buy_option(e.message, url: url, json: json)
227
421
  end
228
422
  private_class_method :parse_buy_options
229
423
 
@@ -234,14 +428,39 @@ module Portage
234
428
  parser.on("--product-id ID") { |v| buy[:product_id] = v }
235
429
  parser.on("--yes") { buy[:yes] = true }
236
430
  parser.on("--dry-run") { buy[:dry_run] = true }
237
- parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
238
- parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
239
431
  parser.on("--json") { parsed[:json] = true }
432
+ add_handoff_options(parser, buy)
433
+ add_wait_options(parser, parsed)
240
434
  add_search_options(parser, buy, parsed)
435
+ add_confidence_options(parser, parsed[:confidence])
241
436
  end
242
437
  end
243
438
  private_class_method :buy_option_parser
244
439
 
440
+ # The opt-in confidence gate in front of a `--yes` completion (see
441
+ # ConfidenceCheck) — both default to their PORTAGE_* env vars.
442
+ def self.add_confidence_options(parser, confidence)
443
+ parser.on("--decision-backend NAME") { |v| confidence[:backend] = v }
444
+ parser.on("--min-confidence N", Float) { |v| confidence[:threshold] = v }
445
+ end
446
+ private_class_method :add_confidence_options
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
+
245
464
  # `--query` feeds both halves: it's the store search when there's no URL
246
465
  # and the catalog search once a store is settled, so it's registered once
247
466
  # here rather than twice on the same parser.
@@ -249,7 +468,7 @@ module Portage
249
468
  parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
250
469
  parser.on("--store URL") { |v| parsed[:store] = v }
251
470
  parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
252
- 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) }
253
472
  end
254
473
  private_class_method :add_search_options
255
474
 
@@ -310,13 +529,30 @@ module Portage
310
529
  end
311
530
  private_class_method :format_history
312
531
 
532
+ # `outcome` first, since it's what the entry is for. Entries recorded
533
+ # before `outcome` existed fall back to their checkout_status/message.
313
534
  def self.history_purchase_line(entry)
314
- "#{Time.at(entry['at'])} — #{entry['url']} (#{entry['query']}) — #{entry['checkout_status'] || entry['message']}"
535
+ items = Array(entry["items"]).map { |item| item_label(item) }.join(", ")
536
+ [
537
+ "#{Time.at(entry['at'])} — #{entry['outcome'] || entry['checkout_status'] || entry['message']}",
538
+ "#{entry['url']} (#{entry['query']})", (items unless items.empty?),
539
+ (format_amount(entry["total"], entry["currency"]) if entry["total"]),
540
+ (entry["checkout_url"] unless entry["outcome"] == "purchased")
541
+ ].compact.join(" — ")
315
542
  end
316
543
  private_class_method :history_purchase_line
317
544
 
545
+ # Takes a report's symbol-keyed item or a history entry's string-keyed
546
+ # one.
547
+ def self.item_label(item)
548
+ item = item.transform_keys(&:to_s)
549
+ "#{item['title'] || item['id']} x#{item['quantity']}"
550
+ end
551
+ private_class_method :item_label
552
+
318
553
  def self.history_search_line(entry)
319
- "#{Time.at(entry['at'])} — \"#{entry['query']}\" — #{entry['offer_count']} offer(s)"
554
+ where = entry["url"] ? " at #{entry['url']}" : ""
555
+ "#{Time.at(entry['at'])} — \"#{entry['query']}\"#{where} — #{entry['offer_count']} result(s)"
320
556
  end
321
557
  private_class_method :history_search_line
322
558
 
@@ -369,13 +605,14 @@ module Portage
369
605
  private_class_method :run_payment_mutate
370
606
 
371
607
  def self.parse_payment_enroll_options(argv)
372
- opts = { scope_merchants: [] }
608
+ opts = { scope_merchants: [], proxy: {} }
373
609
  OptionParser.new do |parser|
374
610
  parser.on("--label NAME") { |v| opts[:label] = v }
375
611
  parser.on("--json") { opts[:json] = true }
376
612
  parser.on("--scope-merchant HOST") { |v| opts[:scope_merchants] << v }
377
613
  parser.on("--scope-max-amount N", Integer) { |v| opts[:scope_max_amount] = v }
378
614
  parser.on("--scope-currency CUR") { |v| opts[:scope_currency] = v }
615
+ ProxySettings.add_options(parser, opts[:proxy])
379
616
  end.parse!(argv)
380
617
  opts[:url] = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
381
618
  opts
@@ -400,6 +637,7 @@ module Portage
400
637
  warn USAGE
401
638
  return 1
402
639
  end
640
+ return 1 unless apply_proxy_settings(opts[:proxy])
403
641
 
404
642
  result = PaymentMethods.new.enroll(opts[:url], label: opts[:label],
405
643
  scope: payment_enroll_scope(opts)) do |setup_url|
@@ -524,14 +762,86 @@ module Portage
524
762
  end
525
763
  private_class_method :format_payment_enroll
526
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
+
527
836
  # --- doctor ---
528
837
 
529
838
  def self.parse_doctor_options(argv)
530
- opts = {}
839
+ opts = { proxy: {} }
531
840
  OptionParser.new do |parser|
532
841
  parser.on("--require FILE") { |v| opts[:require] = v }
533
842
  parser.on("--adapter CLASS_NAME") { |v| opts[:adapter] = v }
534
843
  parser.on("--json") { opts[:json] = true }
844
+ ProxySettings.add_options(parser, opts[:proxy])
535
845
  end.parse!(argv)
536
846
  opts
537
847
  end
@@ -541,8 +851,10 @@ module Portage
541
851
  opts = parse_doctor_options(argv)
542
852
  require File.expand_path(opts[:require]) if opts[:require]
543
853
  adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
854
+ proxy_settings = apply_proxy_settings(opts[:proxy])
855
+ return 1 unless proxy_settings
544
856
 
545
- findings = Doctor.new(adapter_class: adapter_class).call
857
+ findings = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call
546
858
  puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
547
859
  findings.empty? ? 0 : 1
548
860
  end
@@ -598,21 +910,44 @@ module Portage
598
910
  puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
599
911
  puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
600
912
  "and off the machine that serves the public profile"
913
+ puts "Next: commit #{result[:profile_path]}, then, once it's on main, run " \
914
+ "`bundle exec rake agent_profile:purge` from the repo root — see docs/agent-profile.md."
601
915
  0
602
916
  end
603
917
  private_class_method :run_generate_agent_profile
604
918
 
605
919
  # --- output ---
606
920
 
921
+ # The `[outcome]` tag leads so a caller reading text, not --json, has the
922
+ # same value to branch on that the JSON report carries.
607
923
  def self.format_report(report)
608
- lines = ["#{report[:message]} (source: #{report[:source]})"]
924
+ lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
609
925
  report[:products].each { |p| lines << " - #{product_line(p)}" }
926
+ lines.concat(format_checkout(report))
610
927
  lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
611
928
  lines.concat(format_handoff(report[:handoff])) if report[:handoff]
929
+ lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
612
930
  lines.join("\n")
613
931
  end
614
932
  private_class_method :format_report
615
933
 
934
+ # What the checkout holds, as opposed to the search results above it,
935
+ # and where it differs from the request.
936
+ def self.format_checkout(report)
937
+ lines = Array(report[:items]).map { |item| " in checkout: #{item_label(item)}" }
938
+ total = report_total(report)
939
+ lines << " total: #{format_amount(total, report[:currency])}" if total
940
+ lines + Array(report[:warnings]).map { |w| " warning: #{w}" }
941
+ end
942
+ private_class_method :format_checkout
943
+
944
+ def self.format_decisions(decisions)
945
+ decisions.map do |name, verdict|
946
+ " decision #{name}: #{verdict.compact.map { |key, value| "#{key}=#{value}" }.join(' ')}"
947
+ end
948
+ end
949
+ private_class_method :format_decisions
950
+
616
951
  def self.format_handoff(handoff)
617
952
  lines = [" opened in browser: #{handoff[:opened]}", " notified: #{handoff[:notified]}"]
618
953
  lines << " notify error: #{handoff[:notify_error]}" if handoff[:notify_error]
@@ -656,8 +991,13 @@ module Portage
656
991
  def self.format_price(offer)
657
992
  return "price n/a" unless offer[:amount]
658
993
 
659
- "#{format('%.2f', offer[:amount] / 100.0)}#{" #{offer[:currency]}" if offer[:currency]}"
994
+ format_amount(offer[:amount], offer[:currency])
660
995
  end
661
996
  private_class_method :format_price
997
+
998
+ def self.format_amount(amount, currency)
999
+ "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
1000
+ end
1001
+ private_class_method :format_amount
662
1002
  end
663
1003
  end