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
|
+
module Index
|
|
4
|
+
module Sources
|
|
5
|
+
# A pointer, not a reader. Browser-derived entries come from
|
|
6
|
+
# `portage browser import` (BrowserImport::Importer, Phase 3), which
|
|
7
|
+
# writes them straight into Index::Store — `sources: ["history"]`/
|
|
8
|
+
# `["bookmark"]` — only after the user has seen the list and
|
|
9
|
+
# approved it. `index build` must never read a browser's history on
|
|
10
|
+
# its own (that would skip the approval), so this source yields
|
|
11
|
+
# nothing and isn't in Sources::DEFAULT_NAMES; it stays in the
|
|
12
|
+
# registry so `portage index sources` still says where browser
|
|
13
|
+
# entries come from.
|
|
14
|
+
class Browser
|
|
15
|
+
def name = "browser"
|
|
16
|
+
|
|
17
|
+
def description
|
|
18
|
+
"Bookmarks/history shop domains — written by `portage browser import` after you approve the list " \
|
|
19
|
+
"(never read by `index build` itself). Yields nothing here."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def source_path = nil
|
|
23
|
+
|
|
24
|
+
# `**` accepts (and ignores) the shared Source#candidates(queries:)
|
|
25
|
+
# interface.
|
|
26
|
+
def candidates(**) = []
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
require_relative "../../offer_sources"
|
|
4
|
+
require_relative "../../buyer_context"
|
|
5
|
+
require_relative "../../classifier"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
module Index
|
|
10
|
+
module Sources
|
|
11
|
+
# Harvests merchant origins and product identities from Shopify's
|
|
12
|
+
# global catalog — the same source `find` already uses live
|
|
13
|
+
# (OfferSources::ShopifyCatalog), reused here rather than
|
|
14
|
+
# duplicating its catalog-search/variant-URL logic (docs/plans/
|
|
15
|
+
# buy-skill-and-local-browser.md Phase 2b: "reuse the Phase 1
|
|
16
|
+
# ShopifyCatalog code rather than duplicating it").
|
|
17
|
+
#
|
|
18
|
+
# One query per top-level taxonomy node by default (21 nodes —
|
|
19
|
+
# known-stores/categories.yml's own top level, its keyword rather
|
|
20
|
+
# than its full "Parent > Child" name, so each query is a plain
|
|
21
|
+
# shopping term like "electronics" or "apparel" instead of a taxon
|
|
22
|
+
# label). Not per-country: BuyerContext.from_env already applies
|
|
23
|
+
# whatever PORTAGE_SHIP_COUNTRY/PORTAGE_CURRENCY the user has set,
|
|
24
|
+
# same as every other catalog call this CLI makes, and fanning the
|
|
25
|
+
# same 21 queries out across every market on every `index build`
|
|
26
|
+
# would turn a several-second command into a slow one for no
|
|
27
|
+
# benefit a single run doesn't already get from the user's own
|
|
28
|
+
# locale (Open question 1, resolved 2026-09-28).
|
|
29
|
+
class ShopifyCatalog
|
|
30
|
+
PER_QUERY_LIMIT = 20
|
|
31
|
+
|
|
32
|
+
def initialize(catalog: OfferSources::ShopifyCatalog.new, categories_path: Classifier::KNOWN_PATH)
|
|
33
|
+
@catalog = catalog
|
|
34
|
+
@categories_path = categories_path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def name = "shopify_catalog"
|
|
38
|
+
|
|
39
|
+
def description
|
|
40
|
+
"Shopify's global catalog search (catalog.shopify.com) — one query per top-level taxonomy " \
|
|
41
|
+
"node, or --queries FILE. Harvests merchant origins from variants[].url and product " \
|
|
42
|
+
"identities from the results."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def source_path = nil
|
|
46
|
+
|
|
47
|
+
# @param queries [Array<String>, nil] nil (the default) runs one
|
|
48
|
+
# query per top-level taxonomy node.
|
|
49
|
+
# @return [Array<Hash>] sightings: origin:, url:, title:, brand:,
|
|
50
|
+
# gtin:.
|
|
51
|
+
def candidates(queries: nil)
|
|
52
|
+
(queries || default_queries).flat_map { |query| sightings_for(query) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def sightings_for(query)
|
|
58
|
+
@catalog.offers(query, limit: PER_QUERY_LIMIT, context: BuyerContext.from_env).map do |offer|
|
|
59
|
+
{ origin: offer[:store], url: offer[:url], title: offer[:title], brand: nil, gtin: nil }
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The shipped taxonomy's own top-level keyword ("animals",
|
|
66
|
+
# "apparel", "electronics", ...) rather than its full name — a
|
|
67
|
+
# single plain word is a better catalog search term than
|
|
68
|
+
# "Apparel & Accessories" would be.
|
|
69
|
+
def default_queries
|
|
70
|
+
data = YAML.safe_load_file(@categories_path)
|
|
71
|
+
return [] unless data.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
top_level = data.values.reject { |node| node["name"].to_s.include?(">") }
|
|
74
|
+
top_level.filter_map { |node| Array(node["keywords"]).first }.uniq
|
|
75
|
+
rescue StandardError
|
|
76
|
+
[]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
require_relative "../../search_backends"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
module Index
|
|
8
|
+
module Sources
|
|
9
|
+
# The user's own `~/.portage/stores.yml` (or `PORTAGE_STORES`) —
|
|
10
|
+
# already-trusted stores they listed themselves. Reuses
|
|
11
|
+
# SearchBackends::Allowlist's own parsing (env + file, bare URL or
|
|
12
|
+
# `{url:, categories:}`) rather than re-reading the file, so a
|
|
13
|
+
# change to that format only has to be made in one place.
|
|
14
|
+
class StoresFile
|
|
15
|
+
def initialize(allowlist: SearchBackends::Allowlist.new)
|
|
16
|
+
@allowlist = allowlist
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name = "stores_file"
|
|
20
|
+
|
|
21
|
+
def description
|
|
22
|
+
"Your own ~/.portage/stores.yml / PORTAGE_STORES — stores you've already trusted directly."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def source_path = SearchBackends::Allowlist::PATH
|
|
26
|
+
|
|
27
|
+
# @return [Array<Hash>] one sighting per store, no product
|
|
28
|
+
# identity (stores.yml only ever names a store, never a
|
|
29
|
+
# product). `**` accepts (and ignores) the shared
|
|
30
|
+
# Source#candidates(queries:) interface — stores.yml has no
|
|
31
|
+
# query of its own to run.
|
|
32
|
+
def candidates(**)
|
|
33
|
+
@allowlist.stores.filter_map { |entry| sighting_for(entry[:url]) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def sighting_for(url)
|
|
39
|
+
origin = origin_of(url)
|
|
40
|
+
return nil unless origin
|
|
41
|
+
|
|
42
|
+
{ origin: origin, url: nil, title: nil, brand: nil, gtin: nil }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def origin_of(url)
|
|
46
|
+
uri = URI.parse(url.to_s)
|
|
47
|
+
return nil unless uri.host && uri.scheme.to_s.start_with?("http")
|
|
48
|
+
|
|
49
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
50
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
51
|
+
rescue URI::InvalidURIError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
module Index
|
|
4
|
+
module Sources
|
|
5
|
+
# Would detect each known origin's WebMCP preset by driving a
|
|
6
|
+
# browser bridge against it (docs/plans/webmcp-universal-outbound.md)
|
|
7
|
+
# — that bridge doesn't exist yet (it's Phase 6 here / WebMCP Phase
|
|
8
|
+
# 3-4), so this source skips cleanly rather than failing: it's
|
|
9
|
+
# listed by `portage index sources` so its eventual arrival is
|
|
10
|
+
# discoverable, but `candidates` is always empty until a bridge is
|
|
11
|
+
# wired in.
|
|
12
|
+
class WebmcpSweep
|
|
13
|
+
def name = "webmcp_sweep"
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
"Detects a WebMCP preset per known origin via the browser bridge. No bridge is wired up yet " \
|
|
17
|
+
"— skips cleanly, yields nothing."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def source_path = nil
|
|
21
|
+
|
|
22
|
+
# `**` accepts (and ignores) the shared Source#candidates(queries:)
|
|
23
|
+
# interface, since this source never runs a query of its own.
|
|
24
|
+
def candidates(**) = []
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "portage/ucp/support/connection"
|
|
6
|
+
|
|
7
|
+
require_relative "../../user_agent"
|
|
8
|
+
|
|
9
|
+
module Portage
|
|
10
|
+
module Cli
|
|
11
|
+
module Index
|
|
12
|
+
module Sources
|
|
13
|
+
# Wikidata SPARQL for retail chains' and brands' official websites
|
|
14
|
+
# (CC0). Optional and **off by default** (Sources.default excludes
|
|
15
|
+
# it) — a live, read-only trial run of this exact query on
|
|
16
|
+
# 2026-09-28 returned real hits (Venchi, DNS, Fix Price, Avtomir and
|
|
17
|
+
# more, each with a genuine official-site URL), which is the bar
|
|
18
|
+
# the plan set for keeping it, but the yield skews toward chains
|
|
19
|
+
# with no UCP/WebMCP support at all — most of what it finds fails
|
|
20
|
+
# the `/.well-known/ucp` probe like any other candidate, same as a
|
|
21
|
+
# web-search backend's own noise. Worth keeping for the rare real
|
|
22
|
+
# hit, not worth running on every default `index build`.
|
|
23
|
+
class Wikidata
|
|
24
|
+
ENDPOINT = "https://query.wikidata.org/sparql".freeze
|
|
25
|
+
TIMEOUT = 15
|
|
26
|
+
LIMIT = 200
|
|
27
|
+
|
|
28
|
+
# wd:Q507619 = "retail chain". wdt:P279* walks subclasses, so a
|
|
29
|
+
# supermarket chain or department-store chain tagged as a
|
|
30
|
+
# subclass of retail chain is included too, not just an exact
|
|
31
|
+
# match.
|
|
32
|
+
QUERY = <<~SPARQL
|
|
33
|
+
SELECT ?item ?itemLabel ?website WHERE {
|
|
34
|
+
?item wdt:P31/wdt:P279* wd:Q507619 .
|
|
35
|
+
?item wdt:P856 ?website .
|
|
36
|
+
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
|
37
|
+
} LIMIT #{LIMIT}
|
|
38
|
+
SPARQL
|
|
39
|
+
.freeze
|
|
40
|
+
|
|
41
|
+
def name = "wikidata"
|
|
42
|
+
|
|
43
|
+
def description
|
|
44
|
+
"Wikidata SPARQL for retail chains' official websites (CC0). Off by default (low yield) " \
|
|
45
|
+
"— opt in with --sources wikidata."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def source_path = nil
|
|
49
|
+
|
|
50
|
+
# `**` accepts (and ignores) the shared Source#candidates(queries:)
|
|
51
|
+
# interface — this source runs one fixed SPARQL query, never the
|
|
52
|
+
# caller's.
|
|
53
|
+
def candidates(**)
|
|
54
|
+
rows = Timeout.timeout(TIMEOUT) { fetch }
|
|
55
|
+
rows.filter_map { |row| sighting_for(row) }
|
|
56
|
+
rescue StandardError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def fetch
|
|
63
|
+
uri = URI.parse(ENDPOINT)
|
|
64
|
+
uri.query = URI.encode_www_form(query: QUERY)
|
|
65
|
+
headers = UserAgent.headers.merge("Accept" => "application/sparql-results+json")
|
|
66
|
+
response = Portage::Ucp::Support::Connection.start(
|
|
67
|
+
uri, route: :search, open_timeout: 5, read_timeout: TIMEOUT
|
|
68
|
+
) { |http| http.get(uri.request_uri, headers) }
|
|
69
|
+
return [] unless response.is_a?(Net::HTTPSuccess)
|
|
70
|
+
|
|
71
|
+
Array(JSON.parse(response.body).dig("results", "bindings"))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sighting_for(row)
|
|
75
|
+
url = row.dig("website", "value")
|
|
76
|
+
origin = origin_of(url)
|
|
77
|
+
return nil unless origin
|
|
78
|
+
|
|
79
|
+
{ origin: origin, url: url, title: nil, brand: row.dig("itemLabel", "value"), gtin: nil }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def origin_of(url)
|
|
83
|
+
uri = URI.parse(url.to_s)
|
|
84
|
+
return nil unless uri.host && uri.scheme.to_s.start_with?("http")
|
|
85
|
+
|
|
86
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
87
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
88
|
+
rescue URI::InvalidURIError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require_relative "sources/shopify_catalog"
|
|
2
|
+
require_relative "sources/stores_file"
|
|
3
|
+
require_relative "sources/browser"
|
|
4
|
+
require_relative "sources/wikidata"
|
|
5
|
+
require_relative "sources/webmcp_sweep"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
module Index
|
|
10
|
+
# One small file per source under index/sources/ — `portage index
|
|
11
|
+
# sources` lists exactly this registry, so what the command shows and
|
|
12
|
+
# what `index build` can actually run never drift apart.
|
|
13
|
+
module Sources
|
|
14
|
+
ALL = {
|
|
15
|
+
"shopify_catalog" => -> { ShopifyCatalog.new },
|
|
16
|
+
"stores_file" => -> { StoresFile.new },
|
|
17
|
+
"browser" => -> { Browser.new },
|
|
18
|
+
"wikidata" => -> { Wikidata.new },
|
|
19
|
+
"webmcp_sweep" => -> { WebmcpSweep.new }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Runs with no extra opt-in: no bridge required, and no low-yield
|
|
23
|
+
# trade-off to accept up front. `wikidata` and `webmcp_sweep` still
|
|
24
|
+
# show up in `portage index sources`, just not run unless named in
|
|
25
|
+
# `--sources`. `browser` is listed but never a default — it yields
|
|
26
|
+
# nothing; `portage browser import` writes those entries itself
|
|
27
|
+
# (see Sources::Browser).
|
|
28
|
+
DEFAULT_NAMES = %w[shopify_catalog stores_file].freeze
|
|
29
|
+
|
|
30
|
+
def self.all = ALL.values.map(&:call)
|
|
31
|
+
|
|
32
|
+
def self.default = DEFAULT_NAMES.map { |n| build(n) }
|
|
33
|
+
|
|
34
|
+
# @param names [Array<String>]
|
|
35
|
+
# @return [Array<#name, #description, #source_path, #candidates>]
|
|
36
|
+
# unknown names are silently dropped — callers report them
|
|
37
|
+
# themselves (see Cli's own option-parsing errors elsewhere).
|
|
38
|
+
def self.by_name(names) = Array(names).filter_map { |n| build(n) }
|
|
39
|
+
|
|
40
|
+
def self.build(name) = ALL[name]&.call
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
module Index
|
|
8
|
+
# `~/.portage/index/stores.json` — one entry per merchant origin
|
|
9
|
+
# `portage index build`/`refresh`/`add` has found and verified.
|
|
10
|
+
#
|
|
11
|
+
# This is untrusted, user-owned data, same posture as `stores.yml`
|
|
12
|
+
# before it's tagged: never checked into git (docs/plans/
|
|
13
|
+
# buy-skill-and-local-browser.md Phase 2b), never written into
|
|
14
|
+
# Portage::Ucp::Policy#merchant_allowlist, and never itself the
|
|
15
|
+
# "picked a store" a `--yes` buy requires — SearchBackends::Index just
|
|
16
|
+
# hands back candidate URLs, same as any other search backend, so
|
|
17
|
+
# `Cli.buy_from_search`'s existing --store/interactive-pick gate
|
|
18
|
+
# applies to an index-sourced offer exactly as it does to a web
|
|
19
|
+
# search result (see search_backends_spec.rb/cli_spec.rb for the
|
|
20
|
+
# specs proving both).
|
|
21
|
+
#
|
|
22
|
+
# Entry shape (string keys, JSON on disk): origin, platform,
|
|
23
|
+
# ucp_version, capabilities (array of "catalog"/"cart"/"checkout"),
|
|
24
|
+
# webmcp_preset, categories (category id => weight, top 5),
|
|
25
|
+
# sources (array of source names that found it), last_verified
|
|
26
|
+
# (unix seconds), handoff_only.
|
|
27
|
+
class Store
|
|
28
|
+
PATH = File.join(Dir.home, ".portage", "index", "stores.json").freeze
|
|
29
|
+
|
|
30
|
+
def initialize(path: PATH)
|
|
31
|
+
@path = path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all = entries.values
|
|
35
|
+
|
|
36
|
+
def find(origin) = entries[origin]
|
|
37
|
+
|
|
38
|
+
# Merges `fields` onto whatever's already there for `origin` (or
|
|
39
|
+
# starts a fresh entry) — so a second source finding the same store
|
|
40
|
+
# adds to its `sources`/`categories` rather than clobbering the
|
|
41
|
+
# first source's findings.
|
|
42
|
+
def upsert(origin, **fields)
|
|
43
|
+
existing = entries[origin] || { "origin" => origin }
|
|
44
|
+
entries[origin] = existing.merge(fields.transform_keys(&:to_s))
|
|
45
|
+
write
|
|
46
|
+
entries[origin]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Integer] how many entries (0 or 1 — origins are unique)
|
|
50
|
+
# this host's removal actually dropped.
|
|
51
|
+
def remove(host)
|
|
52
|
+
before = entries.length
|
|
53
|
+
entries.reject! { |origin, _| host_of(origin) == host }
|
|
54
|
+
write
|
|
55
|
+
before - entries.length
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Integer, nil] seconds since the oldest entry was
|
|
59
|
+
# verified — nil when the index is empty. `doctor` and `index
|
|
60
|
+
# show` use this to say how stale the local index is.
|
|
61
|
+
def oldest_verified_age(now: Time.now)
|
|
62
|
+
timestamps = entries.values.filter_map { |e| e["last_verified"] }
|
|
63
|
+
return nil if timestamps.empty?
|
|
64
|
+
|
|
65
|
+
now.to_i - timestamps.min
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def exists? = File.exist?(@path)
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def host_of(origin)
|
|
73
|
+
URI.parse(origin).host.to_s
|
|
74
|
+
rescue URI::InvalidURIError
|
|
75
|
+
""
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def entries
|
|
79
|
+
@entries ||= read
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read
|
|
83
|
+
return {} unless File.readable?(@path)
|
|
84
|
+
|
|
85
|
+
# A business name/title can carry non-ASCII bytes (curly quotes,
|
|
86
|
+
# ®, accents); reading with the process's default external
|
|
87
|
+
# encoding (US-ASCII on a bare-minimal LANG, confirmed live in a
|
|
88
|
+
# sandbox with no locale set) would otherwise raise on the very
|
|
89
|
+
# first non-ASCII byte and get silently swallowed below, quietly
|
|
90
|
+
# dropping the whole file's worth of entries — this was caught by
|
|
91
|
+
# this phase's own live index-build check.
|
|
92
|
+
parsed = JSON.parse(File.read(@path, encoding: "UTF-8"))
|
|
93
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
94
|
+
rescue StandardError
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# An index that can't be written just means this run's findings
|
|
99
|
+
# aren't saved — never a failed build.
|
|
100
|
+
def write
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
102
|
+
File.write(@path, JSON.generate(@entries))
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require_relative "index/store"
|
|
2
|
+
require_relative "index/product_store"
|
|
3
|
+
require_relative "index/known_cache"
|
|
4
|
+
require_relative "index/sources"
|
|
5
|
+
require_relative "index/builder"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
# `portage index` — a local store and product index the user builds
|
|
10
|
+
# themselves (docs/plans/buy-skill-and-local-browser.md Phase 2b),
|
|
11
|
+
# feeding `find` a fresh install's stores.yml can't. Never checked into
|
|
12
|
+
# git; never trusted the way stores.yml is (see Index::Store's own
|
|
13
|
+
# comment). Phase 2c adds the repo's own jsdelivr-hosted known-stores
|
|
14
|
+
# list (Index::KnownCache), fetched under the user's own entries here —
|
|
15
|
+
# never in place of them.
|
|
16
|
+
module Index
|
|
17
|
+
DIR = File.join(Dir.home, ".portage", "index").freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# Where the repo's own `known-stores/{stores,products}.json` (docs/plans/
|
|
4
|
+
# buy-skill-and-local-browser.md Phase 2c) are published for a fresh
|
|
5
|
+
# install to fetch: the same jsdelivr `@main` channel AgentProfileUrl
|
|
6
|
+
# already uses for the agent profile. Free GitHub, no Actions — a new
|
|
7
|
+
# entry reaches every install on its next refresh as soon as a PR
|
|
8
|
+
# merges, with no gem/brew release to wait on.
|
|
9
|
+
module KnownStoresUrl
|
|
10
|
+
BASE = "https://cdn.jsdelivr.net/gh/tomtom87/Portage@main/portage-cli/known-stores".freeze
|
|
11
|
+
STORES = "#{BASE}/stores.json".freeze
|
|
12
|
+
PRODUCTS = "#{BASE}/products.json".freeze
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|