portage-cli 0.7.5 → 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 +559 -0
- data/README.md +266 -5
- 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 +155 -1
- data/lib/portage/cli/dot_env.rb +55 -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 +525 -5
- metadata +58 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
class SetupWizard
|
|
4
|
+
module Steps
|
|
5
|
+
# Step 5 (docs/plans/buy-skill-and-local-browser.md Phase 4):
|
|
6
|
+
# `portage index build` (Phases 2b-2c). Opt-in and off by default —
|
|
7
|
+
# it's a live network sweep of Shopify's global catalog, one query
|
|
8
|
+
# per taxonomy node, which is a lot to run unasked inside a wizard.
|
|
9
|
+
# Delegates to Cli.run_index_build so the command's own sources,
|
|
10
|
+
# probe cap and progress output are exactly what a direct
|
|
11
|
+
# `portage index build` would give.
|
|
12
|
+
class IndexBuild
|
|
13
|
+
def initialize(prompt:) = @prompt = prompt
|
|
14
|
+
|
|
15
|
+
def title = "Local store index"
|
|
16
|
+
def default_yes? = false
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
@prompt.say("Builds ~/.portage/index/{stores,products}.json — stores and products `find` can " \
|
|
20
|
+
"route by on top of stores.yml, the known-stores list this repo already fetches, " \
|
|
21
|
+
"and web search. Takes a little while: one query per product-category node against " \
|
|
22
|
+
"Shopify's catalog, verifying each new store with a single /.well-known/ucp probe.")
|
|
23
|
+
return unless @prompt.confirm("Run `portage index build` now?", default: false)
|
|
24
|
+
|
|
25
|
+
Portage::Cli.send(:run_index_build, [], refresh: false)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
class SetupWizard
|
|
4
|
+
module Steps
|
|
5
|
+
# Step 6 (docs/plans/buy-skill-and-local-browser.md Phase 4):
|
|
6
|
+
# spending policy caps (Portage::Ucp::PolicyGuard), via `portage
|
|
7
|
+
# policy set` — reused, not reimplemented, so the actual cap/
|
|
8
|
+
# allowlist logic (Cli.set_policy_cap/set_policy_allowlist) has
|
|
9
|
+
# exactly one place it lives.
|
|
10
|
+
class Policy
|
|
11
|
+
def initialize(prompt:) = @prompt = prompt
|
|
12
|
+
|
|
13
|
+
def title = "Spending policy caps"
|
|
14
|
+
def default_yes? = true
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
@prompt.say("Checked before every `portage buy --yes` checkout completes. Enter skips a " \
|
|
18
|
+
"question you don't want to answer right now — nothing here is required.")
|
|
19
|
+
args = cap_args + allowlist_args
|
|
20
|
+
return @prompt.say("No policy changes made.") if args.empty?
|
|
21
|
+
|
|
22
|
+
Portage::Cli.send(:run_policy_set, args)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def cap_args
|
|
28
|
+
major = @prompt.ask("Per-transaction spending cap", hint: "major units, e.g. 200 for $200; Enter to skip")
|
|
29
|
+
return [] unless major
|
|
30
|
+
|
|
31
|
+
parsed = Float(major, exception: false)
|
|
32
|
+
return not_a_number unless parsed&.positive?
|
|
33
|
+
|
|
34
|
+
currency = @prompt.ask("Currency for that cap", hint: "e.g. USD")
|
|
35
|
+
return no_currency if currency.nil?
|
|
36
|
+
|
|
37
|
+
# Cli.to_minor_units is private — reused via `send` rather than
|
|
38
|
+
# a second implementation of major-to-minor-units conversion.
|
|
39
|
+
["--per-transaction-cap", Portage::Cli.send(:to_minor_units, parsed).to_s, "--currency", currency]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def not_a_number
|
|
43
|
+
@prompt.say("Not a number — cap not set.")
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def no_currency
|
|
48
|
+
@prompt.say("No currency given — cap not set.")
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def allowlist_args
|
|
53
|
+
hosts = @prompt.ask("Merchant allowlist hosts to add", hint: "comma-separated, Enter to skip")
|
|
54
|
+
Array(hosts&.split(",")).map(&:strip).reject(&:empty?).flat_map { |host| ["--allow", host] }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative "../../dot_env"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
class SetupWizard
|
|
6
|
+
module Steps
|
|
7
|
+
# Phase 7 (docs/plans/buy-skill-and-local-browser.md): the five
|
|
8
|
+
# official retailer offer sources (OfferSources) are each opt-in —
|
|
9
|
+
# this step is where their keys get written, same as SearchKeys
|
|
10
|
+
# does for Brave/Google CSE. Every one of these still ends in
|
|
11
|
+
# hand-off (see HandoffOnly / OfferSources.retail_handoff_host?):
|
|
12
|
+
# setting a key here only makes `find` show more real offers, it
|
|
13
|
+
# never lets `portage buy` complete a purchase at any of these
|
|
14
|
+
# retailers.
|
|
15
|
+
class RetailerKeys
|
|
16
|
+
FIELDS = {
|
|
17
|
+
"WALMART_AFFILIATE_API_KEY" => "Walmart Affiliate API key",
|
|
18
|
+
"EBAY_BROWSE_ACCESS_TOKEN" => "eBay Browse API access token (Buy It Now search only)",
|
|
19
|
+
"BESTBUY_API_KEY" => "Best Buy Products API key",
|
|
20
|
+
"ETSY_LISTINGS_API_KEY" => "Etsy Open API v3 key (buyer-side listing search)",
|
|
21
|
+
"AMAZON_CREATORS_ACCESS_TOKEN" => "Amazon Creators API access token"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(prompt:) = @prompt = prompt
|
|
25
|
+
|
|
26
|
+
def title = "Retailer offer sources"
|
|
27
|
+
def default_yes? = false
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
@prompt.say("Optional: official buyer-side APIs for Walmart, eBay (Buy It Now only), Best " \
|
|
31
|
+
"Buy, Etsy and Amazon. Each needs its own key from that retailer's developer " \
|
|
32
|
+
"program — skip any you don't have. None of these ever completes a purchase: " \
|
|
33
|
+
"every offer they return still ends in hand-off, same as Amazon today. Neither " \
|
|
34
|
+
"key is echoed back; Enter keeps whatever's already set.")
|
|
35
|
+
assignments = collect
|
|
36
|
+
return @prompt.say("Left retailer offer source keys unchanged.") if assignments.empty?
|
|
37
|
+
|
|
38
|
+
path = DotEnv.update!(assignments)
|
|
39
|
+
@prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def collect
|
|
45
|
+
FIELDS.each_with_object({}) do |(var, label), assignments|
|
|
46
|
+
hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
|
|
47
|
+
answer = @prompt.ask_secret(label, hint: hint)
|
|
48
|
+
assignments[var] = answer if answer
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require_relative "../../dot_env"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
class SetupWizard
|
|
6
|
+
module Steps
|
|
7
|
+
# Step 2 (docs/plans/buy-skill-and-local-browser.md Phase 4): Brave
|
|
8
|
+
# and Google CSE search keys, written to ~/.portage/.env. See
|
|
9
|
+
# Doctor#search_backend_finding — with neither set, `find`/`buy`
|
|
10
|
+
# with no URL only ever resolves DuckDuckGo's Instant Answer API,
|
|
11
|
+
# which is keyless but only ever matches a specific brand/product
|
|
12
|
+
# name, not an open-ended query ("coffee", "hiking boots").
|
|
13
|
+
class SearchKeys
|
|
14
|
+
FIELDS = {
|
|
15
|
+
"BRAVE_SEARCH_API_KEY" => "Brave Search API key",
|
|
16
|
+
"GOOGLE_CSE_KEY" => "Google Programmable Search API key",
|
|
17
|
+
"GOOGLE_CSE_CX" => "Google Programmable Search engine id (cx)"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Google's `cx` names which engine to query, not a credential —
|
|
21
|
+
# only the two API keys are secrets that never echo back.
|
|
22
|
+
SECRET = %w[BRAVE_SEARCH_API_KEY GOOGLE_CSE_KEY].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(prompt:) = @prompt = prompt
|
|
25
|
+
|
|
26
|
+
def title = "Search API keys"
|
|
27
|
+
def default_yes? = true
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
@prompt.say("DuckDuckGo's free API is already active and needs no key, but only resolves a " \
|
|
31
|
+
"specific brand/product name — set either Brave's key, or both Google fields, for " \
|
|
32
|
+
"real open-ended search. Neither key is echoed back; Enter keeps whatever's " \
|
|
33
|
+
"already set.")
|
|
34
|
+
assignments = collect
|
|
35
|
+
return @prompt.say("Left search API keys unchanged.") if assignments.empty?
|
|
36
|
+
|
|
37
|
+
path = DotEnv.update!(assignments)
|
|
38
|
+
@prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def collect
|
|
44
|
+
FIELDS.each_with_object({}) do |(var, label), assignments|
|
|
45
|
+
hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
|
|
46
|
+
answer = SECRET.include?(var) ? @prompt.ask_secret(label, hint: hint) : @prompt.ask(label, hint: hint)
|
|
47
|
+
assignments[var] = answer if answer
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require_relative "../../shipping_profile"
|
|
2
|
+
require_relative "../../dot_env"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
class SetupWizard
|
|
7
|
+
module Steps
|
|
8
|
+
# Step 1 (docs/plans/buy-skill-and-local-browser.md Phase 4):
|
|
9
|
+
# PORTAGE_SHIP_*, written to ~/.portage/.env. Without at least the
|
|
10
|
+
# required fields, `portage buy` sends no shipping destination to
|
|
11
|
+
# your own store's checkout, and without the country a native UCP
|
|
12
|
+
# store prices in no market and can report in-stock items as out of
|
|
13
|
+
# stock (see Doctor#shipping_finding, which this step exists to
|
|
14
|
+
# resolve).
|
|
15
|
+
class Shipping
|
|
16
|
+
LABELS = {
|
|
17
|
+
street_address: "Street address", extended_address: "Apt/suite (optional)",
|
|
18
|
+
address_locality: "City", address_region: "State/region (optional)",
|
|
19
|
+
address_country: "Country (ISO 3166-1 alpha-2, e.g. US, GB)", postal_code: "Postal code",
|
|
20
|
+
first_name: "First name (optional)", last_name: "Last name (optional)",
|
|
21
|
+
phone_number: "Phone (optional)"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(prompt:) = @prompt = prompt
|
|
25
|
+
|
|
26
|
+
def title = "Shipping address"
|
|
27
|
+
def default_yes? = true
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
@prompt.say("Enter keeps whatever's already set for a field.")
|
|
31
|
+
assignments = collect
|
|
32
|
+
return @prompt.say("Left shipping address unchanged.") if assignments.empty?
|
|
33
|
+
|
|
34
|
+
path = DotEnv.update!(assignments)
|
|
35
|
+
@prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def collect
|
|
41
|
+
ShippingProfile::ENV_VARS.each_with_object({}) do |(key, var), assignments|
|
|
42
|
+
hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
|
|
43
|
+
answer = @prompt.ask(LABELS.fetch(key), hint: hint)
|
|
44
|
+
assignments[var] = answer if answer
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative "setup_wizard/prompt"
|
|
2
|
+
require_relative "setup_wizard/steps/shipping"
|
|
3
|
+
require_relative "setup_wizard/steps/search_keys"
|
|
4
|
+
require_relative "setup_wizard/steps/retailer_keys"
|
|
5
|
+
require_relative "setup_wizard/steps/agent_profile"
|
|
6
|
+
require_relative "setup_wizard/steps/browser_import"
|
|
7
|
+
require_relative "setup_wizard/steps/index_build"
|
|
8
|
+
require_relative "setup_wizard/steps/policy"
|
|
9
|
+
require_relative "setup_wizard/steps/handoff"
|
|
10
|
+
|
|
11
|
+
module Portage
|
|
12
|
+
module Cli
|
|
13
|
+
# `portage setup`'s interactive wizard (docs/plans/
|
|
14
|
+
# buy-skill-and-local-browser.md Phase 4) — Cli.run_doctor/run_setup
|
|
15
|
+
# decide *whether* to run this (a real TTY, no --json, and either the
|
|
16
|
+
# command was `setup` outright or `doctor`/`configure` found nothing
|
|
17
|
+
# configured at all; see Doctor#nothing_configured?); this class is
|
|
18
|
+
# just the steps themselves, once that decision is already made
|
|
19
|
+
# (Phase 4 shipped seven; Phase 7 added RetailerKeys as an eighth, opt
|
|
20
|
+
# -in step between search keys and the agent profile).
|
|
21
|
+
#
|
|
22
|
+
# Every step: says what it's for, offers to skip (Enter keeps its own
|
|
23
|
+
# default — "on" for the foundational steps, "off" for the ones that
|
|
24
|
+
# are opt-in: browser import and index build's network/filesystem
|
|
25
|
+
# sweeps, and RetailerKeys' per-retailer credentials), and re-runs
|
|
26
|
+
# cleanly — Enter on any individual question inside a step
|
|
27
|
+
# always keeps whatever's already set, never clears it. No step raises
|
|
28
|
+
# its own error class: each one leans on the command it delegates to
|
|
29
|
+
# (`portage generate agent-profile`, `portage browser import`, `portage
|
|
30
|
+
# index build`, `portage policy set`) for its own validation and error
|
|
31
|
+
# reporting, so this class has nothing UCP- or network-specific to
|
|
32
|
+
# rescue.
|
|
33
|
+
class SetupWizard
|
|
34
|
+
INTRO = <<~TEXT.freeze
|
|
35
|
+
Portage setup
|
|
36
|
+
|
|
37
|
+
Walks through shipping, search, your agent profile, browser import,
|
|
38
|
+
the local store index, spending caps and hand-off. Every step can
|
|
39
|
+
be skipped, and this re-runs cleanly at any time — nothing you
|
|
40
|
+
already have set is cleared just by pressing Enter.
|
|
41
|
+
TEXT
|
|
42
|
+
|
|
43
|
+
STEPS = [Steps::Shipping, Steps::SearchKeys, Steps::RetailerKeys, Steps::AgentProfile, Steps::BrowserImport,
|
|
44
|
+
Steps::IndexBuild, Steps::Policy, Steps::Handoff].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(input: $stdin, output: $stdout)
|
|
47
|
+
@prompt = Prompt.new(input: input, output: output)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Integer] always 0 — nothing about walking through (or
|
|
51
|
+
# skipping) setup steps is a failure exit code; a step that hits a
|
|
52
|
+
# real problem (a bad flag reaching `portage policy set`, a browser
|
|
53
|
+
# import permission error) reports it itself, same as running that
|
|
54
|
+
# command directly would.
|
|
55
|
+
def call
|
|
56
|
+
@prompt.say(INTRO)
|
|
57
|
+
STEPS.each { |step_class| run_step(step_class) }
|
|
58
|
+
@prompt.say("\nDone — run `portage doctor` any time to see the current state, or `portage setup` " \
|
|
59
|
+
"again to change anything above.")
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def run_step(step_class)
|
|
66
|
+
step = step_class.new(prompt: @prompt)
|
|
67
|
+
@prompt.say("\n== #{step.title} ==")
|
|
68
|
+
return @prompt.say("Skipped.") unless @prompt.confirm("Configure this now?", default: step.default_yes?)
|
|
69
|
+
|
|
70
|
+
step.call
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED
data/lib/portage/cli/webmcp.rb
CHANGED
|
@@ -8,18 +8,25 @@ module Portage
|
|
|
8
8
|
# caller who actually passes `Buy.new(webmcp_bridge:)` ever needs it
|
|
9
9
|
# installed.
|
|
10
10
|
module Webmcp
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# WebMcp::Autofill, Presets, Matcher and Fingerprint, which the CLI
|
|
12
|
+
# calls, first shipped in portage-ucp-webmcp 0.2.0. An older install
|
|
13
|
+
# counts as not installed rather than failing with a NameError later.
|
|
14
|
+
MIN_VERSION = "0.2.0".freeze
|
|
15
|
+
|
|
16
|
+
# @return [Boolean] whether a new-enough portage-ucp-webmcp could be
|
|
17
|
+
# loaded. Memoized: `require` runs once per process.
|
|
13
18
|
def self.available?
|
|
14
19
|
return @available unless @available.nil?
|
|
15
20
|
|
|
16
21
|
@available = begin
|
|
17
22
|
require "portage/ucp/webmcp"
|
|
18
|
-
|
|
23
|
+
supported?(Portage::Ucp::WebMcp::VERSION)
|
|
19
24
|
rescue LoadError
|
|
20
25
|
false
|
|
21
26
|
end
|
|
22
27
|
end
|
|
28
|
+
|
|
29
|
+
def self.supported?(version) = Gem::Version.new(version) >= Gem::Version.new(MIN_VERSION)
|
|
23
30
|
end
|
|
24
31
|
end
|
|
25
32
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# docs/plans/webmcp-universal-outbound.md Phase 3, decision 2: even once
|
|
4
|
+
# `--autofill`/`PORTAGE_WEBMCP_AUTOFILL=approve` has opted a run in
|
|
5
|
+
# (WebmcpAutofillMode), nothing is typed into the store's checkout page
|
|
6
|
+
# until the shopper approves, in this prompt, exactly which fields and
|
|
7
|
+
# values are about to be entered — same shape as Phase 2's
|
|
8
|
+
# WebmcpMappingConfirm, applied to autofill instead of a tool mapping.
|
|
9
|
+
#
|
|
10
|
+
# Fields is always contact email + shipping address here — WebmcpFields
|
|
11
|
+
# never builds a payment one — but this class has no opinion on that;
|
|
12
|
+
# it only ever shows the shopper what it was given and asks.
|
|
13
|
+
class WebmcpAutofillConfirm
|
|
14
|
+
# @param interactive [Boolean] false under --json or with no TTY on
|
|
15
|
+
# stdin — same posture as WebmcpMappingConfirm: no interactive
|
|
16
|
+
# prompt possible means no autofill, not a guessed default.
|
|
17
|
+
def initialize(interactive:, input: $stdin, output: $stdout)
|
|
18
|
+
@interactive = interactive
|
|
19
|
+
@input = input
|
|
20
|
+
@output = output
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param fields [Hash{String=>String}] autocomplete token => value.
|
|
24
|
+
# @return [Boolean] whether the shopper approved filling exactly these
|
|
25
|
+
# fields. false for an empty `fields` too — nothing to confirm.
|
|
26
|
+
def call(fields)
|
|
27
|
+
return false if !@interactive || fields.empty?
|
|
28
|
+
|
|
29
|
+
@output.puts "Autofill is ready to enter this on the store's own checkout page — contact and " \
|
|
30
|
+
"shipping only, never payment:"
|
|
31
|
+
fields.each { |token, value| @output.puts " #{token}: #{value}" }
|
|
32
|
+
@output.print "Fill these fields now? [y/N] "
|
|
33
|
+
@output.flush
|
|
34
|
+
@input.gets.to_s.strip.downcase == "y"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require_relative "shipping_profile"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# docs/plans/webmcp-universal-outbound.md Phase 3 — the shopper-approved
|
|
6
|
+
# data autofill is ever allowed to type into a store's checkout page:
|
|
7
|
+
# contact email and the PORTAGE_SHIP_* shipping address
|
|
8
|
+
# (Portage::Cli::ShippingProfile), each named by the WHATWG
|
|
9
|
+
# autocomplete token (https://html.spec.whatwg.org/#autofill) a standards
|
|
10
|
+
# -following checkout page's own field should carry, which is what
|
|
11
|
+
# Portage::Ucp::WebMcp::Autofill/assets/autofill.js match against.
|
|
12
|
+
#
|
|
13
|
+
# Deliberately the only place in this gem that reads PORTAGE_SHIP_EMAIL
|
|
14
|
+
# — a contact email is a WebMCP-autofill-only concern (native/adapter
|
|
15
|
+
# checkouts take it as part of `context`/the adapter's own contract, not
|
|
16
|
+
# a standalone shipping field), so it doesn't belong on ShippingProfile
|
|
17
|
+
# or BuyerContext, both of which are shared with paths that never touch
|
|
18
|
+
# a browser.
|
|
19
|
+
module WebmcpAutofillFields
|
|
20
|
+
EMAIL_ENV_VAR = "PORTAGE_SHIP_EMAIL".freeze
|
|
21
|
+
|
|
22
|
+
# ShippingProfile attribute => the autocomplete token
|
|
23
|
+
# assets/autofill.js looks for on the checkout page.
|
|
24
|
+
TOKENS = {
|
|
25
|
+
first_name: "shipping given-name",
|
|
26
|
+
last_name: "shipping family-name",
|
|
27
|
+
street_address: "shipping address-line1",
|
|
28
|
+
extended_address: "shipping address-line2",
|
|
29
|
+
address_locality: "shipping address-level2",
|
|
30
|
+
address_region: "shipping address-level1",
|
|
31
|
+
postal_code: "shipping postal-code",
|
|
32
|
+
address_country: "shipping country",
|
|
33
|
+
phone_number: "shipping tel"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param address [Portage::Ucp::PostalAddress, nil] nil (the default)
|
|
39
|
+
# reads PORTAGE_SHIP_* fresh; injectable so a spec doesn't need real
|
|
40
|
+
# env vars set.
|
|
41
|
+
# @return [Hash{String=>String}] autocomplete token => value. Empty
|
|
42
|
+
# when neither an email nor a usable shipping address is
|
|
43
|
+
# configured — #call in Buy treats that as nothing to autofill,
|
|
44
|
+
# same as no PORTAGE_SHIP_* today.
|
|
45
|
+
def build(address: Portage::Cli::ShippingProfile.from_env)
|
|
46
|
+
fields = {}
|
|
47
|
+
email = ENV.fetch(EMAIL_ENV_VAR, nil)
|
|
48
|
+
fields["email"] = email unless email.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
return fields unless address
|
|
51
|
+
|
|
52
|
+
TOKENS.each do |attribute, token|
|
|
53
|
+
value = address.public_send(attribute)
|
|
54
|
+
fields[token] = value unless value.to_s.strip.empty?
|
|
55
|
+
end
|
|
56
|
+
fields
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "config"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# docs/plans/webmcp-universal-outbound.md Phase 3 — the opt-in gate in
|
|
7
|
+
# front of filling the store's own checkout page. Off by default: only
|
|
8
|
+
# `--autofill` (a per-run CLI flag, forwarded to `Buy.new(autofill:)`)
|
|
9
|
+
# or `PORTAGE_WEBMCP_AUTOFILL=approve` / config.json's
|
|
10
|
+
# `"webmcp_autofill": "approve"` turn it on. Same Setting precedence
|
|
11
|
+
# everywhere else in this gem uses (WebmcpCheckoutMode, HandoffSpendMode)
|
|
12
|
+
# — override beats env beats config — except the override here is a
|
|
13
|
+
# boolean (the flag is either passed or it isn't), while the env/config
|
|
14
|
+
# level is a literal string, `"approve"`, not truthy-string parsing
|
|
15
|
+
# (Setting.flag?'s "1"/"true"/"yes"): a plan that could be
|
|
16
|
+
# `PORTAGE_WEBMCP_AUTOFILL=true` by accident from some other truthy
|
|
17
|
+
# convention shouldn't silently turn on typing into a checkout page.
|
|
18
|
+
#
|
|
19
|
+
# Turning this gate on is still only half of Phase 3's opt-in: even
|
|
20
|
+
# approved, nothing is typed until the shopper also approves the exact
|
|
21
|
+
# field/value pairs in WebmcpAutofillConfirm's own prompt.
|
|
22
|
+
module WebmcpAutofillMode
|
|
23
|
+
ENV_VAR = "PORTAGE_WEBMCP_AUTOFILL".freeze
|
|
24
|
+
CONFIG_KEY = "webmcp_autofill".freeze
|
|
25
|
+
APPROVE = "approve".freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @param override [Boolean, nil] --autofill's value: true when the
|
|
30
|
+
# flag was passed, nil otherwise (there's no --no-autofill; the flag
|
|
31
|
+
# only ever turns this on, same as --dry-run/--yes elsewhere in Buy).
|
|
32
|
+
def approved?(override: nil, config: Config.load)
|
|
33
|
+
return true if override == true
|
|
34
|
+
|
|
35
|
+
Setting.resolve(env: ENV_VAR, config: config, config_key: CONFIG_KEY).to_s.strip.downcase == APPROVE
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# Turns a `Portage::Ucp::WebMcp::Matcher` proposal into `tool_names:`
|
|
4
|
+
# `Buy#webmcp_flow` can pass to `WebMcp.connect`, enforcing Phase 2's
|
|
5
|
+
# confirm-before-mutate rule (docs/plans/webmcp-universal-outbound.md):
|
|
6
|
+
# a read action (`search_catalog`, `get_product`, `get_cart`) is usable
|
|
7
|
+
# straight off the proposal — wrong at worst wastes a call. A mutating
|
|
8
|
+
# action (`create_cart`, `update_cart`, `create_checkout`) needs
|
|
9
|
+
# confirmation first, since a wrong guess there could add to, discard,
|
|
10
|
+
# or charge a stranger's cart.
|
|
11
|
+
#
|
|
12
|
+
# The prompt quotes a mutating tool's own `description` as what it is —
|
|
13
|
+
# page content, therefore untrusted — never as an instruction. Nothing
|
|
14
|
+
# here executes, follows, or acts on anything the description says; it
|
|
15
|
+
# is printed for the shopper to read and nothing more.
|
|
16
|
+
class WebmcpMappingConfirm
|
|
17
|
+
READ_ACTIONS = %w[search_catalog get_product get_cart].freeze
|
|
18
|
+
|
|
19
|
+
# @param interactive [Boolean] whether a prompt can actually be shown
|
|
20
|
+
# and answered — false under --json or with no TTY on stdin (the
|
|
21
|
+
# caller decides; this class has no opinion on how). false means a
|
|
22
|
+
# proposal with any mutating action is always refused.
|
|
23
|
+
def initialize(interactive:, input: $stdin, output: $stdout)
|
|
24
|
+
@interactive = interactive
|
|
25
|
+
@input = input
|
|
26
|
+
@output = output
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param proposal [Hash{String => Matcher::Proposal}]
|
|
30
|
+
# @param tools [Array<Hash>] the page's own tools, so the prompt can
|
|
31
|
+
# quote a mutating tool's description.
|
|
32
|
+
# @return [Hash{String => String}, nil] a tool_names: hash covering
|
|
33
|
+
# every proposed action, or nil when a mutating action was proposed
|
|
34
|
+
# and couldn't be confirmed — no TTY, --json, or the shopper
|
|
35
|
+
# declined. Never returns a partial mapping: either every mutating
|
|
36
|
+
# action in the proposal is approved, or none of them are used.
|
|
37
|
+
def call(proposal, tools)
|
|
38
|
+
reads = proposal.slice(*READ_ACTIONS).transform_values(&:tool)
|
|
39
|
+
mutating = proposal.except(*READ_ACTIONS)
|
|
40
|
+
return reads if mutating.empty?
|
|
41
|
+
return nil unless confirmed?(mutating, tools)
|
|
42
|
+
|
|
43
|
+
reads.merge(mutating.transform_values(&:tool))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def confirmed?(mutating, tools) = @interactive && approve?(mutating, tools)
|
|
49
|
+
|
|
50
|
+
def approve?(mutating, tools)
|
|
51
|
+
@output.puts "This page's WebMCP tools weren't recognized as a known store platform. Based on their " \
|
|
52
|
+
"names and schemas, here's the proposed mapping for cart/checkout actions — the " \
|
|
53
|
+
"description text is quoted from the page itself, not a trusted instruction:"
|
|
54
|
+
mutating.each { |action, match| describe(action, match, tools) }
|
|
55
|
+
@output.print "Use this mapping? [y/N] "
|
|
56
|
+
@output.flush
|
|
57
|
+
@input.gets.to_s.strip.downcase == "y"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def describe(action, match, tools)
|
|
61
|
+
tool = tools.find { |t| (t["name"] || t[:name]).to_s == match.tool }
|
|
62
|
+
description = tool && (tool["description"] || tool[:description])
|
|
63
|
+
@output.puts " #{action} -> #{match.tool} (confidence #{match.confidence}) — #{match.reason}"
|
|
64
|
+
@output.puts " page description: #{description.inspect}" if description
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# Confirmed Phase 2 `tool_names:` mappings — `~/.portage/webmcp_mappings.json`
|
|
8
|
+
# (docs/plans/webmcp-universal-outbound.md) — keyed by the page's tool
|
|
9
|
+
# fingerprint (`Portage::Ucp::WebMcp::Fingerprint.for`: sorted tool names
|
|
10
|
+
# plus a hash of each one's own input schema), never by origin
|
|
11
|
+
# (decision 3): once a shopper confirms a mapping for one store, any
|
|
12
|
+
# other store whose WebMCP tools have the exact same shape reuses it
|
|
13
|
+
# with no prompt at all — in effect a local preset. A lookalike page
|
|
14
|
+
# whose schemas differ even slightly gets a different fingerprint and
|
|
15
|
+
# has to be confirmed again, so it can't borrow a mapping with a
|
|
16
|
+
# different shape underneath the same tool names.
|
|
17
|
+
#
|
|
18
|
+
# Same "absent file or absent key means unset, a corrupt file raises
|
|
19
|
+
# rather than silently falling back" posture as Config — this is
|
|
20
|
+
# deliberately its own file, not a key under config.json, since it's
|
|
21
|
+
# confirmed data (grows with use) rather than a standing preference.
|
|
22
|
+
class WebmcpMappings
|
|
23
|
+
PATH = File.join(Dir.home, ".portage", "webmcp_mappings.json").freeze
|
|
24
|
+
|
|
25
|
+
def self.load(path: PATH) = new(path: path, data: read(path))
|
|
26
|
+
|
|
27
|
+
def initialize(path: PATH, data: {})
|
|
28
|
+
@path = path
|
|
29
|
+
@data = data
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param tools [Array<Hash>] the page's tools, as list_tools returns
|
|
33
|
+
# them.
|
|
34
|
+
# @return [Hash{String => String}, nil] the confirmed tool_names: for
|
|
35
|
+
# this exact fingerprint, or nil when none has been confirmed yet.
|
|
36
|
+
def lookup(tools)
|
|
37
|
+
entry = @data[fingerprint_for(tools)]
|
|
38
|
+
entry && entry["tool_names"].transform_keys(&:to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param tool_names [Hash] action => tool name, as just confirmed.
|
|
42
|
+
# Merged onto whatever this fingerprint already had (so confirming a
|
|
43
|
+
# new action later doesn't drop an earlier one), never replaced
|
|
44
|
+
# outright.
|
|
45
|
+
# @param origin [String, nil] recorded as metadata only — the lookup
|
|
46
|
+
# key is the fingerprint alone (decision 3), never the origin.
|
|
47
|
+
# @return [Hash{String => String}] the fingerprint's tool_names: after
|
|
48
|
+
# merging.
|
|
49
|
+
def confirm!(tools, tool_names:, origin: nil)
|
|
50
|
+
key = fingerprint_for(tools)
|
|
51
|
+
entry = @data[key] || { "tool_names" => {}, "origins" => [] }
|
|
52
|
+
entry["tool_names"] = entry["tool_names"].merge(tool_names.transform_keys(&:to_s))
|
|
53
|
+
entry["origins"] = (entry["origins"] + [origin]).compact.uniq if origin
|
|
54
|
+
entry["confirmed_at"] = Time.now.utc.iso8601
|
|
55
|
+
@data[key] = entry
|
|
56
|
+
write
|
|
57
|
+
entry["tool_names"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h = @data.dup
|
|
61
|
+
|
|
62
|
+
def self.read(path)
|
|
63
|
+
return {} unless File.readable?(path)
|
|
64
|
+
|
|
65
|
+
raw = File.read(path)
|
|
66
|
+
return {} if raw.empty?
|
|
67
|
+
|
|
68
|
+
parsed = JSON.parse(raw)
|
|
69
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
70
|
+
end
|
|
71
|
+
private_class_method :read
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def fingerprint_for(tools) = Portage::Ucp::WebMcp::Fingerprint.for(tools)
|
|
76
|
+
|
|
77
|
+
def write
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
79
|
+
File.write(@path, JSON.pretty_generate(@data))
|
|
80
|
+
File.chmod(0o600, @path)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|