portage-cli 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +580 -0
- data/README.md +322 -19
- data/exe/portage +5 -0
- data/exe/portage-console +3 -0
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +195 -7
- data/lib/portage/cli/dot_env.rb +117 -0
- data/lib/portage/cli/find.rb +96 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +528 -6
- metadata +59 -2
data/lib/portage/cli.rb
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
require "optparse"
|
|
2
2
|
require "json"
|
|
3
|
+
require "uri"
|
|
3
4
|
|
|
4
5
|
require_relative "cli/version"
|
|
5
6
|
require_relative "cli/user_agent"
|
|
6
7
|
require_relative "cli/shipping_profile"
|
|
7
8
|
require_relative "cli/buyer_context"
|
|
8
9
|
require_relative "cli/catalog_products"
|
|
10
|
+
require_relative "cli/agent_profile_url"
|
|
11
|
+
require_relative "cli/offer_sources"
|
|
12
|
+
require_relative "cli/index"
|
|
13
|
+
require_relative "cli/browser_import"
|
|
14
|
+
require_relative "cli/browser_profile"
|
|
9
15
|
require_relative "cli/buy"
|
|
10
16
|
require_relative "cli/find"
|
|
11
17
|
require_relative "cli/compare"
|
|
12
18
|
require_relative "cli/history"
|
|
13
19
|
require_relative "cli/payment_methods"
|
|
14
20
|
require_relative "cli/proxy_settings"
|
|
21
|
+
require_relative "cli/handoff_only"
|
|
22
|
+
require_relative "cli/handoff_target"
|
|
15
23
|
require_relative "cli/doctor"
|
|
24
|
+
require_relative "cli/setup_wizard"
|
|
16
25
|
require_relative "cli/handoff_reconciler"
|
|
17
26
|
require_relative "cli/handoff_waiter"
|
|
18
27
|
require_relative "cli/reconcile_notifier"
|
|
@@ -30,6 +39,7 @@ module Portage
|
|
|
30
39
|
usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
|
|
31
40
|
[--product-id ID] [--yes] [--dry-run]
|
|
32
41
|
[--auto-open|--no-auto-open] [--notify-webhook URL]
|
|
42
|
+
[--handoff-target default|print|profile|agent:NAME]
|
|
33
43
|
[--decision-backend jev|laya] [--min-confidence N] [--json]
|
|
34
44
|
[--wait [--wait-timeout DURATION|off]]
|
|
35
45
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
@@ -51,9 +61,20 @@ module Portage
|
|
|
51
61
|
[--velocity-count N --velocity-window-seconds N]
|
|
52
62
|
[--allow HOST ...] [--clear-allowlist]
|
|
53
63
|
portage orders reconcile [--checkout ID] [--json]
|
|
64
|
+
portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
65
|
+
portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
66
|
+
portage index show [--stores|--products] [--json]
|
|
67
|
+
portage index add <url> [--json]
|
|
68
|
+
portage index remove <host> [--json]
|
|
69
|
+
portage index sources [--json]
|
|
70
|
+
portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
|
|
71
|
+
[--history-days 90] [--include-product-pages] [--max-probes 200]
|
|
72
|
+
[--exclude HOST,HOST] [--dry-run] [--yes] [--json]
|
|
73
|
+
portage browser profile init|open|status [--browser chrome|edge|brave|arc] [--port N]
|
|
74
|
+
[--url URL (open only)] [--json]
|
|
54
75
|
portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
|
|
55
76
|
portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
|
|
56
|
-
portage setup [--
|
|
77
|
+
portage setup [--json] (interactive wizard on a TTY; --json/no TTY: today's doctor report)
|
|
57
78
|
portage generate adapter NAME [--dir DIR]
|
|
58
79
|
portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
|
|
59
80
|
portage --version
|
|
@@ -66,8 +87,9 @@ module Portage
|
|
|
66
87
|
|
|
67
88
|
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
68
89
|
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
69
|
-
"orders" => :run_orders, "
|
|
70
|
-
"
|
|
90
|
+
"orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
|
|
91
|
+
"doctor" => :run_doctor,
|
|
92
|
+
"configure" => :run_doctor, "setup" => :run_setup, "generate" => :run_generate }.freeze
|
|
71
93
|
|
|
72
94
|
VERSION_FLAGS = %w[--version -v version].freeze
|
|
73
95
|
|
|
@@ -220,12 +242,27 @@ module Portage
|
|
|
220
242
|
url = parsed[:buy][:url] || parsed[:store]
|
|
221
243
|
parsed[:confidence_check] = confidence_check(parsed, url)
|
|
222
244
|
return 1 unless parsed[:confidence_check]
|
|
245
|
+
|
|
246
|
+
parsed[:handoff_target] = handoff_target(parsed, url)
|
|
247
|
+
return 1 unless parsed[:handoff_target]
|
|
223
248
|
return execute_buy(parsed, url) if url
|
|
224
249
|
|
|
225
250
|
buy_from_search(parsed)
|
|
226
251
|
end
|
|
227
252
|
private_class_method :run_buy
|
|
228
253
|
|
|
254
|
+
# Built and validated up front, same posture as #confidence_check — an
|
|
255
|
+
# unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
|
|
256
|
+
# usage error, reported before a checkout is ever attempted rather than
|
|
257
|
+
# discovered mid hand-off (docs/plans/buy-skill-and-local-browser.md
|
|
258
|
+
# Phase 5).
|
|
259
|
+
def self.handoff_target(parsed, url)
|
|
260
|
+
HandoffTarget.new(override: parsed[:buy][:handoff_target])
|
|
261
|
+
rescue ArgumentError => e
|
|
262
|
+
invalid_buy_option(e.message, url: url, json: parsed[:json])
|
|
263
|
+
end
|
|
264
|
+
private_class_method :handoff_target
|
|
265
|
+
|
|
229
266
|
# `portage buy` with no URL: search first, then buy from the store the
|
|
230
267
|
# caller picks. `--yes` alone deliberately isn't enough to get here —
|
|
231
268
|
# without a URL the merchant would have been chosen by a search ranker
|
|
@@ -260,8 +297,10 @@ module Portage
|
|
|
260
297
|
private_class_method :prompt_for_offer
|
|
261
298
|
|
|
262
299
|
def self.execute_buy(parsed, url, product_id: nil)
|
|
263
|
-
options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check]
|
|
300
|
+
options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
|
|
301
|
+
handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
|
|
264
302
|
options[:product_id] ||= product_id
|
|
303
|
+
options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
|
|
265
304
|
report = Buy.new(**options).call
|
|
266
305
|
record_buy(report, options[:query])
|
|
267
306
|
result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
|
|
@@ -270,6 +309,64 @@ module Portage
|
|
|
270
309
|
end
|
|
271
310
|
private_class_method :execute_buy
|
|
272
311
|
|
|
312
|
+
# docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
|
|
313
|
+
# profile` gives `portage buy` a browser of its own — the Portage
|
|
314
|
+
# profile — so it's attached here as Buy's `webmcp_bridge:`, exactly
|
|
315
|
+
# the seam Buy#initialize's own doc comment already names as "the only
|
|
316
|
+
# option from the portage buy CLI" before this phase existed. Never
|
|
317
|
+
# raises: any failure to attach (portage-ucp-webmcp not installed, the
|
|
318
|
+
# profile not running, a bad target) just means Buy runs with no
|
|
319
|
+
# bridge at all — its dead-end hand-off to "profile" then reports that
|
|
320
|
+
# the browser isn't attached (see #dispatch_to_target's "profile"
|
|
321
|
+
# case) rather than this crashing the whole buy.
|
|
322
|
+
def self.profile_webmcp_bridge(url)
|
|
323
|
+
return nil unless Webmcp.available?
|
|
324
|
+
|
|
325
|
+
profile = BrowserProfile::Profile.new
|
|
326
|
+
return nil unless profile.status[:running]
|
|
327
|
+
|
|
328
|
+
full_url = absolute_url(url)
|
|
329
|
+
target = browser_profile_target(profile, full_url)
|
|
330
|
+
ws_url = target && target["webSocketDebuggerUrl"]
|
|
331
|
+
return nil unless ws_url
|
|
332
|
+
|
|
333
|
+
socket = BrowserProfile::CdpSocket.connect(ws_url)
|
|
334
|
+
allowlist = BrowserProfile::Allowlist.new(hosts: [URI(full_url).host])
|
|
335
|
+
BrowserProfile::Bridge.new(socket: socket, allowlist: allowlist)
|
|
336
|
+
rescue StandardError
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
private_class_method :profile_webmcp_bridge
|
|
340
|
+
|
|
341
|
+
# Same "bare host gets an https:// prefix" normalization Buy#initialize
|
|
342
|
+
# applies to the same `url` — done again here since this runs before
|
|
343
|
+
# Buy exists to do it, and a bare host like "shop.example" isn't a
|
|
344
|
+
# URI CDP's own `/json/new` or Runtime.evaluate's `window.location`
|
|
345
|
+
# comparisons can parse a host out of otherwise.
|
|
346
|
+
def self.absolute_url(url)
|
|
347
|
+
raw = url.to_s.strip
|
|
348
|
+
raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}"
|
|
349
|
+
end
|
|
350
|
+
private_class_method :absolute_url
|
|
351
|
+
|
|
352
|
+
# An existing tab already on this store's host, so a shopper who's
|
|
353
|
+
# mid-session there isn't yanked to a fresh one; otherwise a brand new
|
|
354
|
+
# tab navigated straight to `url`.
|
|
355
|
+
def self.browser_profile_target(profile, url)
|
|
356
|
+
host = URI(url).host
|
|
357
|
+
existing = BrowserProfile::Cdp.list(port: profile.port)
|
|
358
|
+
.find { |t| t["type"] == "page" && same_host?(t["url"], host) }
|
|
359
|
+
existing || BrowserProfile::Cdp.new_tab(port: profile.port, url: url)
|
|
360
|
+
end
|
|
361
|
+
private_class_method :browser_profile_target
|
|
362
|
+
|
|
363
|
+
def self.same_host?(url, host)
|
|
364
|
+
URI(url.to_s).host == host
|
|
365
|
+
rescue URI::InvalidURIError
|
|
366
|
+
false
|
|
367
|
+
end
|
|
368
|
+
private_class_method :same_host?
|
|
369
|
+
|
|
273
370
|
# docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`. A
|
|
274
371
|
# no-op (returns nil) whenever there's nothing to wait on: --dry-run
|
|
275
372
|
# never hands off at all, and a completed/browse-only/dead-end report
|
|
@@ -411,6 +508,7 @@ module Portage
|
|
|
411
508
|
parser = buy_option_parser(buy, parsed)
|
|
412
509
|
ProxySettings.add_options(parser, parsed[:proxy])
|
|
413
510
|
parser.parse!(argv)
|
|
511
|
+
url = reinterpret_bare_query(url, buy, parsed)
|
|
414
512
|
buy[:query] ||= ""
|
|
415
513
|
return parsed if url || !buy[:query].strip.empty?
|
|
416
514
|
|
|
@@ -421,6 +519,24 @@ module Portage
|
|
|
421
519
|
end
|
|
422
520
|
private_class_method :parse_buy_options
|
|
423
521
|
|
|
522
|
+
# Bare arg is normally the store URL (`portage buy <url> --query "..."`),
|
|
523
|
+
# but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
|
|
524
|
+
# a URL/domain — means the same thing as `portage buy --query "coffee"`:
|
|
525
|
+
# search first, then buy from whatever the caller picks. Only reinterpret
|
|
526
|
+
# when no --query was already given, so `portage buy shop.com --query
|
|
527
|
+
# "coffee"` keeps treating "shop.com" as the store.
|
|
528
|
+
def self.reinterpret_bare_query(url, buy, parsed)
|
|
529
|
+
return url unless url && buy[:query].to_s.strip.empty? && !url_like?(url)
|
|
530
|
+
|
|
531
|
+
buy[:url] = nil
|
|
532
|
+
parsed[:find][:query] = buy[:query] = url
|
|
533
|
+
nil
|
|
534
|
+
end
|
|
535
|
+
private_class_method :reinterpret_bare_query
|
|
536
|
+
|
|
537
|
+
def self.url_like?(text) = text.match?(%r{\A[a-z][a-z0-9+.-]*://}i) || text.include?(".")
|
|
538
|
+
private_class_method :url_like?
|
|
539
|
+
|
|
424
540
|
def self.buy_option_parser(buy, parsed)
|
|
425
541
|
OptionParser.new do |parser|
|
|
426
542
|
parser.on("--qty N", Integer) { |v| buy[:qty] = v }
|
|
@@ -428,6 +544,7 @@ module Portage
|
|
|
428
544
|
parser.on("--product-id ID") { |v| buy[:product_id] = v }
|
|
429
545
|
parser.on("--yes") { buy[:yes] = true }
|
|
430
546
|
parser.on("--dry-run") { buy[:dry_run] = true }
|
|
547
|
+
parser.on("--autofill") { buy[:autofill] = true }
|
|
431
548
|
parser.on("--json") { parsed[:json] = true }
|
|
432
549
|
add_handoff_options(parser, buy)
|
|
433
550
|
add_wait_options(parser, parsed)
|
|
@@ -448,6 +565,7 @@ module Portage
|
|
|
448
565
|
def self.add_handoff_options(parser, buy)
|
|
449
566
|
parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
|
|
450
567
|
parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
|
|
568
|
+
parser.on("--handoff-target TARGET") { |v| buy[:handoff_target] = v }
|
|
451
569
|
end
|
|
452
570
|
private_class_method :add_handoff_options
|
|
453
571
|
|
|
@@ -757,6 +875,9 @@ module Portage
|
|
|
757
875
|
when "pending"
|
|
758
876
|
"Timed out waiting for enrollment — finish it at #{result[:setup_url]}, then run " \
|
|
759
877
|
"`portage payment enroll` again."
|
|
878
|
+
when "handoff_only"
|
|
879
|
+
"#{result[:host]} is hand-off only — there's nothing to set up here. Sign in and add a card " \
|
|
880
|
+
"on #{result[:host]} yourself."
|
|
760
881
|
else "This store doesn't support payment enrollment."
|
|
761
882
|
end
|
|
762
883
|
end
|
|
@@ -833,6 +954,379 @@ module Portage
|
|
|
833
954
|
end
|
|
834
955
|
private_class_method :format_reconcile_result
|
|
835
956
|
|
|
957
|
+
# --- index (docs/plans/buy-skill-and-local-browser.md Phase 2b) ---
|
|
958
|
+
|
|
959
|
+
INDEX_SUBCOMMANDS = {
|
|
960
|
+
"build" => ->(argv) { run_index_build(argv, refresh: false) },
|
|
961
|
+
"refresh" => ->(argv) { run_index_build(argv, refresh: true) },
|
|
962
|
+
"show" => ->(argv) { run_index_show(argv) },
|
|
963
|
+
"add" => ->(argv) { run_index_add(argv) },
|
|
964
|
+
"remove" => ->(argv) { run_index_remove(argv) },
|
|
965
|
+
"sources" => ->(argv) { run_index_sources(argv) }
|
|
966
|
+
}.freeze
|
|
967
|
+
|
|
968
|
+
def self.run_index(argv)
|
|
969
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
970
|
+
return INDEX_SUBCOMMANDS[sub].call(argv) if INDEX_SUBCOMMANDS.key?(sub)
|
|
971
|
+
|
|
972
|
+
warn USAGE
|
|
973
|
+
1
|
|
974
|
+
end
|
|
975
|
+
private_class_method :run_index
|
|
976
|
+
|
|
977
|
+
def self.parse_index_build_options(argv)
|
|
978
|
+
opts = { sources: nil, queries: nil, dry_run: false, json: false, export: nil }
|
|
979
|
+
OptionParser.new do |parser|
|
|
980
|
+
parser.on("--sources LIST") { |v| opts[:sources] = v.split(",").map(&:strip) }
|
|
981
|
+
parser.on("--queries FILE") { |v| opts[:queries] = v }
|
|
982
|
+
parser.on("--dry-run") { opts[:dry_run] = true }
|
|
983
|
+
parser.on("--export DIR") { |v| opts[:export] = v }
|
|
984
|
+
parser.on("--json") { opts[:json] = true }
|
|
985
|
+
end.parse!(argv)
|
|
986
|
+
opts[:queries] &&= File.readlines(opts[:queries]).map(&:strip).reject(&:empty?)
|
|
987
|
+
opts
|
|
988
|
+
end
|
|
989
|
+
private_class_method :parse_index_build_options
|
|
990
|
+
|
|
991
|
+
def self.run_index_build(argv, refresh:)
|
|
992
|
+
opts = parse_index_build_options(argv)
|
|
993
|
+
builder = Index::Builder.new(sources: index_sources(opts[:sources]), out: opts[:json] ? nil : $stdout)
|
|
994
|
+
result = if refresh
|
|
995
|
+
builder.refresh(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
|
|
996
|
+
else
|
|
997
|
+
builder.build(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
|
|
998
|
+
end
|
|
999
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_index_build(result)
|
|
1000
|
+
0
|
|
1001
|
+
end
|
|
1002
|
+
private_class_method :run_index_build
|
|
1003
|
+
|
|
1004
|
+
def self.index_sources(names) = names ? Index::Sources.by_name(names) : nil
|
|
1005
|
+
private_class_method :index_sources
|
|
1006
|
+
|
|
1007
|
+
def self.format_index_build(result)
|
|
1008
|
+
lines = ["Ran #{result[:sources_run].join(', ')} — #{result[:candidates]} candidate(s)."]
|
|
1009
|
+
lines << "Checked #{result[:new_origins_checked].length} new origin(s), " \
|
|
1010
|
+
"#{result[:verified].length} verified UCP."
|
|
1011
|
+
lines << "Hit the #{Index::Builder::MAX_NEW_PROBES}-probe cap for this run." if result[:capped]
|
|
1012
|
+
lines << "#{result[:products_added]} product sighting(s) recorded." if result[:products_added]
|
|
1013
|
+
if result[:exported]
|
|
1014
|
+
lines << "Exported #{result[:exported][:stores]} store(s), #{result[:exported][:products]} " \
|
|
1015
|
+
"product(s) to #{result[:exported][:dir]}."
|
|
1016
|
+
end
|
|
1017
|
+
lines.join("\n")
|
|
1018
|
+
end
|
|
1019
|
+
private_class_method :format_index_build
|
|
1020
|
+
|
|
1021
|
+
def self.run_index_show(argv)
|
|
1022
|
+
opts = { kind: nil, json: false }
|
|
1023
|
+
OptionParser.new do |parser|
|
|
1024
|
+
parser.on("--stores") { opts[:kind] = "stores" }
|
|
1025
|
+
parser.on("--products") { opts[:kind] = "products" }
|
|
1026
|
+
parser.on("--json") { opts[:json] = true }
|
|
1027
|
+
end.parse!(argv)
|
|
1028
|
+
|
|
1029
|
+
result = { stores: opts[:kind] == "products" ? [] : Index::Store.new.all,
|
|
1030
|
+
products: opts[:kind] == "stores" ? [] : Index::ProductStore.new.all }
|
|
1031
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
|
|
1032
|
+
0
|
|
1033
|
+
end
|
|
1034
|
+
private_class_method :run_index_show
|
|
1035
|
+
|
|
1036
|
+
def self.format_index_show(result)
|
|
1037
|
+
lines = ["Stores:"]
|
|
1038
|
+
result[:stores].each { |s| lines << " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
|
|
1039
|
+
lines << "(none)" if result[:stores].empty?
|
|
1040
|
+
lines << "Products:"
|
|
1041
|
+
result[:products].each { |p| lines << " #{p['title'] || p['key']}" }
|
|
1042
|
+
lines << "(none)" if result[:products].empty?
|
|
1043
|
+
lines.join("\n")
|
|
1044
|
+
end
|
|
1045
|
+
private_class_method :format_index_show
|
|
1046
|
+
|
|
1047
|
+
def self.run_index_add(argv)
|
|
1048
|
+
json = argv.delete("--json") ? true : false
|
|
1049
|
+
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1050
|
+
unless url
|
|
1051
|
+
warn USAGE
|
|
1052
|
+
return 1
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
result = Index::Builder.new.add(url)
|
|
1056
|
+
puts json ? JSON.pretty_generate(result) : result[:message]
|
|
1057
|
+
result[:added] ? 0 : 1
|
|
1058
|
+
end
|
|
1059
|
+
private_class_method :run_index_add
|
|
1060
|
+
|
|
1061
|
+
def self.run_index_remove(argv)
|
|
1062
|
+
json = argv.delete("--json") ? true : false
|
|
1063
|
+
host = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1064
|
+
unless host
|
|
1065
|
+
warn USAGE
|
|
1066
|
+
return 1
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
result = Index::Builder.new.remove(host)
|
|
1070
|
+
puts json ? JSON.pretty_generate(result) : result[:message]
|
|
1071
|
+
result[:removed] ? 0 : 1
|
|
1072
|
+
end
|
|
1073
|
+
private_class_method :run_index_remove
|
|
1074
|
+
|
|
1075
|
+
def self.run_index_sources(argv)
|
|
1076
|
+
json = argv.delete("--json") ? true : false
|
|
1077
|
+
sources = Index::Sources.all.map { |s| { name: s.name, description: s.description, path: s.source_path } }
|
|
1078
|
+
puts json ? JSON.pretty_generate(sources) : format_index_sources(sources)
|
|
1079
|
+
0
|
|
1080
|
+
end
|
|
1081
|
+
private_class_method :run_index_sources
|
|
1082
|
+
|
|
1083
|
+
def self.format_index_sources(sources)
|
|
1084
|
+
sources.map { |s| "#{s[:name]}: #{s[:description]}#{" (#{s[:path]})" if s[:path]}" }.join("\n")
|
|
1085
|
+
end
|
|
1086
|
+
private_class_method :format_index_sources
|
|
1087
|
+
|
|
1088
|
+
# --- browser (docs/plans/buy-skill-and-local-browser.md Phase 3) ---
|
|
1089
|
+
|
|
1090
|
+
BROWSER_SUBCOMMANDS = { "import" => ->(argv) { run_browser_import(argv) },
|
|
1091
|
+
"profile" => ->(argv) { run_browser_profile(argv) } }.freeze
|
|
1092
|
+
|
|
1093
|
+
def self.run_browser(argv)
|
|
1094
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1095
|
+
return BROWSER_SUBCOMMANDS[sub].call(argv) if BROWSER_SUBCOMMANDS.key?(sub)
|
|
1096
|
+
|
|
1097
|
+
warn USAGE
|
|
1098
|
+
1
|
|
1099
|
+
end
|
|
1100
|
+
private_class_method :run_browser
|
|
1101
|
+
|
|
1102
|
+
def self.parse_browser_import_options(argv)
|
|
1103
|
+
opts = { browser: nil, root: nil, history_days: BrowserImport::Importer::DEFAULT_HISTORY_DAYS,
|
|
1104
|
+
include_product_pages: false, max_probes: BrowserImport::Importer::MAX_PROBES, exclude: [],
|
|
1105
|
+
dry_run: false, yes: false, json: false }
|
|
1106
|
+
browser_import_option_parser(opts).parse!(argv)
|
|
1107
|
+
opts
|
|
1108
|
+
end
|
|
1109
|
+
private_class_method :parse_browser_import_options
|
|
1110
|
+
|
|
1111
|
+
def self.browser_import_option_parser(opts)
|
|
1112
|
+
OptionParser.new do |parser|
|
|
1113
|
+
parser.on("--browser NAME", BrowserImport::Profiles::BROWSERS) { |v| opts[:browser] = v }
|
|
1114
|
+
parser.on("--profile-root DIR") { |v| opts[:root] = File.expand_path(v) }
|
|
1115
|
+
parser.on("--history-days N", Integer) { |v| opts[:history_days] = v }
|
|
1116
|
+
parser.on("--max-probes N", Integer) { |v| opts[:max_probes] = v }
|
|
1117
|
+
parser.on("--exclude HOSTS", Array) { |v| opts[:exclude] = v.map(&:strip) }
|
|
1118
|
+
%i[include_product_pages dry_run yes json].each do |flag|
|
|
1119
|
+
parser.on("--#{flag.to_s.tr('_', '-')}") { opts[flag] = true }
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
end
|
|
1123
|
+
private_class_method :browser_import_option_parser
|
|
1124
|
+
|
|
1125
|
+
# Reads, reduces and probes (BrowserImport::Importer#plan), shows the
|
|
1126
|
+
# list, and saves only through BrowserImport::Confirm's gate: `--yes`,
|
|
1127
|
+
# or a "y" at a real TTY prompt. Under `--json`/no TTY without `--yes`
|
|
1128
|
+
# nothing is written — the report says `saved: false,
|
|
1129
|
+
# needs_confirmation: true` and exits 0, so an agent shows the user the
|
|
1130
|
+
# list and re-runs with `--yes` only once they've approved it.
|
|
1131
|
+
def self.run_browser_import(argv)
|
|
1132
|
+
opts = parse_browser_import_options(argv)
|
|
1133
|
+
plan = browser_importer.plan(browser_import_options(opts))
|
|
1134
|
+
return report_browser_import(plan, opts, nil) if plan[:error]
|
|
1135
|
+
|
|
1136
|
+
puts format_browser_import(plan) unless opts[:json]
|
|
1137
|
+
interactive = !opts[:json] && $stdin.tty?
|
|
1138
|
+
decision = BrowserImport::Confirm.new(interactive: interactive).call(plan, yes: opts[:yes],
|
|
1139
|
+
dry_run: opts[:dry_run])
|
|
1140
|
+
saved = decision == :save ? browser_importer.save(plan) : nil
|
|
1141
|
+
report_browser_import(plan, opts, decision, saved)
|
|
1142
|
+
rescue OptionParser::ParseError => e
|
|
1143
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1144
|
+
1
|
|
1145
|
+
end
|
|
1146
|
+
private_class_method :run_browser_import
|
|
1147
|
+
|
|
1148
|
+
# The real HandoffOnly list (docs/plans/buy-skill-and-local-browser.md
|
|
1149
|
+
# Phase 5) into Importer's own injectable `handoff_only_hosts:` seam
|
|
1150
|
+
# (Phase 3 left it defaulting to `[]` for exactly this) — a history/
|
|
1151
|
+
# bookmark domain on the list is kept as `handoff_only: true` and never
|
|
1152
|
+
# probed.
|
|
1153
|
+
def self.browser_importer
|
|
1154
|
+
BrowserImport::Importer.new(handoff_only_hosts: HandoffOnly.new.hosts)
|
|
1155
|
+
end
|
|
1156
|
+
private_class_method :browser_importer
|
|
1157
|
+
|
|
1158
|
+
def self.browser_import_options(opts)
|
|
1159
|
+
browser = opts[:browser] || BrowserImport::Profiles.detect || "chrome"
|
|
1160
|
+
BrowserImport::Importer::Options.new(
|
|
1161
|
+
browser: browser, root: opts[:root] || BrowserImport::Profiles.default_root(browser),
|
|
1162
|
+
history_days: opts[:history_days], include_product_pages: opts[:include_product_pages],
|
|
1163
|
+
max_probes: opts[:max_probes], exclude: opts[:exclude]
|
|
1164
|
+
)
|
|
1165
|
+
end
|
|
1166
|
+
private_class_method :browser_import_options
|
|
1167
|
+
|
|
1168
|
+
BROWSER_IMPORT_MESSAGES = {
|
|
1169
|
+
dry_run: "Dry run — nothing saved.",
|
|
1170
|
+
nothing: "No shop domains to save.",
|
|
1171
|
+
declined: "Nothing saved.",
|
|
1172
|
+
needs_confirmation: "Nothing saved: there's no terminal to confirm on. Show this list to the user, then " \
|
|
1173
|
+
"re-run with --yes once they've approved it (--exclude HOST,... drops any they don't want)."
|
|
1174
|
+
}.freeze
|
|
1175
|
+
|
|
1176
|
+
def self.report_browser_import(plan, opts, decision, saved = nil)
|
|
1177
|
+
message = plan[:message] || browser_import_message(decision, saved)
|
|
1178
|
+
if opts[:json]
|
|
1179
|
+
puts JSON.pretty_generate(plan.merge(saved: !saved.nil?, needs_confirmation: decision == :needs_confirmation,
|
|
1180
|
+
message: message))
|
|
1181
|
+
else
|
|
1182
|
+
puts message
|
|
1183
|
+
end
|
|
1184
|
+
plan[:error] ? 1 : 0
|
|
1185
|
+
end
|
|
1186
|
+
private_class_method :report_browser_import
|
|
1187
|
+
|
|
1188
|
+
def self.browser_import_message(decision, saved)
|
|
1189
|
+
return "Saved #{saved[:stores]} store(s) and #{saved[:products]} product(s) to your local index." if saved
|
|
1190
|
+
|
|
1191
|
+
BROWSER_IMPORT_MESSAGES.fetch(decision, "Nothing saved.")
|
|
1192
|
+
end
|
|
1193
|
+
private_class_method :browser_import_message
|
|
1194
|
+
|
|
1195
|
+
def self.format_browser_import(plan)
|
|
1196
|
+
lines = browser_import_counts(plan)
|
|
1197
|
+
lines << "Shops found (#{plan[:kept].length}):"
|
|
1198
|
+
plan[:kept].each { |entry| lines << " #{browser_import_line(entry)}" }
|
|
1199
|
+
lines << " (none)" if plan[:kept].empty?
|
|
1200
|
+
lines << "#{plan[:products].length} product page(s) to keep." if plan[:products].any?
|
|
1201
|
+
lines.join("\n")
|
|
1202
|
+
end
|
|
1203
|
+
private_class_method :format_browser_import
|
|
1204
|
+
|
|
1205
|
+
def self.browser_import_counts(plan)
|
|
1206
|
+
skipped = plan[:skipped].map { |reason, n| "#{n} #{reason}" }.join(", ")
|
|
1207
|
+
lines = ["Read #{plan[:rows][:history]} history and #{plan[:rows][:bookmark]} bookmark row(s) across " \
|
|
1208
|
+
"#{plan[:domains]} domain(s) from #{plan[:browser]}.",
|
|
1209
|
+
"Skipped #{skipped.empty? ? 'none' : skipped}; probed #{plan[:probed]} " \
|
|
1210
|
+
"(#{plan[:not_ucp]} without UCP, #{plan[:cached_miss]} already known not to)."]
|
|
1211
|
+
lines << "Hit the #{plan[:probed]}-probe cap; #{plan[:unprobed]} domain(s) left unprobed." if plan[:capped]
|
|
1212
|
+
lines
|
|
1213
|
+
end
|
|
1214
|
+
private_class_method :browser_import_counts
|
|
1215
|
+
|
|
1216
|
+
def self.browser_import_line(entry)
|
|
1217
|
+
categories = entry[:category_names].empty? ? "uncategorised" : entry[:category_names].join(", ")
|
|
1218
|
+
"#{entry[:domain]} — #{entry[:verdict]} — #{categories} " \
|
|
1219
|
+
"(#{entry[:sources].join(', ')}, #{entry[:visits]} visit(s))"
|
|
1220
|
+
end
|
|
1221
|
+
private_class_method :browser_import_line
|
|
1222
|
+
|
|
1223
|
+
# --- browser profile (docs/plans/buy-skill-and-local-browser.md Phase 6) ---
|
|
1224
|
+
|
|
1225
|
+
BROWSER_PROFILE_SUBCOMMANDS = {
|
|
1226
|
+
"init" => ->(argv) { run_browser_profile_init(argv) },
|
|
1227
|
+
"open" => ->(argv) { run_browser_profile_open(argv) },
|
|
1228
|
+
"status" => ->(argv) { run_browser_profile_status(argv) }
|
|
1229
|
+
}.freeze
|
|
1230
|
+
|
|
1231
|
+
def self.run_browser_profile(argv)
|
|
1232
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1233
|
+
return browser_profile_usage unless BROWSER_PROFILE_SUBCOMMANDS.key?(sub)
|
|
1234
|
+
|
|
1235
|
+
BROWSER_PROFILE_SUBCOMMANDS[sub].call(argv)
|
|
1236
|
+
end
|
|
1237
|
+
private_class_method :run_browser_profile
|
|
1238
|
+
|
|
1239
|
+
def self.browser_profile_usage
|
|
1240
|
+
warn USAGE
|
|
1241
|
+
1
|
|
1242
|
+
end
|
|
1243
|
+
private_class_method :browser_profile_usage
|
|
1244
|
+
|
|
1245
|
+
def self.parse_browser_profile_options(argv)
|
|
1246
|
+
opts = { browser: nil, port: BrowserProfile::Profile::DEFAULT_PORT, url: nil, json: false }
|
|
1247
|
+
OptionParser.new do |parser|
|
|
1248
|
+
parser.on("--browser NAME", BrowserProfile::Browsers::CHROMIUM) { |v| opts[:browser] = v }
|
|
1249
|
+
parser.on("--port N", Integer) { |v| opts[:port] = v }
|
|
1250
|
+
parser.on("--url URL") { |v| opts[:url] = v }
|
|
1251
|
+
parser.on("--json") { opts[:json] = true }
|
|
1252
|
+
end.parse!(argv)
|
|
1253
|
+
opts
|
|
1254
|
+
end
|
|
1255
|
+
private_class_method :parse_browser_profile_options
|
|
1256
|
+
|
|
1257
|
+
# `--browser` names the exact browser; without it, the first Chromium
|
|
1258
|
+
# family browser BrowserImport::Profiles finds installed, falling back
|
|
1259
|
+
# to "chrome" — same "pick something reasonable, let --browser
|
|
1260
|
+
# override" posture as run_browser_import's own default.
|
|
1261
|
+
def self.browser_profile_for(opts)
|
|
1262
|
+
browser = opts[:browser] || BrowserProfile::Browsers.detect || "chrome"
|
|
1263
|
+
BrowserProfile::Profile.new(browser: browser, port: opts[:port])
|
|
1264
|
+
end
|
|
1265
|
+
private_class_method :browser_profile_for
|
|
1266
|
+
|
|
1267
|
+
def self.run_browser_profile_init(argv)
|
|
1268
|
+
opts = parse_browser_profile_options(argv)
|
|
1269
|
+
result = browser_profile_for(opts).init!
|
|
1270
|
+
puts opts[:json] ? JSON.pretty_generate(result) : "Profile ready at #{result[:dir]} (#{result[:browser]})."
|
|
1271
|
+
0
|
|
1272
|
+
rescue OptionParser::ParseError => e
|
|
1273
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1274
|
+
1
|
|
1275
|
+
end
|
|
1276
|
+
private_class_method :run_browser_profile_init
|
|
1277
|
+
|
|
1278
|
+
# Launches the profile (if it isn't already running on its own port)
|
|
1279
|
+
# and either opens a new tab at --url or attaches to the first
|
|
1280
|
+
# existing one. Never touches the browser's default profile — Profile
|
|
1281
|
+
# itself only ever points --user-data-dir at its own dedicated
|
|
1282
|
+
# directory.
|
|
1283
|
+
def self.run_browser_profile_open(argv)
|
|
1284
|
+
opts = parse_browser_profile_options(argv)
|
|
1285
|
+
result = browser_profile_for(opts).open!(url: opts[:url])
|
|
1286
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_open(result)
|
|
1287
|
+
0
|
|
1288
|
+
rescue BrowserProfile::Error => e
|
|
1289
|
+
report_browser_profile_error(e, opts[:json])
|
|
1290
|
+
rescue OptionParser::ParseError => e
|
|
1291
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1292
|
+
1
|
|
1293
|
+
end
|
|
1294
|
+
private_class_method :run_browser_profile_open
|
|
1295
|
+
|
|
1296
|
+
def self.run_browser_profile_status(argv)
|
|
1297
|
+
opts = parse_browser_profile_options(argv)
|
|
1298
|
+
result = browser_profile_for(opts).status
|
|
1299
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_status(result)
|
|
1300
|
+
0
|
|
1301
|
+
rescue OptionParser::ParseError => e
|
|
1302
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1303
|
+
1
|
|
1304
|
+
end
|
|
1305
|
+
private_class_method :run_browser_profile_status
|
|
1306
|
+
|
|
1307
|
+
def self.format_browser_profile_open(result)
|
|
1308
|
+
tab = result.dig(:target, "url")
|
|
1309
|
+
"#{result[:browser]} profile is open (port #{result[:port]}, #{result[:dir]})#{" — tab: #{tab}" if tab}."
|
|
1310
|
+
end
|
|
1311
|
+
private_class_method :format_browser_profile_open
|
|
1312
|
+
|
|
1313
|
+
def self.format_browser_profile_status(result)
|
|
1314
|
+
return "#{result[:browser]} profile (#{result[:dir]}) isn't running." unless result[:running]
|
|
1315
|
+
|
|
1316
|
+
"#{result[:browser]} profile is running on port #{result[:port]} (#{result[:dir]})."
|
|
1317
|
+
end
|
|
1318
|
+
private_class_method :format_browser_profile_status
|
|
1319
|
+
|
|
1320
|
+
def self.report_browser_profile_error(error, json)
|
|
1321
|
+
if json
|
|
1322
|
+
puts JSON.pretty_generate(error: error.class.name.split("::").last, message: error.message)
|
|
1323
|
+
else
|
|
1324
|
+
warn error.message
|
|
1325
|
+
end
|
|
1326
|
+
1
|
|
1327
|
+
end
|
|
1328
|
+
private_class_method :report_browser_profile_error
|
|
1329
|
+
|
|
836
1330
|
# --- doctor ---
|
|
837
1331
|
|
|
838
1332
|
def self.parse_doctor_options(argv)
|
|
@@ -847,17 +1341,45 @@ module Portage
|
|
|
847
1341
|
end
|
|
848
1342
|
private_class_method :parse_doctor_options
|
|
849
1343
|
|
|
850
|
-
|
|
1344
|
+
# `wizard: :force` is `portage setup`, always offering the wizard on a
|
|
1345
|
+
# TTY; `:auto` is `doctor`/`configure`, which only offers it when
|
|
1346
|
+
# Doctor#nothing_configured? — the read-only report is what every other
|
|
1347
|
+
# run of `doctor` still gets, exactly as before this phase
|
|
1348
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 4).
|
|
1349
|
+
def self.run_doctor(argv, wizard: :auto)
|
|
851
1350
|
opts = parse_doctor_options(argv)
|
|
852
1351
|
require File.expand_path(opts[:require]) if opts[:require]
|
|
853
1352
|
adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
|
|
854
1353
|
proxy_settings = apply_proxy_settings(opts[:proxy])
|
|
855
1354
|
return 1 unless proxy_settings
|
|
856
1355
|
|
|
857
|
-
|
|
1356
|
+
doctor = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings,
|
|
1357
|
+
seller: !(opts[:require] || opts[:adapter]).nil?)
|
|
1358
|
+
return run_setup_wizard if run_wizard?(wizard, opts, doctor)
|
|
1359
|
+
|
|
1360
|
+
report_doctor(doctor.call, json: opts[:json])
|
|
858
1361
|
end
|
|
859
1362
|
private_class_method :run_doctor
|
|
860
1363
|
|
|
1364
|
+
def self.run_setup(argv) = run_doctor(argv, wizard: :force)
|
|
1365
|
+
private_class_method :run_setup
|
|
1366
|
+
|
|
1367
|
+
# --json or no TTY on stdin always stays today's read-only report,
|
|
1368
|
+
# whichever command name was used — a piped/CI/agent run never blocks
|
|
1369
|
+
# on a prompt it can't answer.
|
|
1370
|
+
def self.run_wizard?(mode, opts, doctor)
|
|
1371
|
+
return false if opts[:json] || !$stdin.tty?
|
|
1372
|
+
return true if mode == :force
|
|
1373
|
+
|
|
1374
|
+
doctor.nothing_configured?
|
|
1375
|
+
end
|
|
1376
|
+
private_class_method :run_wizard?
|
|
1377
|
+
|
|
1378
|
+
def self.run_setup_wizard
|
|
1379
|
+
SetupWizard.new.call
|
|
1380
|
+
end
|
|
1381
|
+
private_class_method :run_setup_wizard
|
|
1382
|
+
|
|
861
1383
|
def self.report_doctor(findings, json:)
|
|
862
1384
|
puts json ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
|
|
863
1385
|
findings.none?(&:warning?) ? 0 : 1
|