portage-cli 0.7.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/README.md +302 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/approval_policy.rb +56 -0
  7. data/lib/portage/cli/approve.rb +135 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_opener.rb +38 -0
  21. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  22. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  23. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  24. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  25. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  26. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  27. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  28. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  29. data/lib/portage/cli/browser_profile.rb +25 -0
  30. data/lib/portage/cli/buy.rb +559 -29
  31. data/lib/portage/cli/checkout_handoff.rb +5 -24
  32. data/lib/portage/cli/classifier.rb +158 -0
  33. data/lib/portage/cli/compare.rb +3 -0
  34. data/lib/portage/cli/doctor.rb +155 -1
  35. data/lib/portage/cli/dot_env.rb +55 -0
  36. data/lib/portage/cli/find.rb +103 -12
  37. data/lib/portage/cli/handoff_agents.rb +186 -0
  38. data/lib/portage/cli/handoff_only.rb +94 -0
  39. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  40. data/lib/portage/cli/handoff_target.rb +61 -0
  41. data/lib/portage/cli/history.rb +47 -2
  42. data/lib/portage/cli/human_prompt.rb +118 -0
  43. data/lib/portage/cli/index/builder.rb +335 -0
  44. data/lib/portage/cli/index/exporter.rb +91 -0
  45. data/lib/portage/cli/index/known_cache.rb +155 -0
  46. data/lib/portage/cli/index/product_store.rb +101 -0
  47. data/lib/portage/cli/index/sources/browser.rb +31 -0
  48. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  49. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  50. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  51. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  52. data/lib/portage/cli/index/sources.rb +44 -0
  53. data/lib/portage/cli/index/store.rb +109 -0
  54. data/lib/portage/cli/index.rb +20 -0
  55. data/lib/portage/cli/known_stores_url.rb +15 -0
  56. data/lib/portage/cli/money.rb +18 -0
  57. data/lib/portage/cli/offer_choice.rb +38 -0
  58. data/lib/portage/cli/offer_sources.rb +460 -0
  59. data/lib/portage/cli/payment_methods.rb +24 -3
  60. data/lib/portage/cli/pick.rb +167 -0
  61. data/lib/portage/cli/product_page.rb +84 -0
  62. data/lib/portage/cli/quotes.rb +82 -0
  63. data/lib/portage/cli/search_backends.rb +337 -12
  64. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  65. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  66. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  67. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  68. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  69. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  70. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  71. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  72. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  73. data/lib/portage/cli/setup_wizard.rb +74 -0
  74. data/lib/portage/cli/version.rb +1 -1
  75. data/lib/portage/cli/webmcp.rb +10 -3
  76. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  77. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  78. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  79. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  80. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  81. data/lib/portage/cli.rb +930 -43
  82. metadata +67 -2
@@ -0,0 +1,335 @@
1
+ require "uri"
2
+ require "portage/ucp/client"
3
+
4
+ require_relative "../user_agent"
5
+ require_relative "../classifier"
6
+ require_relative "../probe_cache"
7
+ require_relative "../handoff_only"
8
+ require_relative "store"
9
+ require_relative "product_store"
10
+ require_relative "known_cache"
11
+ require_relative "exporter"
12
+ require_relative "sources"
13
+
14
+ module Portage
15
+ module Cli
16
+ module Index
17
+ # `portage index build`/`refresh`/`add` — runs each configured
18
+ # Sources::* against the user's own machine, verifies any new origin
19
+ # with one `/.well-known/ucp` probe through the existing ProbeCache
20
+ # (throttled, capped at MAX_NEW_PROBES per run), and writes the
21
+ # survivors into Store/ProductStore.
22
+ #
23
+ # Never writes Portage::Ucp::Policy#merchant_allowlist, and never
24
+ # decides what `--yes` treats as "picked a store" — this only ever
25
+ # produces candidates SearchBackends::Index hands to `find`, on the
26
+ # same footing as any other search backend (see Store's own comment).
27
+ class Builder
28
+ CATALOG_CAP = "dev.ucp.shopping.catalog".freeze
29
+ CART_CAP = "dev.ucp.shopping.cart".freeze
30
+ CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
31
+ CAPABILITY_PREFIXES = { "catalog" => CATALOG_CAP, "cart" => CART_CAP, "checkout" => CHECKOUT_CAP }.freeze
32
+
33
+ # Duplicated from Find::MAX_PROBES/THROTTLE rather than required —
34
+ # same reasoning as SearchBackends::Allowlist::TOTAL_CAP: this class
35
+ # is usable (and tested) with no dependency on find.rb.
36
+ MAX_NEW_PROBES = 500
37
+ THROTTLE = 0.1
38
+ STALE_AFTER = 7 * 24 * 60 * 60
39
+ TOP_CATEGORIES = 5
40
+
41
+ # Public so BrowserImport::Importer (Phase 3) labels a probed
42
+ # origin's capabilities exactly the way an index build does.
43
+ # @return [Array<String>] "catalog"/"cart"/"checkout", in that order.
44
+ def self.capabilities_of(session)
45
+ CAPABILITY_PREFIXES.filter_map do |label, prefix|
46
+ label if Array(session.capabilities).any? { |c| c == prefix || c.start_with?("#{prefix}.") }
47
+ end
48
+ end
49
+
50
+ def initialize(stores: Store.new, products: ProductStore.new, cache: ProbeCache.new, sources: nil,
51
+ throttle: THROTTLE, max_new_probes: MAX_NEW_PROBES, out: $stdout, now: Time.now,
52
+ known_cache: KnownCache.new, handoff_only: nil)
53
+ @stores = stores
54
+ @products = products
55
+ @cache = cache
56
+ @sources = sources || Sources.default
57
+ @throttle = throttle
58
+ @max_new_probes = max_new_probes
59
+ @out = out
60
+ @now = now
61
+ @known_cache = known_cache
62
+ @handoff_only = handoff_only || HandoffOnly.new
63
+ end
64
+
65
+ # @param queries [Array<String>, nil] passed through to every
66
+ # source that takes one (today, just shopify_catalog).
67
+ # @param export [String, nil] a directory to also write a PR-ready
68
+ # `{stores,products}.json` into (see Exporter) — nil (the
69
+ # default) skips it.
70
+ # @return [Hash] a run summary — sources_run, candidates,
71
+ # new_origins_checked, verified, products_added, capped, and
72
+ # (with `export:`) exported.
73
+ def build(queries: nil, dry_run: false, export: nil)
74
+ sightings = gather(queries)
75
+ result = apply(sightings, dry_run: dry_run)
76
+ result[:exported] = Exporter.new(stores: @stores, products: @products).export(export) if export
77
+ result
78
+ end
79
+
80
+ # Re-verifies every entry older than 7 days (bumping last_verified
81
+ # only for the ones still answering), refreshes the known-stores
82
+ # cache unconditionally (Phase 2c — this is one of the three
83
+ # triggers the plan names, alongside a first-run fetch and a stale
84
+ # `doctor` check), then runs an ordinary #build, which itself adds
85
+ # anything new the sources turn up.
86
+ def refresh(queries: nil, dry_run: false, export: nil)
87
+ @known_cache.refresh! unless dry_run
88
+ reverify_stale(dry_run: dry_run)
89
+ build(queries: queries, dry_run: dry_run, export: export).merge(refreshed: true)
90
+ end
91
+
92
+ # `portage index add URL` — verifies and stores one origin
93
+ # directly, no source involved. A hand-off-only origin (Tier C,
94
+ # HandoffOnly) is recorded without ever probing it — the user
95
+ # explicitly named it, but that's still not a request this process
96
+ # sends.
97
+ def add(url)
98
+ origin = origin_of(url)
99
+ return { added: false, message: "Not a valid http(s) URL: #{url}" } unless origin
100
+ return store_manual_handoff_only(origin) if handoff_only_origin?(origin)
101
+
102
+ session = probe(origin)
103
+ store_manual(origin, session)
104
+ end
105
+
106
+ # `portage index remove HOST`
107
+ def remove(host)
108
+ removed = @stores.remove(host).positive?
109
+ { removed: removed,
110
+ message: removed ? "Removed #{host} from the index." : "No index entry for #{host}." }
111
+ end
112
+
113
+ private
114
+
115
+ def gather(queries)
116
+ @sources.flat_map { |source| candidates_from(source, queries) }
117
+ end
118
+
119
+ # One source failing shouldn't take the rest of the build with it —
120
+ # same posture as SearchBackends#urls_from/OfferSources.
121
+ def candidates_from(source, queries)
122
+ source.candidates(queries: queries).map { |c| c.merge(source: source.name) }
123
+ rescue StandardError
124
+ []
125
+ end
126
+
127
+ def apply(sightings, dry_run:)
128
+ grouped = sightings.group_by { |s| s[:origin] }
129
+ probed = probe_new_origins(grouped, dry_run: dry_run)
130
+ products_added = dry_run ? 0 : store_products(sightings)
131
+ { sources_run: @sources.map(&:name), candidates: sightings.length, products_added: products_added,
132
+ new_origins_checked: probed[:checked], verified: probed[:verified], capped: probed[:capped] }
133
+ end
134
+
135
+ def probe_new_origins(grouped, dry_run:)
136
+ state = { checked: [], verified: [], probes: 0, capped: false }
137
+ grouped.each { |origin, group| probe_one_new_origin(origin, group, state, dry_run: dry_run) }
138
+ state.slice(:checked, :verified, :capped)
139
+ end
140
+
141
+ def probe_one_new_origin(origin, group, state, dry_run:)
142
+ if @stores.find(origin)
143
+ update_existing(origin, group) unless dry_run
144
+ return
145
+ end
146
+ return handoff_only_new_origin(origin, group, dry_run: dry_run) if handoff_only_origin?(origin)
147
+ return state[:capped] = true if state[:probes] >= @max_new_probes
148
+
149
+ throttle(state[:probes])
150
+ state[:probes] += 1
151
+ state[:checked] << origin
152
+ progress("Probing #{origin}...")
153
+ session = probe(origin)
154
+ return unless session
155
+
156
+ state[:verified] << origin
157
+ store_new(origin, session, group) unless dry_run
158
+ end
159
+
160
+ # Never spends a probe (docs/plans/buy-skill-and-local-browser.md
161
+ # Phase 5) — recorded straight as `handoff_only: true`, same as a
162
+ # `store_new` verdict but with no capabilities and no request ever
163
+ # made.
164
+ def handoff_only_new_origin(origin, group, dry_run:)
165
+ return if dry_run
166
+
167
+ @stores.upsert(origin, platform: platform_of(group), capabilities: [],
168
+ categories: merge_categories({}, group), sources: merged_sources(nil, group),
169
+ last_verified: @now.to_i, handoff_only: true)
170
+ end
171
+
172
+ def handoff_only_origin?(origin) = @handoff_only.host?(host_of(origin))
173
+
174
+ def host_of(origin)
175
+ URI.parse(origin).host
176
+ rescue URI::InvalidURIError
177
+ nil
178
+ end
179
+
180
+ def store_manual_handoff_only(origin)
181
+ existing = @stores.find(origin)
182
+ sources = ((existing && existing["sources"]) || []) + ["manual"]
183
+ @stores.upsert(origin, sources: sources.uniq, last_verified: @now.to_i, handoff_only: true)
184
+ { added: true, origin: origin, message: "Added #{origin} — hand-off only, never probed." }
185
+ end
186
+
187
+ def update_existing(origin, group)
188
+ existing = @stores.find(origin)
189
+ @stores.upsert(origin, sources: merged_sources(existing, group),
190
+ categories: merge_categories(existing["categories"], group))
191
+ end
192
+
193
+ def store_new(origin, session, group)
194
+ @stores.upsert(origin, platform: platform_of(group), capabilities: capabilities_of(session),
195
+ categories: merge_categories({}, group), sources: merged_sources(nil, group),
196
+ last_verified: @now.to_i, handoff_only: false)
197
+ end
198
+
199
+ def store_manual(origin, session)
200
+ existing = @stores.find(origin)
201
+ sources = ((existing && existing["sources"]) || []) + ["manual"]
202
+ if session
203
+ @stores.upsert(origin, platform: existing && existing["platform"], capabilities: capabilities_of(session),
204
+ sources: sources.uniq, last_verified: @now.to_i, handoff_only: false)
205
+ { added: true, origin: origin, message: "Added #{origin} — verified UCP." }
206
+ else
207
+ @stores.upsert(origin, sources: sources.uniq, last_verified: @now.to_i, handoff_only: true)
208
+ { added: true, origin: origin,
209
+ message: "Added #{origin} — no /.well-known/ucp response; marked hand-off only." }
210
+ end
211
+ end
212
+
213
+ def merged_sources(existing, group)
214
+ (Array(existing && existing["sources"]) + group.map { |g| g[:source] }).uniq
215
+ end
216
+
217
+ def merge_categories(existing, group)
218
+ tally = Hash.new(0)
219
+ Array(existing).each { |id, weight| tally[id] += weight.to_i }
220
+ group.each do |sighting|
221
+ next unless sighting[:title]
222
+
223
+ Classifier.categories_for(sighting[:title]).each { |id| tally[id] += 1 }
224
+ end
225
+ tally.sort_by { |_id, weight| -weight }.first(TOP_CATEGORIES).to_h
226
+ end
227
+
228
+ def platform_of(group)
229
+ "shopify" if group.any? { |g| g[:source] == "shopify_catalog" }
230
+ end
231
+
232
+ def capabilities_of(session) = self.class.capabilities_of(session)
233
+
234
+ def store_products(sightings)
235
+ eligible = sightings.select { |s| s[:title] && @stores.find(s[:origin]) }
236
+ eligible.each { |sighting| store_product(sighting) }
237
+ eligible.length
238
+ end
239
+
240
+ def store_product(sighting)
241
+ key = product_key(sighting)
242
+ category = Classifier.categories_for(sighting[:title]).first
243
+ @products.upsert(key, origin: sighting[:origin], seen_at: @now.to_i, title: sighting[:title],
244
+ brand: sighting[:brand], gtin: sighting[:gtin], category: category,
245
+ sources: [sighting[:source]].compact)
246
+ end
247
+
248
+ # GTIN when a source has one (none do yet); otherwise a normalized
249
+ # brand+title slug, stable across runs so the same product seen
250
+ # again updates its entry instead of duplicating it.
251
+ def product_key(sighting)
252
+ return "gtin:#{sighting[:gtin]}" if sighting[:gtin]
253
+
254
+ slug = [sighting[:brand], sighting[:title]].compact.join(" ").downcase.gsub(/[^a-z0-9]+/, "-")
255
+ "title:#{slug}"
256
+ end
257
+
258
+ # Skips by whether the origin is *currently* hand-off only
259
+ # (`handoff_only_origin?`, checked against live config), never by
260
+ # the flag a previous run happened to store — those disagree in
261
+ # both directions: an origin whose UCP probe simply failed is
262
+ # stored `handoff_only: true` too (#store_new/#store_manual) but
263
+ # isn't on the Tier C list and should keep getting re-verified in
264
+ # case it comes online, while a stale entry recorded *before* the
265
+ # user added its host to `handoff_only_hosts` is stored
266
+ # `handoff_only: false` and must stop being probed the moment that
267
+ # config changes, without waiting for some other source to
268
+ # re-sight it. An origin caught by the live check gets its stored
269
+ # flag flipped to match — no request, just a `last_verified` bump
270
+ # so it isn't re-checked again until the next stale window.
271
+ def reverify_stale(dry_run:)
272
+ probes = 0
273
+ @stores.all.select { |e| stale?(e) }.each do |entry|
274
+ origin = entry["origin"]
275
+ next reverify_now_handoff_only(origin, dry_run: dry_run) if handoff_only_origin?(origin)
276
+ break if probes >= @max_new_probes
277
+
278
+ throttle(probes)
279
+ probes += 1
280
+ progress("Re-verifying #{origin}...")
281
+ session = probe(origin)
282
+ @stores.upsert(origin, capabilities: capabilities_of(session), last_verified: @now.to_i) \
283
+ if session && !dry_run
284
+ end
285
+ end
286
+
287
+ def reverify_now_handoff_only(origin, dry_run:)
288
+ return if dry_run
289
+
290
+ @stores.upsert(origin, handoff_only: true, last_verified: @now.to_i)
291
+ end
292
+
293
+ def stale?(entry)
294
+ verified = entry["last_verified"]
295
+ verified.nil? || (@now.to_i - verified.to_i) > STALE_AFTER
296
+ end
297
+
298
+ # A cached *miss* saves the work; a cached hit still has to connect
299
+ # for the manifest details this run wants (same posture as
300
+ # Find#probe).
301
+ def probe(origin)
302
+ return nil if @cache.fetch(origin) == false
303
+
304
+ session = discover(origin)
305
+ @cache.record(origin, !session.nil?)
306
+ session
307
+ end
308
+
309
+ def discover(origin)
310
+ Portage::Ucp::Client.discover(origin, headers: UserAgent.headers)
311
+ rescue StandardError
312
+ nil
313
+ end
314
+
315
+ def throttle(probed)
316
+ sleep(@throttle) if probed.positive? && @throttle.to_f.positive?
317
+ end
318
+
319
+ def progress(message)
320
+ @out&.puts(message)
321
+ end
322
+
323
+ def origin_of(url)
324
+ uri = URI.parse(url.to_s)
325
+ return nil unless uri.host && uri.scheme.to_s.start_with?("http")
326
+
327
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
328
+ "#{uri.scheme}://#{uri.host}#{port}"
329
+ rescue URI::InvalidURIError
330
+ nil
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,91 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Portage
5
+ module Cli
6
+ module Index
7
+ # `portage index build --export DIR` — writes a PR-ready copy of the
8
+ # user's own local index into `DIR/{stores,products}.json`, in the
9
+ # same shape as `portage-cli/known-stores/` in the repo, so a
10
+ # maintainer's run is just "run this, `git add`, open a PR" (Decision
11
+ # 1a: the user builds the index, not CI).
12
+ #
13
+ # Nothing personal ships in the export. PERSONAL_SOURCES are the
14
+ # labels a browser import writes (`history`/`bookmark` — Phase 3 —
15
+ # plus Phase 2b's placeholder `browser`): a store entry whose only
16
+ # `sources` are personal is dropped outright, and they're stripped
17
+ # from the `sources` of any entry that also has a real source, so a
18
+ # store found both by `shopify_catalog` and by browsing history
19
+ # exports as if browsing history had never touched it. A product
20
+ # entry is filtered the same way on its own `sources` (Phase 3 adds
21
+ # that field — so a product page kept by `browser import
22
+ # --include-product-pages` never exports, even at a store a real
23
+ # source also found), and in any case is kept only if at least one
24
+ # of its `stores[].origin` survived the store filter, with its
25
+ # `stores` array trimmed to just those surviving origins (entries
26
+ # written before Phase 3 carry no `sources`, so that origin check is
27
+ # all they get).
28
+ class Exporter
29
+ PERSONAL_SOURCES = %w[browser history bookmark].freeze
30
+
31
+ def initialize(stores:, products:)
32
+ @stores = stores
33
+ @products = products
34
+ end
35
+
36
+ # @return [Hash] stores:, products: — counts written, plus dir.
37
+ def export(dir)
38
+ FileUtils.mkdir_p(dir)
39
+ exportable_stores = @stores.all.filter_map { |entry| exportable_store(entry) }
40
+ exportable_origins = exportable_stores.map { |e| e["origin"] }
41
+ exportable_products = @products.all.filter_map { |entry| exportable_product(entry, exportable_origins) }
42
+
43
+ write(dir, "stores.json", exportable_stores, key: "origin")
44
+ write(dir, "products.json", exportable_products, key: "key")
45
+
46
+ { dir: dir, stores: exportable_stores.length, products: exportable_products.length }
47
+ end
48
+
49
+ private
50
+
51
+ # nil (never exported) when browsing history/bookmarks are the
52
+ # *only* source; otherwise the entry with every personal label
53
+ # stripped out of its own `sources` list, so a mixed-source entry
54
+ # still exports — just with no trace that browsing history ever
55
+ # found it too.
56
+ def exportable_store(entry)
57
+ return nil if personal_only?(entry)
58
+
59
+ entry.merge("sources" => Array(entry["sources"]) - PERSONAL_SOURCES)
60
+ end
61
+
62
+ # A product with no surviving store origin is exactly as personal
63
+ # as a store entry with only "browser" as its source — it only
64
+ # exists in the index because of something the user looked at, not
65
+ # something a source discovered — so it's dropped the same way.
66
+ def exportable_product(entry, exportable_origins)
67
+ return nil if personal_only?(entry)
68
+
69
+ kept_stores = Array(entry["stores"]).select { |s| exportable_origins.include?(s["origin"]) }
70
+ return nil if kept_stores.empty?
71
+
72
+ exported = entry.merge("stores" => kept_stores)
73
+ entry.key?("sources") ? exported.merge("sources" => Array(entry["sources"]) - PERSONAL_SOURCES) : exported
74
+ end
75
+
76
+ # True when the entry names at least one source and every one of
77
+ # them is personal. An entry with no `sources` (a product written
78
+ # before Phase 3) or an empty list isn't judged here at all.
79
+ def personal_only?(entry)
80
+ sources = Array(entry["sources"])
81
+ !sources.empty? && (sources - PERSONAL_SOURCES).empty?
82
+ end
83
+
84
+ def write(dir, filename, entries, key:)
85
+ data = entries.to_h { |entry| [entry[key], entry] }
86
+ File.write(File.join(dir, filename), JSON.generate(data))
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,155 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "fileutils"
5
+ require "timeout"
6
+ require "portage/ucp/support/connection"
7
+
8
+ require_relative "../known_stores_url"
9
+ require_relative "../user_agent"
10
+
11
+ module Portage
12
+ module Cli
13
+ module Index
14
+ # The repo's own `known-stores/{stores,products}.json`, fetched over
15
+ # jsdelivr `@main` and cached at `~/.portage/index/known-{stores,
16
+ # products}.json` (docs/plans/buy-skill-and-local-browser.md Phase
17
+ # 2c). Same untrusted-data posture as Store/ProductStore — see their
18
+ # own comments at the top of this directory — plus one more rule:
19
+ # the user's own entries always win, so SearchBackends::Index merges
20
+ # this cache *underneath* Store/ProductStore rather than the other
21
+ # way round; this class never writes into Store/ProductStore itself.
22
+ #
23
+ # Fetched lazily the first time a caller needs it and no cache file
24
+ # exists yet (SearchBackends::Index#available?/#search), refreshed
25
+ # unconditionally by `portage index refresh`, and refreshed by
26
+ # `doctor` when it finds the cache older than STALE_AFTER. A fetch
27
+ # failure — offline, timeout, bad JSON, a non-2xx response — is
28
+ # swallowed exactly like SearchBackends/OfferSources::ShopifyCatalog:
29
+ # a stale or missing cache just means `find` works the way it did
30
+ # before this phase.
31
+ class KnownCache
32
+ STORES_PATH = File.join(Dir.home, ".portage", "index", "known-stores.json").freeze
33
+ PRODUCTS_PATH = File.join(Dir.home, ".portage", "index", "known-products.json").freeze
34
+
35
+ STALE_AFTER = 7 * 24 * 60 * 60
36
+ TIMEOUT = 5
37
+
38
+ # No known-stores/products entry may carry a price or stock field
39
+ # — Store/ProductStore's own comment says those are always live,
40
+ # and a fetched file is one more untrusted input than a locally
41
+ # built one, so this is checked again on the way in rather than
42
+ # trusting the source repo's own review (there's no CI gate for
43
+ # it — Decision 1a, "no central artifact, so no signing").
44
+ FORBIDDEN_FIELDS = %w[price amount stock].freeze
45
+
46
+ def initialize(stores_path: STORES_PATH, products_path: PRODUCTS_PATH,
47
+ stores_url: KnownStoresUrl::STORES, products_url: KnownStoresUrl::PRODUCTS)
48
+ @stores_path = stores_path
49
+ @products_path = products_path
50
+ @stores_url = stores_url
51
+ @products_url = products_url
52
+ end
53
+
54
+ # @return [Hash] origin => entry, same schema as Index::Store's own
55
+ # file.
56
+ def stores = read(@stores_path)
57
+
58
+ # @return [Hash] key => entry, same schema as Index::ProductStore's
59
+ # own file.
60
+ def products = read(@products_path)
61
+
62
+ def exists? = File.exist?(@stores_path) || File.exist?(@products_path)
63
+
64
+ # @return [Integer, nil] seconds since the stores cache was last
65
+ # fetched — its own file mtime, since the cache is written in the
66
+ # same schema as the local index and carries no fetch timestamp
67
+ # of its own. nil when there's no cache yet.
68
+ def age(now: Time.now)
69
+ return nil unless File.exist?(@stores_path)
70
+
71
+ now.to_i - File.mtime(@stores_path).to_i
72
+ end
73
+
74
+ def stale?(now: Time.now)
75
+ a = age(now: now)
76
+ a.nil? || a > STALE_AFTER
77
+ end
78
+
79
+ # The "first run" trigger: only fetches when there's no cache file
80
+ # at all — a stale-but-present cache is left for `index
81
+ # refresh`/`doctor` to decide about, not silently re-fetched on
82
+ # every `find`.
83
+ # @return [Boolean] whether a fetch happened (and its outcome).
84
+ def fetch_if_missing!
85
+ return false if exists?
86
+
87
+ refresh!
88
+ end
89
+
90
+ # `index refresh` and a stale `doctor` check call this
91
+ # unconditionally.
92
+ # @return [Boolean] true when at least one of the two files
93
+ # fetched successfully.
94
+ def refresh!
95
+ stores_ok = fetch_and_cache(@stores_url, @stores_path)
96
+ products_ok = fetch_and_cache(@products_url, @products_path)
97
+ stores_ok || products_ok
98
+ end
99
+
100
+ private
101
+
102
+ def fetch_and_cache(url, path)
103
+ data = Timeout.timeout(TIMEOUT) { fetch(url) }
104
+ return false unless valid?(data)
105
+
106
+ write(path, sanitize(data))
107
+ true
108
+ rescue StandardError
109
+ false
110
+ end
111
+
112
+ def fetch(url)
113
+ uri = URI.parse(url)
114
+ response = Portage::Ucp::Support::Connection.start(
115
+ uri, route: :search, open_timeout: TIMEOUT, read_timeout: TIMEOUT
116
+ ) { |http| http.get(uri.request_uri, UserAgent.headers) }
117
+ return nil unless response.is_a?(Net::HTTPSuccess)
118
+
119
+ JSON.parse(response.body)
120
+ end
121
+
122
+ # Same shape as Store/ProductStore's own file: a Hash keyed by
123
+ # origin (stores) or product key (products), each value itself a
124
+ # Hash. Anything else — an array, a string, a top-level list — is a
125
+ # fetch gone wrong, not a cache worth keeping.
126
+ def valid?(data)
127
+ data.is_a?(Hash) && data.values.all?(Hash)
128
+ end
129
+
130
+ def sanitize(data)
131
+ data.transform_values { |entry| entry.reject { |k, _| FORBIDDEN_FIELDS.include?(k.to_s) } }
132
+ end
133
+
134
+ # A cache that can't be written just means this fetch's findings
135
+ # aren't saved — never a crash (same posture as Store/
136
+ # ProductStore#write).
137
+ def write(path, data)
138
+ FileUtils.mkdir_p(File.dirname(path))
139
+ File.write(path, JSON.generate(data))
140
+ rescue StandardError
141
+ nil
142
+ end
143
+
144
+ def read(path)
145
+ return {} unless File.readable?(path)
146
+
147
+ parsed = JSON.parse(File.read(path, encoding: "UTF-8"))
148
+ parsed.is_a?(Hash) ? parsed : {}
149
+ rescue StandardError
150
+ {}
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,101 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Portage
5
+ module Cli
6
+ module Index
7
+ # `~/.portage/index/products.json` — product identities the index has
8
+ # seen, one entry per GTIN/MPN (when a source gives one) or per
9
+ # title+brand otherwise. **No prices or stock** — those are always
10
+ # live, read straight from the store's own catalog at buy time
11
+ # (docs/plans/buy-skill-and-local-browser.md Phase 2b).
12
+ #
13
+ # Entry shape: title, brand, gtin, category, aliases (other titles the
14
+ # same product went by at a different store), stores (array of
15
+ # {origin:, last_seen:}), sources (which index sources ever saw it —
16
+ # Phase 3 adds this so Index::Exporter can tell a product only a
17
+ # browser import saw from one a real source found; entries written
18
+ # before it have none).
19
+ class ProductStore
20
+ PATH = File.join(Dir.home, ".portage", "index", "products.json").freeze
21
+
22
+ def initialize(path: PATH)
23
+ @path = path
24
+ end
25
+
26
+ def all = entries.values
27
+
28
+ def find(key) = entries[key]
29
+
30
+ # @param key [String] a stable key for this product — the source's
31
+ # GTIN/MPN when it has one, else a normalized title+brand.
32
+ # @param origin [String] the store this sighting came from.
33
+ # @param seen_at [Integer] unix seconds.
34
+ def upsert(key, origin:, seen_at:, **fields)
35
+ existing = entries[key] || { "key" => key, "aliases" => [], "stores" => [] }
36
+ aliases = merge_aliases(existing, fields[:title])
37
+ merged = existing.merge(fields.transform_keys(&:to_s)) { |field, old, new| merge_field(field, old, new) }
38
+ merged["aliases"] = aliases
39
+ merged["stores"] = merge_stores(merged["stores"], origin, seen_at)
40
+ entries[key] = merged
41
+ write
42
+ merged
43
+ end
44
+
45
+ def exists? = File.exist?(@path)
46
+
47
+ private
48
+
49
+ # `aliases`/`stores` accumulate across upserts (merged separately
50
+ # below), `sources` is the union of every sighting's; every other
51
+ # field (title, brand, gtin, category) is just the latest
52
+ # sighting's value, since a later source's read is no less
53
+ # authoritative than an earlier one's.
54
+ def merge_field(field, old, new)
55
+ return (Array(old) + Array(new)).uniq if field == "sources"
56
+
57
+ %w[aliases stores].include?(field) ? old : (new || old)
58
+ end
59
+
60
+ # The *previous* title becomes an alias when a later sighting gives
61
+ # a different one — the newest sighting's title stays canonical
62
+ # (see #merge_field), and the one it replaced is kept so a search
63
+ # for either still finds this product.
64
+ def merge_aliases(existing, new_title)
65
+ old_title = existing["title"]
66
+ return Array(existing["aliases"]) if new_title.nil? || old_title.nil? || new_title == old_title
67
+
68
+ (Array(existing["aliases"]) + [old_title]).uniq
69
+ end
70
+
71
+ def merge_stores(stores, origin, seen_at)
72
+ kept = Array(stores).reject { |s| s["origin"] == origin }
73
+ (kept + [{ "origin" => origin, "last_seen" => seen_at }]).sort_by { |s| s["origin"] }
74
+ end
75
+
76
+ def entries
77
+ @entries ||= read
78
+ end
79
+
80
+ def read
81
+ return {} unless File.readable?(@path)
82
+
83
+ # See Index::Store#read's comment — a product title routinely
84
+ # carries non-ASCII bytes, so this has to read as UTF-8 rather
85
+ # than whatever the process's default external encoding is.
86
+ parsed = JSON.parse(File.read(@path, encoding: "UTF-8"))
87
+ parsed.is_a?(Hash) ? parsed : {}
88
+ rescue StandardError
89
+ {}
90
+ end
91
+
92
+ def write
93
+ FileUtils.mkdir_p(File.dirname(@path))
94
+ File.write(@path, JSON.generate(@entries))
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end