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