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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/README.md +88 -18
  4. data/lib/portage/cli/adapter_session.rb +34 -0
  5. data/lib/portage/cli/browser_import/importer.rb +1 -1
  6. data/lib/portage/cli/browser_import/plist_xml.rb +5 -12
  7. data/lib/portage/cli/browser_import/sqlite.rb +19 -25
  8. data/lib/portage/cli/browser_profile/cdp.rb +0 -9
  9. data/lib/portage/cli/buy.rb +217 -39
  10. data/lib/portage/cli/compare.rb +4 -3
  11. data/lib/portage/cli/confidence_check.rb +27 -7
  12. data/lib/portage/cli/confidence_state.rb +109 -0
  13. data/lib/portage/cli/find.rb +66 -9
  14. data/lib/portage/cli/generate/agent_profile.rb +1 -1
  15. data/lib/portage/cli/handoff_agents.rb +7 -21
  16. data/lib/portage/cli/handoff_reconciler.rb +2 -22
  17. data/lib/portage/cli/homepage_fetch.rb +5 -3
  18. data/lib/portage/cli/index/builder.rb +2 -11
  19. data/lib/portage/cli/index/known_cache.rb +9 -2
  20. data/lib/portage/cli/index/sources/stores_file.rb +2 -11
  21. data/lib/portage/cli/index/sources/wikidata.rb +2 -11
  22. data/lib/portage/cli/install_doctor.rb +2 -1
  23. data/lib/portage/cli/notifier.rb +16 -12
  24. data/lib/portage/cli/offer_sources.rb +3 -14
  25. data/lib/portage/cli/payment_methods.rb +7 -30
  26. data/lib/portage/cli/reconcile_notifier.rb +2 -5
  27. data/lib/portage/cli/setup_wizard/steps/env_keys_step.rb +40 -0
  28. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +11 -23
  29. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +10 -22
  30. data/lib/portage/cli/setup_wizard/steps/shipping.rb +5 -19
  31. data/lib/portage/cli/version.rb +1 -1
  32. data/lib/portage/cli.rb +25 -173
  33. metadata +5 -3
  34. data/lib/portage/cli/known_stores_url.rb +0 -15
data/lib/portage/cli.rb CHANGED
@@ -55,6 +55,7 @@ module Portage
55
55
  portage buy --quote QUOTE_ID --yes [--json] ...
56
56
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
57
57
  portage find --query "..." [--max-price N] [--limit N] [--json]
58
+ portage find --store URL --query "..." [--max-price N] [--json] (one store, live, read-only)
58
59
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
59
60
  [--max-price N] [--json]
60
61
  portage check <url> [--json]
@@ -128,7 +129,6 @@ module Portage
128
129
  puts VERSION
129
130
  0
130
131
  end
131
- private_class_method :run_version
132
132
 
133
133
  # --- proxy (docs/plans/proxy-support.md Phase 2) ---
134
134
 
@@ -148,7 +148,6 @@ module Portage
148
148
  warn "portage: #{e.message}"
149
149
  nil
150
150
  end
151
- private_class_method :apply_proxy_settings
152
151
 
153
152
  # --- find ---
154
153
 
@@ -164,21 +163,18 @@ module Portage
164
163
  puts json ? JSON.pretty_generate(report) : format_find(report)
165
164
  report[:offers].any? ? 0 : 1
166
165
  end
167
- private_class_method :run_find
168
166
 
169
167
  # @return [Hash, nil] the saved search entry.
170
168
  def self.record_find(report)
171
169
  History.new.record_search(query: report[:query], offer_count: report[:offers].length,
172
170
  message: report[:message], offers: report[:offers])
173
171
  end
174
- private_class_method :record_find
175
172
 
176
173
  # docs/plans/human-pick-and-approve.md Phase 2: the saved search's id,
177
174
  # for `portage pick --search`. Additive; absent when nothing was saved.
178
175
  def self.with_search_id(report, entry)
179
176
  entry.is_a?(Hash) && entry["search_id"] ? report.merge(search_id: entry["search_id"]) : report
180
177
  end
181
- private_class_method :with_search_id
182
178
 
183
179
  def self.parse_find_options(argv)
184
180
  opts = {}
@@ -188,27 +184,38 @@ module Portage
188
184
  return nil
189
185
  end
190
186
 
191
- opts
187
+ valid_find_store?(opts) ? opts : nil
188
+ end
189
+
190
+ # `--store` must be an http(s) URL (a bare host is read as https); anything
191
+ # else is a usage error rather than a silent no-op search.
192
+ def self.valid_find_store?(opts)
193
+ return true unless opts.key?(:store)
194
+ return true if Find.store_origin(opts[:store])
195
+
196
+ warn "portage: --store needs an http(s) store URL, got #{opts[:store].inspect}."
197
+ warn USAGE
198
+ false
192
199
  end
193
- private_class_method :parse_find_options
194
200
 
195
201
  def self.find_option_parser(opts)
196
202
  opts[:proxy] = {}
197
203
  OptionParser.new do |parser|
198
204
  parser.on("--query QUERY") { |v| opts[:query] = v }
205
+ parser.on("--store URL", "Search only this store's catalogue, live and read-only (http/https)") do |v|
206
+ opts[:store] = v
207
+ end
199
208
  parser.on("--limit N", Integer) { |v| opts[:limit] = v }
200
209
  parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
201
210
  parser.on("--json") { opts[:json] = true }
202
211
  ProxySettings.add_options(parser, opts[:proxy])
203
212
  end
204
213
  end
205
- private_class_method :find_option_parser
206
214
 
207
215
  # `--max-price 400` means 400 of whatever the offer is priced in, and the
208
216
  # comparison happens per-offer in that offer's own currency — no FX
209
217
  # conversion, and no attempt to handle zero-decimal currencies like JPY.
210
218
  def self.to_minor_units(major) = (major * 100).round
211
- private_class_method :to_minor_units
212
219
 
213
220
  # --- check ---
214
221
 
@@ -224,7 +231,6 @@ module Portage
224
231
  puts opts[:json] ? JSON.pretty_generate(report) : format_check(report)
225
232
  Check::USABLE_VERDICTS.include?(report[:verdict]) ? 0 : 1
226
233
  end
227
- private_class_method :run_check
228
234
 
229
235
  def self.parse_check_options(argv)
230
236
  opts = { proxy: {} }
@@ -238,7 +244,6 @@ module Portage
238
244
  warn USAGE
239
245
  nil
240
246
  end
241
- private_class_method :parse_check_options
242
247
 
243
248
  def self.valid_check_url?(url)
244
249
  uri = URI.parse(url =~ %r{\Ahttps?://}i ? url : "https://#{url}")
@@ -246,7 +251,6 @@ module Portage
246
251
  rescue URI::InvalidURIError
247
252
  false
248
253
  end
249
- private_class_method :valid_check_url?
250
254
 
251
255
  def self.format_check(report)
252
256
  lines = ["#{report[:url]}: #{report[:verdict]}"]
@@ -256,7 +260,6 @@ module Portage
256
260
  lines << " index: `#{report[:index_hint]}` adds its catalogue to your local index" if report[:index_hint]
257
261
  lines.join("\n")
258
262
  end
259
- private_class_method :format_check
260
263
 
261
264
  # --- compare ---
262
265
 
@@ -273,7 +276,6 @@ module Portage
273
276
  puts json ? JSON.pretty_generate(report) : format_compare(report)
274
277
  report[:offers].any? ? 0 : 1
275
278
  end
276
- private_class_method :run_compare
277
279
 
278
280
  # Recorded as a search, not a purchase — compare never checks out. The
279
281
  # query string names the compare so `portage history list` doesn't
@@ -287,7 +289,6 @@ module Portage
287
289
  History.new.record_search(query: "compare: #{url} (product #{product_id})", offer_count: offers.length,
288
290
  message: report[:message], offers: offers)
289
291
  end
290
- private_class_method :record_compare
291
292
 
292
293
  def self.parse_compare_options(argv)
293
294
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
@@ -301,7 +302,6 @@ module Portage
301
302
  opts[:url] = url
302
303
  opts
303
304
  end
304
- private_class_method :parse_compare_options
305
305
 
306
306
  def self.compare_option_parser(opts)
307
307
  opts[:proxy] = {}
@@ -314,7 +314,6 @@ module Portage
314
314
  ProxySettings.add_options(parser, opts[:proxy])
315
315
  end
316
316
  end
317
- private_class_method :compare_option_parser
318
317
 
319
318
  # --- buy ---
320
319
 
@@ -332,7 +331,6 @@ module Portage
332
331
 
333
332
  dispatch_buy(parsed, url)
334
333
  end
335
- private_class_method :run_buy
336
334
 
337
335
  # A saved quote or offer names its own store; otherwise a url does, and
338
336
  # with neither the search picks one.
@@ -343,7 +341,6 @@ module Portage
343
341
 
344
342
  buy_from_search(parsed)
345
343
  end
346
- private_class_method :dispatch_buy
347
344
 
348
345
  # Built and validated up front, same posture as #confidence_check — an
349
346
  # unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
@@ -355,7 +352,6 @@ module Portage
355
352
  rescue ArgumentError => e
356
353
  invalid_buy_option(e.message, url: url, json: parsed[:json])
357
354
  end
358
- private_class_method :handoff_target
359
355
 
360
356
  # `portage buy` with no URL: search first, then buy from the store the
361
357
  # caller picks. `--yes` alone deliberately isn't enough to get here —
@@ -373,7 +369,6 @@ module Portage
373
369
  parsed[:page] = { title: offer[:title], url: offer[:url] }
374
370
  execute_buy(parsed, offer[:store], product_id: offer[:product_id])
375
371
  end
376
- private_class_method :buy_from_search
377
372
 
378
373
  # `--offer REF`: the store, product and query come from the saved
379
374
  # `find` that produced the ref, as if they'd been passed as flags.
@@ -390,7 +385,6 @@ module Portage
390
385
  parsed[:buy][:query] = offer["query"].to_s
391
386
  execute_buy(parsed, offer["store"], product_id: offer["product_id"])
392
387
  end
393
- private_class_method :buy_from_offer
394
388
 
395
389
  # `--quote QUOTE_ID`: buys what a `--dry-run` showed. Buy is handed the
396
390
  # quoted total as a cap and refuses (`quote_changed`) if the real
@@ -414,10 +408,10 @@ module Portage
414
408
  parsed[:quote_record] = quote
415
409
  buy = parsed[:buy]
416
410
  buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
417
- buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"])
411
+ buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"], quote_store: quote["store"],
412
+ quote_title: quote["title"])
418
413
  execute_buy(parsed, quote["store"])
419
414
  end
420
- private_class_method :buy_from_quote
421
415
 
422
416
  def self.usable_quote(parsed)
423
417
  quote = Quotes.new.find(parsed[:quote])
@@ -430,7 +424,6 @@ module Portage
430
424
  invalid_buy_option("Quote #{parsed[:quote]} has already been used — dry-run again for a new one.",
431
425
  url: quote["store"], json: parsed[:json], outcome: "quote_used")
432
426
  end
433
- private_class_method :usable_quote
434
427
 
435
428
  def self.refuse_unapproved_quote(parsed, quote, level)
436
429
  summary = Approve.summary(quote)
@@ -439,7 +432,6 @@ module Portage
439
432
  print_buy_report(report, nil, parsed[:json])
440
433
  buy_exit_code(report)
441
434
  end
442
- private_class_method :refuse_unapproved_quote
443
435
 
444
436
  # With a terminal (and no --json) the person picks there, through the
445
437
  # same HumanPrompt numbered pick `portage pick` uses; otherwise the
@@ -453,7 +445,6 @@ module Portage
453
445
 
454
446
  prompt_for_offer(prompt, report)
455
447
  end
456
- private_class_method :pick_offer
457
448
 
458
449
  def self.prompt_for_offer(prompt, report)
459
450
  prompt.say(report[:message].to_s)
@@ -461,7 +452,6 @@ module Portage
461
452
  index = prompt.choose("Pick one to buy", choices, view: OfferChoice.method(:view_message))
462
453
  index && report[:offers][index]
463
454
  end
464
- private_class_method :prompt_for_offer
465
455
 
466
456
  # docs/plans/human-pick-and-approve.md Phase 2: under
467
457
  # `--require-approval any|person` a real `--yes` run with no approved
@@ -481,7 +471,6 @@ module Portage
481
471
  print_buy_report(report, result, parsed[:json])
482
472
  buy_exit_code(report)
483
473
  end
484
- private_class_method :execute_buy
485
474
 
486
475
  def self.buy_options(parsed, url, product_id: nil)
487
476
  options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
@@ -490,23 +479,19 @@ module Portage
490
479
  options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
491
480
  options
492
481
  end
493
- private_class_method :buy_options
494
482
 
495
483
  # `needs_approval` exits 0 like Buy's own `needs_confirmation`: the run
496
484
  # did what it could and is waiting on the person.
497
485
  def self.buy_exit_code(report)
498
486
  report[:outcome] == "needs_approval" || report[:checkout] || report[:browse] ? 0 : 1
499
487
  end
500
- private_class_method :buy_exit_code
501
488
 
502
489
  def self.real_run?(parsed) = parsed[:buy][:yes] && !parsed[:buy][:dry_run]
503
- private_class_method :real_run?
504
490
 
505
491
  # An approved quote already passed ApprovalPolicy in #buy_from_quote.
506
492
  def self.approval_gate?(parsed)
507
493
  real_run?(parsed) && !parsed[:quote_record] && ApprovalPolicy.level != "off"
508
494
  end
509
- private_class_method :approval_gate?
510
495
 
511
496
  # A gated run that got as far as a priced checkout saved a quote; one
512
497
  # that didn't (no match, a dead end) is reported as it is — nothing
@@ -518,7 +503,6 @@ module Portage
518
503
  summary = Approve.summary(quote)
519
504
  report.merge(Approve.needs_approval(summary, message: approval_message(summary, ApprovalPolicy.level)))
520
505
  end
521
- private_class_method :needs_approval_report
522
506
 
523
507
  def self.approval_message(summary, level)
524
508
  id = summary[:quote_id]
@@ -530,7 +514,6 @@ module Portage
530
514
  how = level == "person" ? "`portage approve #{id} --via tty` at a terminal" : "`portage approve #{id}`"
531
515
  "Nothing was bought. #{held} Approve it with #{how}, then run `portage buy --quote #{id} --yes`."
532
516
  end
533
- private_class_method :approval_message
534
517
 
535
518
  # A dry run saves a quote and reports its `quote_id`. A run of a saved
536
519
  # quote spends it once it purchases or hands off; any other outcome
@@ -549,7 +532,6 @@ module Portage
549
532
  **quote_page(report, parsed))
550
533
  saved ? report.merge(quote_id: saved["quote_id"]) : report
551
534
  end
552
- private_class_method :settle_quote
553
535
 
554
536
  # What `portage approve` shows (docs/plans/human-pick-and-approve.md
555
537
  # Phase 2): the title of what's in the checkout, else the picked
@@ -558,7 +540,6 @@ module Portage
558
540
  page = parsed[:page] || {}
559
541
  { title: Array(report[:items]).first&.dig(:title) || page[:title], url: page[:url] }
560
542
  end
561
- private_class_method :quote_page
562
543
 
563
544
  # docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
564
545
  # profile` gives `portage buy` a browser of its own — the Portage
@@ -587,7 +568,6 @@ module Portage
587
568
  rescue StandardError
588
569
  nil
589
570
  end
590
- private_class_method :profile_webmcp_bridge
591
571
 
592
572
  # Same "bare host gets an https:// prefix" normalization Buy#initialize
593
573
  # applies to the same `url` — done again here since this runs before
@@ -598,7 +578,6 @@ module Portage
598
578
  raw = url.to_s.strip
599
579
  raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}"
600
580
  end
601
- private_class_method :absolute_url
602
581
 
603
582
  # An existing tab already on this store's host, so a shopper who's
604
583
  # mid-session there isn't yanked to a fresh one; otherwise a brand new
@@ -609,14 +588,12 @@ module Portage
609
588
  .find { |t| t["type"] == "page" && same_host?(t["url"], host) }
610
589
  existing || BrowserProfile::Cdp.new_tab(port: profile.port, url: url)
611
590
  end
612
- private_class_method :browser_profile_target
613
591
 
614
592
  def self.same_host?(url, host)
615
593
  URI(url.to_s).host == host
616
594
  rescue URI::InvalidURIError
617
595
  false
618
596
  end
619
- private_class_method :same_host?
620
597
 
621
598
  # docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`. A
622
599
  # no-op (returns nil) whenever there's nothing to wait on: --dry-run
@@ -646,12 +623,10 @@ module Portage
646
623
  wait_timeout_override: parsed[:wait_timeout])
647
624
  waiter.call(record) { |event, result| emit_wait_event(event, result, json) }
648
625
  end
649
- private_class_method :wait_for_handoff
650
626
 
651
627
  def self.pending_shopper_record(checkout_id, transaction_log)
652
628
  transaction_log.each_record.find { |r| r["checkout_id"] == checkout_id && r["settled_by"] == "shopper" }
653
629
  end
654
- private_class_method :pending_shopper_record
655
630
 
656
631
  # `terminal` is forced on for a plain-text wait (see ReconcileNotify) so
657
632
  # the shopper sees a line the moment it settles even with nothing
@@ -661,13 +636,11 @@ module Portage
661
636
  def self.wait_notifier(json)
662
637
  ReconcileNotifier.new(channels: ReconcileNotify.resolve(extra: json ? [] : ["terminal"]))
663
638
  end
664
- private_class_method :wait_notifier
665
639
 
666
640
  def self.handoff_event(report)
667
641
  { event: "handoff", checkout_id: report[:checkout_id], checkout_url: report[:checkout_url],
668
642
  reason: report[:outcome] }
669
643
  end
670
- private_class_method :handoff_event
671
644
 
672
645
  # Only `:settled` ever reaches stdout as `handoff_settled` — a timeout or
673
646
  # an interrupted wait leaves `result.settled` false, and that's already
@@ -683,16 +656,13 @@ module Portage
683
656
  order_id: result.order_id, amount: result.amount, currency: result.currency }.compact)
684
657
  end
685
658
  end
686
- private_class_method :emit_wait_event
687
659
 
688
660
  def self.emit_ndjson(payload) = puts JSON.generate(payload)
689
- private_class_method :emit_ndjson
690
661
 
691
662
  def self.print_buy_report(report, result, json)
692
663
  report = report.merge(reconcile: result.to_h) if result
693
664
  puts json ? JSON.pretty_generate(report) : format_report(report)
694
665
  end
695
- private_class_method :print_buy_report
696
666
 
697
667
  # Built before the buy starts (and before the search, when there's no
698
668
  # URL), so a bad --min-confidence, or a bad PORTAGE_MIN_CONFIDENCE with
@@ -703,7 +673,6 @@ module Portage
703
673
  rescue ArgumentError => e
704
674
  invalid_buy_option(e.message, url: url, json: parsed[:json])
705
675
  end
706
- private_class_method :confidence_check
707
676
 
708
677
  # A buy refused before it started. Under --json that's a report like
709
678
  # any other, with outcome `invalid_option`, so an agent loop reading
@@ -719,7 +688,6 @@ module Portage
719
688
  outcome: outcome, browse: false, checkout: false, message: message)
720
689
  nil
721
690
  end
722
- private_class_method :invalid_buy_option
723
691
 
724
692
  # A buy that created a checkout is a purchase entry, whatever its
725
693
  # outcome. One that never got that far (no match, browse-only, dead end,
@@ -739,12 +707,10 @@ module Portage
739
707
  items: Array(report[:items]).map { |item| item.transform_keys(&:to_s) }, message: report[:message]
740
708
  )
741
709
  end
742
- private_class_method :record_buy
743
710
 
744
711
  def self.report_total(report)
745
712
  Portage::Ucp::Support::Totals.amount(report[:totals])
746
713
  end
747
- private_class_method :report_total
748
714
 
749
715
  # A flag OptionParser can't read (`--min-confidence high`, `--qty two`,
750
716
  # an unknown flag) is refused the same way as an out-of-range
@@ -768,12 +734,10 @@ module Portage
768
734
  rescue OptionParser::ParseError => e
769
735
  invalid_buy_option(e.message, url: url, json: json)
770
736
  end
771
- private_class_method :parse_buy_options
772
737
 
773
738
  def self.buy_target?(url, buy, parsed)
774
739
  url || !buy[:query].strip.empty? || parsed[:offer] || parsed[:quote]
775
740
  end
776
- private_class_method :buy_target?
777
741
 
778
742
  # Bare arg is normally the store URL (`portage buy <url> --query "..."`),
779
743
  # but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
@@ -788,10 +752,8 @@ module Portage
788
752
  parsed[:find][:query] = buy[:query] = url
789
753
  nil
790
754
  end
791
- private_class_method :reinterpret_bare_query
792
755
 
793
756
  def self.url_like?(text) = text.match?(%r{\A[a-z][a-z0-9+.-]*://}i) || text.include?(".")
794
- private_class_method :url_like?
795
757
 
796
758
  def self.buy_option_parser(buy, parsed)
797
759
  OptionParser.new do |parser|
@@ -808,7 +770,6 @@ module Portage
808
770
  add_confidence_options(parser, parsed[:confidence])
809
771
  end
810
772
  end
811
- private_class_method :buy_option_parser
812
773
 
813
774
  # The opt-in confidence gate in front of a `--yes` completion (see
814
775
  # ConfidenceCheck) — both default to their PORTAGE_* env vars.
@@ -816,14 +777,12 @@ module Portage
816
777
  parser.on("--decision-backend NAME") { |v| confidence[:backend] = v }
817
778
  parser.on("--min-confidence N", Float) { |v| confidence[:threshold] = v }
818
779
  end
819
- private_class_method :add_confidence_options
820
780
 
821
781
  def self.add_handoff_options(parser, buy)
822
782
  parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
823
783
  parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
824
784
  parser.on("--handoff-target TARGET") { |v| buy[:handoff_target] = v }
825
785
  end
826
- private_class_method :add_handoff_options
827
786
 
828
787
  # docs/plans/handoff-reconcile.md Phase 3 — `--wait`/`--wait-timeout`
829
788
  # land on `parsed`, never on `buy`: they're consumed by
@@ -833,7 +792,6 @@ module Portage
833
792
  parser.on("--wait") { parsed[:wait] = true }
834
793
  parser.on("--wait-timeout DURATION") { |v| parsed[:wait_timeout] = v }
835
794
  end
836
- private_class_method :add_wait_options
837
795
 
838
796
  # `--query` feeds both halves: it's the store search when there's no URL
839
797
  # and the catalog search once a store is settled, so it's registered once
@@ -846,7 +804,6 @@ module Portage
846
804
  parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
847
805
  parser.on("--max-price N", Float) { |v| parsed[:find][:max_price] = buy[:max_price] = to_minor_units(v) }
848
806
  end
849
- private_class_method :add_search_options
850
807
 
851
808
  # --- pick / approve (docs/plans/human-pick-and-approve.md Phase 2) ---
852
809
 
@@ -868,7 +825,6 @@ module Portage
868
825
  **opts.slice(:search, :choose, :view, :compare))
869
826
  print_prompt_result(pick.call, opts[:json])
870
827
  end
871
- private_class_method :run_pick
872
828
 
873
829
  def self.run_approve(argv)
874
830
  quote_id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
@@ -883,7 +839,6 @@ module Portage
883
839
  **opts.slice(:relayed_yes, :view))
884
840
  print_prompt_result(approve.call, opts[:json])
885
841
  end
886
- private_class_method :run_approve
887
842
 
888
843
  # `--via`/`--json` for both commands, plus whatever the block adds.
889
844
  # @return [Hash, nil] nil on a bad flag (already reported).
@@ -900,13 +855,11 @@ module Portage
900
855
  prompt_usage(json, e.message)
901
856
  nil
902
857
  end
903
- private_class_method :parse_prompt_options
904
858
 
905
859
  def self.prompt_usage(json, message)
906
860
  json ? puts(JSON.pretty_generate(outcome: "invalid_option", message: message)) : warn("#{message}\n#{USAGE}")
907
861
  1
908
862
  end
909
- private_class_method :prompt_usage
910
863
 
911
864
  # Pick's "Compare an offer across stores": the same Compare run
912
865
  # `portage compare` does, from the saved offer's store and product.
@@ -915,13 +868,11 @@ module Portage
915
868
 
916
869
  Compare.new(origin_url: offer["store"], origin_product_id: offer["product_id"]).call
917
870
  end
918
- private_class_method :compare_offer
919
871
 
920
872
  def self.print_prompt_result(result, json)
921
873
  puts json ? JSON.pretty_generate(result) : format_prompt_result(result)
922
874
  PROMPT_OK_OUTCOMES.include?(result[:outcome]) ? 0 : 1
923
875
  end
924
- private_class_method :print_prompt_result
925
876
 
926
877
  def self.format_prompt_result(result)
927
878
  lines = ["[#{result[:outcome]}] #{result[:message]}"]
@@ -929,12 +880,10 @@ module Portage
929
880
  lines << " #{approval_line(result[:summary])}" if result[:summary]
930
881
  lines.join("\n")
931
882
  end
932
- private_class_method :format_prompt_result
933
883
 
934
884
  def self.choice_line(choice)
935
885
  [choice[:label], choice[:url], ("ref #{choice[:ref]}" if choice[:ref])].compact.join(" — ")
936
886
  end
937
- private_class_method :choice_line
938
887
 
939
888
  # --- history ---
940
889
 
@@ -948,7 +897,6 @@ module Portage
948
897
  1
949
898
  end
950
899
  end
951
- private_class_method :run_history
952
900
 
953
901
  def self.run_history_list(argv)
954
902
  opts = { limit: History::MAX_ENTRIES }
@@ -961,7 +909,6 @@ module Portage
961
909
  puts json ? JSON.pretty_generate(result) : format_history(result)
962
910
  0
963
911
  end
964
- private_class_method :run_history_list
965
912
 
966
913
  def self.run_history_clear(argv)
967
914
  opts = {}
@@ -970,7 +917,6 @@ module Portage
970
917
  puts "Cleared #{opts[:kind] || 'purchase and search'} history."
971
918
  0
972
919
  end
973
- private_class_method :run_history_clear
974
920
 
975
921
  def self.history_option_parser(opts)
976
922
  OptionParser.new do |parser|
@@ -980,7 +926,6 @@ module Portage
980
926
  parser.on("--json") { opts[:json] = true }
981
927
  end
982
928
  end
983
- private_class_method :history_option_parser
984
929
 
985
930
  def self.format_history(result)
986
931
  lines = ["Purchases:"]
@@ -991,7 +936,6 @@ module Portage
991
936
  lines << "(none)" if result[:searches].empty?
992
937
  lines.join("\n")
993
938
  end
994
- private_class_method :format_history
995
939
 
996
940
  # `outcome` first, since it's what the entry is for. Entries recorded
997
941
  # before `outcome` existed fall back to their checkout_status/message.
@@ -1000,11 +944,10 @@ module Portage
1000
944
  [
1001
945
  "#{Time.at(entry['at'])} — #{entry['outcome'] || entry['checkout_status'] || entry['message']}",
1002
946
  "#{entry['url']} (#{entry['query']})", (items unless items.empty?),
1003
- (format_amount(entry["total"], entry["currency"]) if entry["total"]),
947
+ (Money.format_amount(entry["total"], entry["currency"]) if entry["total"]),
1004
948
  (entry["checkout_url"] unless entry["outcome"] == "purchased")
1005
949
  ].compact.join(" — ")
1006
950
  end
1007
- private_class_method :history_purchase_line
1008
951
 
1009
952
  # Takes a report's symbol-keyed item or a history entry's string-keyed
1010
953
  # one.
@@ -1012,13 +955,11 @@ module Portage
1012
955
  item = item.transform_keys(&:to_s)
1013
956
  "#{item['title'] || item['id']} x#{item['quantity']}"
1014
957
  end
1015
- private_class_method :item_label
1016
958
 
1017
959
  def self.history_search_line(entry)
1018
960
  where = entry["url"] ? " at #{entry['url']}" : ""
1019
961
  "#{Time.at(entry['at'])} — \"#{entry['query']}\"#{where} — #{entry['offer_count']} result(s)"
1020
962
  end
1021
- private_class_method :history_search_line
1022
963
 
1023
964
  # --- payment ---
1024
965
 
@@ -1038,7 +979,6 @@ module Portage
1038
979
  warn USAGE
1039
980
  1
1040
981
  end
1041
- private_class_method :run_payment
1042
982
 
1043
983
  def self.run_payment_list(argv)
1044
984
  json = false
@@ -1047,7 +987,6 @@ module Portage
1047
987
  puts json ? JSON.pretty_generate(methods) : format_payment_list(methods)
1048
988
  0
1049
989
  end
1050
- private_class_method :run_payment_list
1051
990
 
1052
991
  # Shared by set-default/remove/freeze/revoke — each takes exactly one
1053
992
  # `<id>` positional arg and reports the (now-updated) entry, or fails
@@ -1066,7 +1005,6 @@ module Portage
1066
1005
  warn "No payment method enrolled with id #{id}."
1067
1006
  1
1068
1007
  end
1069
- private_class_method :run_payment_mutate
1070
1008
 
1071
1009
  def self.parse_payment_enroll_options(argv)
1072
1010
  opts = { scope_merchants: [], proxy: {} }
@@ -1081,7 +1019,6 @@ module Portage
1081
1019
  opts[:url] = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1082
1020
  opts
1083
1021
  end
1084
- private_class_method :parse_payment_enroll_options
1085
1022
 
1086
1023
  # nil (not `{}`) when no --scope-* flag was given at all, so
1087
1024
  # PaymentMethods#enroll's `scope:` default of no-scope-written stays the
@@ -1093,7 +1030,6 @@ module Portage
1093
1030
  { merchants: opts[:scope_merchants], max_amount: opts[:scope_max_amount], currency: opts[:scope_currency] }
1094
1031
  .compact
1095
1032
  end
1096
- private_class_method :payment_enroll_scope
1097
1033
 
1098
1034
  def self.run_payment_enroll(argv)
1099
1035
  opts = parse_payment_enroll_options(argv)
@@ -1113,7 +1049,6 @@ module Portage
1113
1049
  warn e.message
1114
1050
  1
1115
1051
  end
1116
- private_class_method :run_payment_enroll
1117
1052
 
1118
1053
  # --- policy ---
1119
1054
 
@@ -1127,7 +1062,6 @@ module Portage
1127
1062
  1
1128
1063
  end
1129
1064
  end
1130
- private_class_method :run_policy
1131
1065
 
1132
1066
  # `require_approval` is always shown at its effective value, the
1133
1067
  # default included, so "what does `--yes` need right now" is never a
@@ -1140,7 +1074,6 @@ module Portage
1140
1074
  puts json ? JSON.pretty_generate(effective) : format_policy(policy)
1141
1075
  0
1142
1076
  end
1143
- private_class_method :run_policy_show
1144
1077
 
1145
1078
  def self.format_policy(policy)
1146
1079
  spending = policy.to_h.except(ApprovalPolicy::KEY)
@@ -1148,7 +1081,6 @@ module Portage
1148
1081
  default = " (default)" unless ApprovalPolicy.configured?(policy)
1149
1082
  "#{body}\nrequire_approval: #{ApprovalPolicy.level(policy)}#{default}"
1150
1083
  end
1151
- private_class_method :format_policy
1152
1084
 
1153
1085
  def self.parse_policy_set_options(argv)
1154
1086
  opts = { allow: [] }
@@ -1162,7 +1094,6 @@ module Portage
1162
1094
  end.parse!(argv)
1163
1095
  opts
1164
1096
  end
1165
- private_class_method :parse_policy_set_options
1166
1097
 
1167
1098
  def self.add_policy_cap_options(parser, opts)
1168
1099
  parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
@@ -1170,7 +1101,6 @@ module Portage
1170
1101
  parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
1171
1102
  parser.on("--currency CUR") { |v| opts[:currency] = v }
1172
1103
  end
1173
- private_class_method :add_policy_cap_options
1174
1104
 
1175
1105
  # Each `--*` group is applied independently and only when its required
1176
1106
  # fields are present — `portage policy set --allow shop.example.com`
@@ -1194,7 +1124,6 @@ module Portage
1194
1124
  warn "#{e.message}\n#{USAGE}"
1195
1125
  1
1196
1126
  end
1197
- private_class_method :run_policy_set
1198
1127
 
1199
1128
  # docs/plans/human-pick-and-approve.md Phase 2: raising the level (or
1200
1129
  # setting the same one) needs nothing; lowering it (person -> any/off,
@@ -1211,7 +1140,6 @@ module Portage
1211
1140
  policy.set(ApprovalPolicy::KEY, level)
1212
1141
  true
1213
1142
  end
1214
- private_class_method :require_approval_applied?
1215
1143
 
1216
1144
  def self.confirm_lowering(current, level)
1217
1145
  prompt = HumanPrompt.new(via: "tty")
@@ -1224,14 +1152,12 @@ module Portage
1224
1152
  "terminal here — nothing changed. Run it yourself from a terminal."
1225
1153
  false
1226
1154
  end
1227
- private_class_method :confirm_lowering
1228
1155
 
1229
1156
  def self.lowering_effect(level)
1230
1157
  return "`portage buy --yes` would then buy without anyone approving the total." if level == "off"
1231
1158
 
1232
1159
  "An agent relaying your yes would then be enough to buy."
1233
1160
  end
1234
- private_class_method :lowering_effect
1235
1161
 
1236
1162
  def self.set_policy_cap(policy, opts)
1237
1163
  if opts[:per_transaction_cap]
@@ -1243,14 +1169,12 @@ module Portage
1243
1169
  policy.set("rolling_cap", { "amount" => opts[:rolling_cap], "currency" => require_currency!(opts),
1244
1170
  "window_seconds" => opts[:rolling_window_seconds] })
1245
1171
  end
1246
- private_class_method :set_policy_cap
1247
1172
 
1248
1173
  def self.set_policy_velocity(policy, opts)
1249
1174
  return unless opts[:velocity_count] && opts[:velocity_window_seconds]
1250
1175
 
1251
1176
  policy.set("velocity", { "count" => opts[:velocity_count], "window_seconds" => opts[:velocity_window_seconds] })
1252
1177
  end
1253
- private_class_method :set_policy_velocity
1254
1178
 
1255
1179
  def self.set_policy_allowlist(policy, opts)
1256
1180
  return policy.set("merchant_allowlist", []) if opts[:clear_allowlist]
@@ -1258,19 +1182,16 @@ module Portage
1258
1182
 
1259
1183
  policy.set("merchant_allowlist", (policy.merchant_allowlist + opts[:allow]).uniq)
1260
1184
  end
1261
- private_class_method :set_policy_allowlist
1262
1185
 
1263
1186
  def self.require_currency!(opts)
1264
1187
  opts[:currency] || raise(ArgumentError, "--currency is required alongside a cap")
1265
1188
  end
1266
- private_class_method :require_currency!
1267
1189
 
1268
1190
  def self.format_payment_list(methods)
1269
1191
  return "(no payment methods enrolled)" if methods.empty?
1270
1192
 
1271
1193
  methods.map { |m| "#{m['label']} (#{m['id']}) — default: #{m['default']}, frozen: #{m['frozen']}" }.join("\n")
1272
1194
  end
1273
- private_class_method :format_payment_list
1274
1195
 
1275
1196
  def self.format_payment_enroll(result)
1276
1197
  case result[:status]
@@ -1284,7 +1205,6 @@ module Portage
1284
1205
  else "This store doesn't support payment enrollment."
1285
1206
  end
1286
1207
  end
1287
- private_class_method :format_payment_enroll
1288
1208
 
1289
1209
  # --- orders ---
1290
1210
 
@@ -1302,7 +1222,6 @@ module Portage
1302
1222
  warn USAGE
1303
1223
  1
1304
1224
  end
1305
- private_class_method :run_orders
1306
1225
 
1307
1226
  def self.run_orders_reconcile(argv)
1308
1227
  opts = {}
@@ -1318,14 +1237,12 @@ module Portage
1318
1237
  puts opts[:json] ? JSON.pretty_generate(results.map(&:to_h)) : format_reconcile(results)
1319
1238
  0
1320
1239
  end
1321
- private_class_method :run_orders_reconcile
1322
1240
 
1323
1241
  def self.reconcile_records(checkout_id, transaction_log, reconciler)
1324
1242
  return reconcile_one_checkout(checkout_id, transaction_log, reconciler) if checkout_id
1325
1243
 
1326
1244
  HandoffReconciler.each_pending_shopper_record(transaction_log).map { |record| reconciler.call(record) }
1327
1245
  end
1328
- private_class_method :reconcile_records
1329
1246
 
1330
1247
  # `--checkout ID` reconciles the one named record whatever its
1331
1248
  # `settled_by`/status — #call itself still refuses to settle anything
@@ -1337,14 +1254,12 @@ module Portage
1337
1254
 
1338
1255
  [reconciler.call(transaction_log.find(key))]
1339
1256
  end
1340
- private_class_method :reconcile_one_checkout
1341
1257
 
1342
1258
  def self.format_reconcile(results)
1343
1259
  return "(nothing to reconcile)" if results.empty?
1344
1260
 
1345
1261
  results.map { |r| format_reconcile_result(r) }.join("\n")
1346
1262
  end
1347
- private_class_method :format_reconcile
1348
1263
 
1349
1264
  def self.format_reconcile_result(result)
1350
1265
  return "#{result.idempotency_key}: #{result.note}" unless result.settled
@@ -1352,10 +1267,9 @@ module Portage
1352
1267
  parts = ["#{result.idempotency_key}: #{result.status}"]
1353
1268
  parts << "resolution: #{result.resolution}" if result.resolution
1354
1269
  parts << "order: #{result.order_id}" if result.order_id
1355
- parts << format_amount(result.amount, result.currency) if result.amount
1270
+ parts << Money.format_amount(result.amount, result.currency) if result.amount
1356
1271
  parts.join(" — ")
1357
1272
  end
1358
- private_class_method :format_reconcile_result
1359
1273
 
1360
1274
  # --- index (docs/plans/buy-skill-and-local-browser.md Phase 2b) ---
1361
1275
 
@@ -1376,7 +1290,6 @@ module Portage
1376
1290
  warn USAGE
1377
1291
  1
1378
1292
  end
1379
- private_class_method :run_index
1380
1293
 
1381
1294
  def self.parse_index_build_options(argv)
1382
1295
  opts = { sources: nil, queries: nil, dry_run: false, json: false, export: nil }
@@ -1390,7 +1303,6 @@ module Portage
1390
1303
  opts[:queries] &&= File.readlines(opts[:queries]).map(&:strip).reject(&:empty?)
1391
1304
  opts
1392
1305
  end
1393
- private_class_method :parse_index_build_options
1394
1306
 
1395
1307
  def self.run_index_build(argv, refresh:)
1396
1308
  opts = parse_index_build_options(argv)
@@ -1403,10 +1315,8 @@ module Portage
1403
1315
  puts opts[:json] ? JSON.pretty_generate(result) : format_index_build(result)
1404
1316
  0
1405
1317
  end
1406
- private_class_method :run_index_build
1407
1318
 
1408
1319
  def self.index_sources(names) = names ? Index::Sources.by_name(names) : nil
1409
- private_class_method :index_sources
1410
1320
 
1411
1321
  def self.format_index_build(result)
1412
1322
  lines = ["Ran #{result[:sources_run].join(', ')} — #{result[:candidates]} candidate(s)."]
@@ -1420,7 +1330,6 @@ module Portage
1420
1330
  end
1421
1331
  lines.join("\n")
1422
1332
  end
1423
- private_class_method :format_index_build
1424
1333
 
1425
1334
  INDEX_SHOW_PER_PAGE = 50
1426
1335
 
@@ -1438,12 +1347,10 @@ module Portage
1438
1347
  puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
1439
1348
  0
1440
1349
  end
1441
- private_class_method :run_index_show
1442
1350
 
1443
1351
  def self.index_show_all(kind)
1444
1352
  { stores: kind == "products" ? [] : Index::Store.new.all, products: kind == "stores" ? [] : Index::ProductStore.new.all }
1445
1353
  end
1446
- private_class_method :index_show_all
1447
1354
 
1448
1355
  # `--products` pages (docs/plans/local-catalogue.md Phase 2): a crawled
1449
1356
  # index can hold thousands.
@@ -1452,7 +1359,6 @@ module Portage
1452
1359
  { stores: [], products: products.page(opts[:page], per_page: opts[:per_page]), page: opts[:page],
1453
1360
  per_page: opts[:per_page], products_total: products.count }
1454
1361
  end
1455
- private_class_method :index_products_page
1456
1362
 
1457
1363
  def self.format_index_show(result)
1458
1364
  paged = result.key?(:page)
@@ -1463,13 +1369,11 @@ module Portage
1463
1369
  lines << index_page_line(result) if paged
1464
1370
  lines.join("\n")
1465
1371
  end
1466
- private_class_method :format_index_show
1467
1372
 
1468
1373
  def self.index_store_lines(stores)
1469
1374
  lines = ["Stores:"] + stores.map { |s| " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
1470
1375
  stores.empty? ? lines + ["(none)"] : lines
1471
1376
  end
1472
- private_class_method :index_store_lines
1473
1377
 
1474
1378
  def self.index_page_line(result)
1475
1379
  first = ((result[:page] - 1) * result[:per_page]) + 1
@@ -1479,7 +1383,6 @@ module Portage
1479
1383
  "Showing #{first}-#{last} of #{result[:products_total]}. Next: --page #{result[:page] + 1}" \
1480
1384
  "#{" --per-page #{result[:per_page]}" unless result[:per_page] == INDEX_SHOW_PER_PAGE}"
1481
1385
  end
1482
- private_class_method :index_page_line
1483
1386
 
1484
1387
  # `portage index search` (docs/plans/local-catalogue.md Phase 2): local
1485
1388
  # index entries only, no request. Exits 1 with no hits.
@@ -1498,7 +1401,6 @@ module Portage
1498
1401
  puts opts[:json] ? JSON.pretty_generate(result) : format_index_search(result)
1499
1402
  result[:products].empty? ? 1 : 0
1500
1403
  end
1501
- private_class_method :run_index_search
1502
1404
 
1503
1405
  def self.index_search(query, opts)
1504
1406
  products = Index::ProductStore.new
@@ -1506,24 +1408,22 @@ module Portage
1506
1408
  hits = products.search(query, **filters).map { |entry| entry.merge("product" => Index::EntryProduct.wire(entry)) }
1507
1409
  { query: query, engine: products.search_engine, live: false, filters: filters, products: hits }
1508
1410
  end
1509
- private_class_method :index_search
1510
1411
 
1511
1412
  def self.format_index_search(result)
1512
1413
  return "No index products match \"#{result[:query]}\"." if result[:products].empty?
1513
1414
 
1514
1415
  lines = result[:products].map { |p| index_search_line(p) }
1515
- lines << "(From the local index, not live: check the price and stock with `portage find --store`.)"
1416
+ lines << "(From the local index, not live: check the price and stock with " \
1417
+ "`portage find --store URL --query ...`.)"
1516
1418
  lines << "(FTS5 isn't available in this SQLite, so this was a plain text match.)" if result[:engine] == "like"
1517
1419
  lines.join("\n")
1518
1420
  end
1519
- private_class_method :format_index_search
1520
1421
 
1521
1422
  def self.index_search_line(product)
1522
1423
  hosts = Array(product["stores"]).filter_map { |s| URI.parse(s["origin"].to_s).host }.join(", ")
1523
1424
  ["#{product['title']}#{" — #{product['brand']}" if product['brand']}", ("(#{hosts})" unless hosts.empty?),
1524
1425
  product["url"]].compact.join(" ")
1525
1426
  end
1526
- private_class_method :index_search_line
1527
1427
 
1528
1428
  def self.run_index_add(argv)
1529
1429
  json = !argv.delete("--json").nil?
@@ -1538,7 +1438,6 @@ module Portage
1538
1438
  puts json ? JSON.pretty_generate(result) : result[:message]
1539
1439
  result[:added] ? 0 : 1
1540
1440
  end
1541
- private_class_method :run_index_add
1542
1441
 
1543
1442
  def self.run_index_remove(argv)
1544
1443
  json = argv.delete("--json") ? true : false
@@ -1552,7 +1451,6 @@ module Portage
1552
1451
  puts json ? JSON.pretty_generate(result) : result[:message]
1553
1452
  result[:removed] ? 0 : 1
1554
1453
  end
1555
- private_class_method :run_index_remove
1556
1454
 
1557
1455
  def self.run_index_sources(argv)
1558
1456
  json = argv.delete("--json") ? true : false
@@ -1560,12 +1458,10 @@ module Portage
1560
1458
  puts json ? JSON.pretty_generate(sources) : format_index_sources(sources)
1561
1459
  0
1562
1460
  end
1563
- private_class_method :run_index_sources
1564
1461
 
1565
1462
  def self.format_index_sources(sources)
1566
1463
  sources.map { |s| "#{s[:name]}: #{s[:description]}#{" (#{s[:path]})" if s[:path]}" }.join("\n")
1567
1464
  end
1568
- private_class_method :format_index_sources
1569
1465
 
1570
1466
  # --- browser (docs/plans/buy-skill-and-local-browser.md Phase 3) ---
1571
1467
 
@@ -1579,7 +1475,6 @@ module Portage
1579
1475
  warn USAGE
1580
1476
  1
1581
1477
  end
1582
- private_class_method :run_browser
1583
1478
 
1584
1479
  def self.parse_browser_import_options(argv)
1585
1480
  opts = { browser: nil, root: nil, history_days: BrowserImport::Importer::DEFAULT_HISTORY_DAYS,
@@ -1588,7 +1483,6 @@ module Portage
1588
1483
  browser_import_option_parser(opts).parse!(argv)
1589
1484
  opts
1590
1485
  end
1591
- private_class_method :parse_browser_import_options
1592
1486
 
1593
1487
  def self.browser_import_option_parser(opts)
1594
1488
  OptionParser.new do |parser|
@@ -1602,7 +1496,6 @@ module Portage
1602
1496
  end
1603
1497
  end
1604
1498
  end
1605
- private_class_method :browser_import_option_parser
1606
1499
 
1607
1500
  # Reads, reduces and probes (BrowserImport::Importer#plan), shows the
1608
1501
  # list, and saves only through BrowserImport::Confirm's gate: `--yes`,
@@ -1625,7 +1518,6 @@ module Portage
1625
1518
  warn "#{e.message}\n#{USAGE}"
1626
1519
  1
1627
1520
  end
1628
- private_class_method :run_browser_import
1629
1521
 
1630
1522
  # The real HandoffOnly list (docs/plans/buy-skill-and-local-browser.md
1631
1523
  # Phase 5) into Importer's own injectable `handoff_only_hosts:` seam
@@ -1635,7 +1527,6 @@ module Portage
1635
1527
  def self.browser_importer
1636
1528
  BrowserImport::Importer.new(handoff_only_hosts: HandoffOnly.new.hosts)
1637
1529
  end
1638
- private_class_method :browser_importer
1639
1530
 
1640
1531
  def self.browser_import_options(opts)
1641
1532
  browser = opts[:browser] || BrowserImport::Profiles.detect || "chrome"
@@ -1645,7 +1536,6 @@ module Portage
1645
1536
  max_probes: opts[:max_probes], exclude: opts[:exclude]
1646
1537
  )
1647
1538
  end
1648
- private_class_method :browser_import_options
1649
1539
 
1650
1540
  BROWSER_IMPORT_MESSAGES = {
1651
1541
  dry_run: "Dry run — nothing saved.",
@@ -1665,14 +1555,12 @@ module Portage
1665
1555
  end
1666
1556
  plan[:error] ? 1 : 0
1667
1557
  end
1668
- private_class_method :report_browser_import
1669
1558
 
1670
1559
  def self.browser_import_message(decision, saved)
1671
1560
  return "Saved #{saved[:stores]} store(s) and #{saved[:products]} product(s) to your local index." if saved
1672
1561
 
1673
1562
  BROWSER_IMPORT_MESSAGES.fetch(decision, "Nothing saved.")
1674
1563
  end
1675
- private_class_method :browser_import_message
1676
1564
 
1677
1565
  def self.format_browser_import(plan)
1678
1566
  lines = browser_import_counts(plan)
@@ -1682,7 +1570,6 @@ module Portage
1682
1570
  lines << "#{plan[:products].length} product page(s) to keep." if plan[:products].any?
1683
1571
  lines.join("\n")
1684
1572
  end
1685
- private_class_method :format_browser_import
1686
1573
 
1687
1574
  def self.browser_import_counts(plan)
1688
1575
  skipped = plan[:skipped].map { |reason, n| "#{n} #{reason}" }.join(", ")
@@ -1693,14 +1580,12 @@ module Portage
1693
1580
  lines << "Hit the #{plan[:probed]}-probe cap; #{plan[:unprobed]} domain(s) left unprobed." if plan[:capped]
1694
1581
  lines
1695
1582
  end
1696
- private_class_method :browser_import_counts
1697
1583
 
1698
1584
  def self.browser_import_line(entry)
1699
1585
  categories = entry[:category_names].empty? ? "uncategorised" : entry[:category_names].join(", ")
1700
1586
  "#{entry[:domain]} — #{entry[:verdict]} — #{categories} " \
1701
1587
  "(#{entry[:sources].join(', ')}, #{entry[:visits]} visit(s))"
1702
1588
  end
1703
- private_class_method :browser_import_line
1704
1589
 
1705
1590
  # --- browser profile (docs/plans/buy-skill-and-local-browser.md Phase 6) ---
1706
1591
 
@@ -1716,13 +1601,11 @@ module Portage
1716
1601
 
1717
1602
  BROWSER_PROFILE_SUBCOMMANDS[sub].call(argv)
1718
1603
  end
1719
- private_class_method :run_browser_profile
1720
1604
 
1721
1605
  def self.browser_profile_usage
1722
1606
  warn USAGE
1723
1607
  1
1724
1608
  end
1725
- private_class_method :browser_profile_usage
1726
1609
 
1727
1610
  def self.parse_browser_profile_options(argv)
1728
1611
  opts = { browser: nil, port: BrowserProfile::Profile::DEFAULT_PORT, url: nil, json: false }
@@ -1734,7 +1617,6 @@ module Portage
1734
1617
  end.parse!(argv)
1735
1618
  opts
1736
1619
  end
1737
- private_class_method :parse_browser_profile_options
1738
1620
 
1739
1621
  # `--browser` names the exact browser; without it, the first Chromium
1740
1622
  # family browser BrowserImport::Profiles finds installed, falling back
@@ -1744,7 +1626,6 @@ module Portage
1744
1626
  browser = opts[:browser] || BrowserProfile::Browsers.detect || "chrome"
1745
1627
  BrowserProfile::Profile.new(browser: browser, port: opts[:port])
1746
1628
  end
1747
- private_class_method :browser_profile_for
1748
1629
 
1749
1630
  def self.run_browser_profile_init(argv)
1750
1631
  opts = parse_browser_profile_options(argv)
@@ -1755,7 +1636,6 @@ module Portage
1755
1636
  warn "#{e.message}\n#{USAGE}"
1756
1637
  1
1757
1638
  end
1758
- private_class_method :run_browser_profile_init
1759
1639
 
1760
1640
  # Launches the profile (if it isn't already running on its own port)
1761
1641
  # and either opens a new tab at --url or attaches to the first
@@ -1773,7 +1653,6 @@ module Portage
1773
1653
  warn "#{e.message}\n#{USAGE}"
1774
1654
  1
1775
1655
  end
1776
- private_class_method :run_browser_profile_open
1777
1656
 
1778
1657
  def self.run_browser_profile_status(argv)
1779
1658
  opts = parse_browser_profile_options(argv)
@@ -1784,20 +1663,17 @@ module Portage
1784
1663
  warn "#{e.message}\n#{USAGE}"
1785
1664
  1
1786
1665
  end
1787
- private_class_method :run_browser_profile_status
1788
1666
 
1789
1667
  def self.format_browser_profile_open(result)
1790
1668
  tab = result.dig(:target, "url")
1791
1669
  "#{result[:browser]} profile is open (port #{result[:port]}, #{result[:dir]})#{" — tab: #{tab}" if tab}."
1792
1670
  end
1793
- private_class_method :format_browser_profile_open
1794
1671
 
1795
1672
  def self.format_browser_profile_status(result)
1796
1673
  return "#{result[:browser]} profile (#{result[:dir]}) isn't running." unless result[:running]
1797
1674
 
1798
1675
  "#{result[:browser]} profile is running on port #{result[:port]} (#{result[:dir]})."
1799
1676
  end
1800
- private_class_method :format_browser_profile_status
1801
1677
 
1802
1678
  def self.report_browser_profile_error(error, json)
1803
1679
  if json
@@ -1807,7 +1683,6 @@ module Portage
1807
1683
  end
1808
1684
  1
1809
1685
  end
1810
- private_class_method :report_browser_profile_error
1811
1686
 
1812
1687
  # --- doctor ---
1813
1688
 
@@ -1821,7 +1696,6 @@ module Portage
1821
1696
  end.parse!(argv)
1822
1697
  opts
1823
1698
  end
1824
- private_class_method :parse_doctor_options
1825
1699
 
1826
1700
  # `wizard: :force` is `portage setup`, always offering the wizard on a
1827
1701
  # TTY; `:auto` is `doctor`/`configure`, which only offers it when
@@ -1841,10 +1715,8 @@ module Portage
1841
1715
 
1842
1716
  report_doctor(doctor.call, json: opts[:json])
1843
1717
  end
1844
- private_class_method :run_doctor
1845
1718
 
1846
1719
  def self.run_setup(argv) = run_doctor(argv, wizard: :force)
1847
- private_class_method :run_setup
1848
1720
 
1849
1721
  # --json or no TTY on stdin always stays today's read-only report,
1850
1722
  # whichever command name was used — a piped/CI/agent run never blocks
@@ -1855,18 +1727,15 @@ module Portage
1855
1727
 
1856
1728
  doctor.nothing_configured?
1857
1729
  end
1858
- private_class_method :run_wizard?
1859
1730
 
1860
1731
  def self.run_setup_wizard
1861
1732
  SetupWizard.new.call
1862
1733
  end
1863
- private_class_method :run_setup_wizard
1864
1734
 
1865
1735
  def self.report_doctor(findings, json:)
1866
1736
  puts json ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
1867
1737
  findings.none?(&:warning?) ? 0 : 1
1868
1738
  end
1869
- private_class_method :report_doctor
1870
1739
 
1871
1740
  # Info findings (install method, Ruby, adapters, PATH) first, then the
1872
1741
  # warnings, which alone decide the exit code.
@@ -1877,7 +1746,6 @@ module Portage
1877
1746
  lines.concat(warnings.empty? ? ["No issues found."] : warnings.map { |f| "[#{f.check}] #{f.message}" })
1878
1747
  lines.join("\n")
1879
1748
  end
1880
- private_class_method :format_doctor
1881
1749
 
1882
1750
  # --- generate ---
1883
1751
 
@@ -1891,7 +1759,6 @@ module Portage
1891
1759
  1
1892
1760
  end
1893
1761
  end
1894
- private_class_method :run_generate
1895
1762
 
1896
1763
  def self.run_generate_adapter(rest)
1897
1764
  name, *rest = rest
@@ -1906,7 +1773,6 @@ module Portage
1906
1773
  puts "Scaffolded #{path}/"
1907
1774
  0
1908
1775
  end
1909
- private_class_method :run_generate_adapter
1910
1776
 
1911
1777
  def self.run_generate_agent_profile(rest)
1912
1778
  out = "agent-profile.json"
@@ -1926,7 +1792,6 @@ module Portage
1926
1792
  "`bundle exec rake agent_profile:purge` from the repo root — see docs/agent-profile.md."
1927
1793
  0
1928
1794
  end
1929
- private_class_method :run_generate_agent_profile
1930
1795
 
1931
1796
  # --- output ---
1932
1797
 
@@ -1942,48 +1807,41 @@ module Portage
1942
1807
  lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
1943
1808
  lines.join("\n")
1944
1809
  end
1945
- private_class_method :format_report
1946
1810
 
1947
1811
  def self.format_quote(report)
1948
1812
  lines = report[:quote_id] ? [" quote: #{report[:quote_id]}"] : []
1949
1813
  lines << " approve: #{approval_line(report[:summary])}" if report[:summary]
1950
1814
  lines
1951
1815
  end
1952
- private_class_method :format_quote
1953
1816
 
1954
1817
  def self.approval_line(summary)
1955
1818
  [Approve.describe(summary), summary[:url]].compact.join(" — ")
1956
1819
  end
1957
- private_class_method :approval_line
1958
1820
 
1959
1821
  # What the checkout holds, as opposed to the search results above it,
1960
1822
  # and where it differs from the request.
1961
1823
  def self.format_checkout(report)
1962
1824
  lines = Array(report[:items]).map { |item| " in checkout: #{item_label(item)}" }
1963
1825
  total = report_total(report)
1964
- lines << " total: #{format_amount(total, report[:currency])}" if total
1826
+ lines << " total: #{Money.format_amount(total, report[:currency])}" if total
1965
1827
  lines + Array(report[:warnings]).map { |w| " warning: #{w}" }
1966
1828
  end
1967
- private_class_method :format_checkout
1968
1829
 
1969
1830
  def self.format_decisions(decisions)
1970
1831
  decisions.map do |name, verdict|
1971
1832
  " decision #{name}: #{verdict.compact.map { |key, value| "#{key}=#{value}" }.join(' ')}"
1972
1833
  end
1973
1834
  end
1974
- private_class_method :format_decisions
1975
1835
 
1976
1836
  def self.format_handoff(handoff)
1977
1837
  lines = [" opened in browser: #{handoff[:opened]}", " notified: #{handoff[:notified]}"]
1978
1838
  lines << " notify error: #{handoff[:notify_error]}" if handoff[:notify_error]
1979
1839
  lines
1980
1840
  end
1981
- private_class_method :format_handoff
1982
1841
 
1983
1842
  def self.product_line(product)
1984
1843
  product.respond_to?(:title) ? "#{product.id}: #{product.title}" : "#{product['id']}: #{product['title']}"
1985
1844
  end
1986
- private_class_method :product_line
1987
1845
 
1988
1846
  def self.format_find(report)
1989
1847
  lines = [report[:message].to_s]
@@ -1991,7 +1849,6 @@ module Portage
1991
1849
  lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
1992
1850
  lines.join("\n")
1993
1851
  end
1994
- private_class_method :format_find
1995
1852
 
1996
1853
  def self.offer_line(offer)
1997
1854
  parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
@@ -1999,7 +1856,6 @@ module Portage
1999
1856
  parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
2000
1857
  parts.join(" — ")
2001
1858
  end
2002
- private_class_method :offer_line
2003
1859
 
2004
1860
  def self.format_compare(report)
2005
1861
  lines = [report[:message].to_s]
@@ -2007,7 +1863,6 @@ module Portage
2007
1863
  lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
2008
1864
  lines.join("\n")
2009
1865
  end
2010
- private_class_method :format_compare
2011
1866
 
2012
1867
  def self.compare_offer_line(offer)
2013
1868
  parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
@@ -2015,16 +1870,13 @@ module Portage
2015
1870
  parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
2016
1871
  parts.join(" — ")
2017
1872
  end
2018
- private_class_method :compare_offer_line
2019
1873
 
2020
1874
  def self.format_price(offer)
2021
1875
  return "price n/a" unless offer[:amount]
2022
1876
 
2023
- format_amount(offer[:amount], offer[:currency])
1877
+ Money.format_amount(offer[:amount], offer[:currency])
2024
1878
  end
2025
- private_class_method :format_price
2026
1879
 
2027
- def self.format_amount(amount, currency) = Money.format_amount(amount, currency)
2028
- private_class_method :format_amount
1880
+ private_class_method(*(singleton_methods(false) - [:run]))
2029
1881
  end
2030
1882
  end