portage-cli 0.4.1 → 0.6.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 +66 -1
- data/README.md +39 -4
- data/exe/portage-console +11 -0
- data/lib/portage/cli/buy.rb +73 -6
- data/lib/portage/cli/console.rb +68 -0
- data/lib/portage/cli/doctor.rb +91 -0
- data/lib/portage/cli/find.rb +16 -1
- data/lib/portage/cli/generate/adapter.rb +225 -0
- data/lib/portage/cli/generate/agent_profile.rb +142 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +92 -11
- metadata +31 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4acb63ece20e24b7d43048315145a96e15ded1c4e5b0cf83c3f045603a033af6
|
|
4
|
+
data.tar.gz: 536057c80badcd44ee1be90679fd7f9233adb3d5a1c07b5b11a12201fbae5701
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54bc26bf74fe96c2a2154062ab41619e10bf046da1cc06f067fc1f0f7eaec9c29ff6ab461e9cf2907ede4084805b8718f65557e5d4c3c79c486650eea10c1075
|
|
7
|
+
data.tar.gz: deb45f093ce769ebae837bad5af628c8970aac76b6741ea8b083393b45038451a455ece2d603671c3d2884e3974858941ff6c29d96affd7bd3bbcc89f6a689d9
|
data/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,72 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
5
|
pre-1.0, so APIs may still shift between minor versions.
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [0.6.0] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
- Fixed `buy`/`find` crashing with a raw `Faraday::UnprocessableContentError`
|
|
10
|
+
against a real UCP store, once manifest parsing succeeded — the actual
|
|
11
|
+
`search_catalog`/`create_checkout` calls were still built in the wrong
|
|
12
|
+
wire shape (see `portage-ucp-client` 0.4.0). Both commands
|
|
13
|
+
now report a clear, actionable message instead: set `PORTAGE_AGENT_PROFILE`
|
|
14
|
+
when it's missing, or surface the store's rejection cleanly when it's set
|
|
15
|
+
but not accepted.
|
|
16
|
+
- Added `PORTAGE_AGENT_PROFILE` — required for `find`/`buy` against a real,
|
|
17
|
+
external UCP store (not your own store via an adapter). No default; see
|
|
18
|
+
`.env.example`.
|
|
19
|
+
- Fixed `buy` sending a catalog product's own id as the purchasable line
|
|
20
|
+
item — correct for a backend where "the product" and "the thing you add
|
|
21
|
+
to a cart" share one id, but wrong for Shopify (confirmed live: every
|
|
22
|
+
`portage buy` against a real Shopify test store failed with "Invalid id",
|
|
23
|
+
since Storefront's cart takes a `ProductVariant` GID, not the parent
|
|
24
|
+
`Product` GID `search_catalog` returns as `id`). `Buy#full_buy` and
|
|
25
|
+
`#redirect_checkout` now build `line_items` from a product's first/
|
|
26
|
+
default variant id when one exists, falling back to the product id
|
|
27
|
+
otherwise; `--product-id` matching (`#product_id_of`) is unchanged, since
|
|
28
|
+
it still needs to match the catalog-level id `find`/`compare` show the
|
|
29
|
+
caller.
|
|
30
|
+
- Added `portage generate agent-profile`, which writes a real UCP
|
|
31
|
+
agent-identity document (the JSON a store fetches from the
|
|
32
|
+
`meta.ucp-agent.profile` URL to decide whether to answer at all).
|
|
33
|
+
`portage-cli`'s own generated profile is checked in at
|
|
34
|
+
`agent-profile/agent-profile.json` and published to a stable URL by
|
|
35
|
+
`.github/workflows/publish-agent-profile.yml`, which is what
|
|
36
|
+
`PORTAGE_AGENT_PROFILE` defaults to pointing at.
|
|
37
|
+
- Widens the `portage-ucp` pin to `~> 0.8` and the `portage-ucp-client` pin
|
|
38
|
+
to `~> 0.4` so this gem installs alongside the 0.8.0-line releases.
|
|
39
|
+
|
|
40
|
+
## [0.5.1] - 2026-09-16
|
|
41
|
+
|
|
42
|
+
- No behavior change — 0.5.0 was built and pushed with `gem build` run from
|
|
43
|
+
the workspace root instead of this gem's own directory, so `spec.files =
|
|
44
|
+
Dir[...]` resolved against the wrong working directory and packaged an
|
|
45
|
+
empty gem. 0.5.0 has been yanked; 0.5.1 repackages the exact same 0.5.0
|
|
46
|
+
code correctly.
|
|
47
|
+
|
|
48
|
+
## [0.5.0] - 2026-09-16
|
|
49
|
+
|
|
50
|
+
- Added `portage-console` — a read-only IRB REPL over the local
|
|
51
|
+
`TransactionLog`/`OrderLedger`/`PurchaseJournal` stores (design-log §22
|
|
52
|
+
item 6, `Portage::Cli::Console`). `transactions`/`find_transaction`/
|
|
53
|
+
`transactions_since`, `orders`/`find_order`, and `journal`. Every result
|
|
54
|
+
passes through `Portage::Ucp::Observability.redact`. Deliberately a local
|
|
55
|
+
REPL, not the admin/web panel design-log §16 also describes — see the
|
|
56
|
+
README's Console section for why. New runtime dependency on
|
|
57
|
+
`portage-ucp-journal` (`~> 0.1`).
|
|
58
|
+
- `Buy#client_for` now passes a real `Portage::Ucp::Journal::PurchaseJournal`
|
|
59
|
+
(file-backed, `~/.portage/journal.jsonl`) into `Client.for_adapter` — the
|
|
60
|
+
own-store loopback path used by `portage buy` against your own store now
|
|
61
|
+
actually records to the purchase journal, closing the gap `portage-console`
|
|
62
|
+
above depends on: `journal` was empty on this path because nothing passed
|
|
63
|
+
`journal:` through it (design-log §37/§38).
|
|
64
|
+
- Added `portage generate adapter` — scaffolds a new adapter gem (gemspec,
|
|
65
|
+
Gemfile, Rakefile, rubocop config, lib entrypoint, version file, an
|
|
66
|
+
`Adapter` subclass with every capability method stubbed by reflecting off
|
|
67
|
+
`Portage::Ucp::Adapter`'s real method signatures, and a conformance spec),
|
|
68
|
+
modeled on `portage-ucp-etsy`.
|
|
69
|
+
- Added `portage doctor` — sanity-checks a seller's
|
|
70
|
+
`Portage::Ucp.configuration` (authenticator/rate-limiter left at
|
|
71
|
+
unconfigured fail-safe defaults, no signing keys/payment handlers
|
|
72
|
+
configured, adapter capabilities only half-implemented).
|
|
8
73
|
|
|
9
74
|
## [0.4.1] - 2026-09-15
|
|
10
75
|
|
data/README.md
CHANGED
|
@@ -39,11 +39,13 @@ portage compare https://your-shop.example --product-id prod_123 --results 5
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
|
|
42
|
-
(for platform detection via `Resolver`)
|
|
42
|
+
(for platform detection via `Resolver`),
|
|
43
43
|
[`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
|
|
44
|
-
(for the actual buy calls)
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
(for the actual buy calls), and
|
|
45
|
+
[`portage-ucp-journal`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-journal)
|
|
46
|
+
(for `portage-console`'s read-only view of local purchase state — see below).
|
|
47
|
+
No single adapter gem is a hard dependency — install whichever
|
|
48
|
+
`portage-ucp-<platform>` gem matches the store you're integrated with, if any.
|
|
47
49
|
|
|
48
50
|
## Installation
|
|
49
51
|
|
|
@@ -313,6 +315,39 @@ Backends that have no credentials sit out; DuckDuckGo is the keyless default
|
|
|
313
315
|
because it's the only no-key engine with a real API, and its narrowness is the
|
|
314
316
|
price of not scraping.
|
|
315
317
|
|
|
318
|
+
### Console
|
|
319
|
+
|
|
320
|
+
`portage-console` is a separate executable — a read-only IRB REPL over the
|
|
321
|
+
three local `~/.portage` stores (design-log §22 item 6): `TransactionLog`
|
|
322
|
+
(reserve/complete records), `OrderLedger` (settled-order snapshots), and, if
|
|
323
|
+
you've wired `journal:` into your own `Dispatcher` (nothing in this gem does
|
|
324
|
+
that for you), the `portage-ucp-journal` gem's `PurchaseJournal`.
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
portage-console
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
transactions # every reserved/completed transaction
|
|
332
|
+
transactions(shop: "your-shop.example")
|
|
333
|
+
find_transaction("idem_key_123")
|
|
334
|
+
transactions_since(Time.now - 86400, shop: "your-shop.example")
|
|
335
|
+
|
|
336
|
+
orders # every settled-order snapshot
|
|
337
|
+
find_order("order_123")
|
|
338
|
+
|
|
339
|
+
journal # empty unless a Dispatcher was built with journal:
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Every result is passed through `Portage::Ucp::Observability.redact` before
|
|
343
|
+
it's returned — `payment_token`/`oauth_token`/`Authorization` and the PII
|
|
344
|
+
fields on an order's fulfillment destinations never print, even in a REPL you
|
|
345
|
+
trust. This is deliberately a local REPL, not the admin/web panel design-log
|
|
346
|
+
§16 also describes: a browser is a new place for a token to leak, and the
|
|
347
|
+
process holding a web panel also holds this machine's live platform admin
|
|
348
|
+
credentials in its env — problems a REPL run by whoever already has shell
|
|
349
|
+
access to this machine doesn't have.
|
|
350
|
+
|
|
316
351
|
## Development
|
|
317
352
|
|
|
318
353
|
```bash
|
data/exe/portage-console
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "irb"
|
|
5
|
+
require "portage/cli/console"
|
|
6
|
+
|
|
7
|
+
include Portage::Cli::Console # rubocop:disable Style/MixinUsage -- top-level REPL helpers, by design
|
|
8
|
+
|
|
9
|
+
puts "portage-console — read-only helpers: transactions, find_transaction(key), " \
|
|
10
|
+
"transactions_since(time, shop:), orders, find_order(id), journal. Every result is redacted."
|
|
11
|
+
IRB.start
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "uri"
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "portage/ucp"
|
|
5
5
|
require "portage/ucp/client"
|
|
6
|
+
require "portage/ucp/journal"
|
|
6
7
|
require_relative "payment_methods"
|
|
7
8
|
|
|
8
9
|
module Portage
|
|
@@ -60,6 +61,14 @@ module Portage
|
|
|
60
61
|
|
|
61
62
|
def discover(url)
|
|
62
63
|
Portage::Ucp::Client.discover(url.to_s)
|
|
64
|
+
rescue Portage::Ucp::Client::ManifestShapeError => e
|
|
65
|
+
# The store *is* running UCP — this client just couldn't parse its
|
|
66
|
+
# manifest. Distinct from a genuine 404/unreachable host below:
|
|
67
|
+
# falling through silently there would hide a bug in this gem behind
|
|
68
|
+
# the same "no automated path" message a store with no UCP support
|
|
69
|
+
# gets, so this warns instead.
|
|
70
|
+
warn "portage: #{url} serves a UCP manifest this client couldn't parse (#{e.message})"
|
|
71
|
+
nil
|
|
63
72
|
rescue Portage::Ucp::Client::DiscoveryError
|
|
64
73
|
nil
|
|
65
74
|
end
|
|
@@ -70,6 +79,30 @@ module Portage
|
|
|
70
79
|
else
|
|
71
80
|
catalog_only(session)
|
|
72
81
|
end
|
|
82
|
+
rescue Portage::Ucp::Client::MissingAgentProfileError, Portage::Ucp::Client::UnsupportedWireShapeError,
|
|
83
|
+
MCP::Client::RequestHandlerError => e
|
|
84
|
+
native_flow_error_report(e)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def native_flow_error_report(error)
|
|
88
|
+
case error
|
|
89
|
+
when Portage::Ucp::Client::MissingAgentProfileError
|
|
90
|
+
build_report(source: "native_ucp", browse: false, checkout: false,
|
|
91
|
+
message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — " \
|
|
92
|
+
"#{@uri} verifies it before answering any UCP call.")
|
|
93
|
+
when Portage::Ucp::Client::UnsupportedWireShapeError
|
|
94
|
+
build_report(source: "native_ucp", browse: true, checkout: false,
|
|
95
|
+
message: "Can't complete checkout on #{@uri} yet: #{error.message}")
|
|
96
|
+
else
|
|
97
|
+
# MCP::Client::RequestHandlerError doesn't retain the server's JSON
|
|
98
|
+
# error body on this path, so this can't quote the server's own
|
|
99
|
+
# explanation — a common cause is PORTAGE_AGENT_PROFILE not
|
|
100
|
+
# pointing at a real, JSON agent-profile document the store's UCP
|
|
101
|
+
# endpoint accepts.
|
|
102
|
+
build_report(source: "native_ucp", browse: false, checkout: false,
|
|
103
|
+
message: "#{@uri} rejected the request (#{error.message}) — if PORTAGE_AGENT_PROFILE " \
|
|
104
|
+
"is set, check it points at a real agent-profile document the store accepts.")
|
|
105
|
+
end
|
|
73
106
|
end
|
|
74
107
|
|
|
75
108
|
def catalog_only(session)
|
|
@@ -127,8 +160,18 @@ module Portage
|
|
|
127
160
|
Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
|
|
128
161
|
end
|
|
129
162
|
|
|
163
|
+
# `journal:` closes design-log §37's named gap — `Mcp::Server.build`
|
|
164
|
+
# (and the `Loopback`/`Client.for_adapter` it sits behind) now forward
|
|
165
|
+
# a `journal:` kwarg straight to `Dispatcher`, but nothing on this own-
|
|
166
|
+
# store loopback path ever passed one, so `portage buy` against your
|
|
167
|
+
# own store settled real transactions/orders while leaving the journal
|
|
168
|
+
# `nil`. `PurchaseJournal.new`'s own default (`FileStore.new`) is
|
|
169
|
+
# `~/.portage/journal.jsonl` — the same "real file-backed default"
|
|
170
|
+
# posture `transaction_log:`/`order_ledger:` already get from
|
|
171
|
+
# `Dispatcher#initialize`.
|
|
130
172
|
def client_for(adapter)
|
|
131
|
-
Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new
|
|
173
|
+
Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new,
|
|
174
|
+
journal: Portage::Ucp::Journal::PurchaseJournal.new)
|
|
132
175
|
end
|
|
133
176
|
|
|
134
177
|
def catalog_only_adapter(adapter, platform)
|
|
@@ -145,8 +188,9 @@ module Portage
|
|
|
145
188
|
def redirect_checkout(adapter, products)
|
|
146
189
|
return nil if products.empty? || !Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
|
|
147
190
|
|
|
148
|
-
|
|
149
|
-
|
|
191
|
+
item_id = products.first.variants&.first&.id || products.first.id
|
|
192
|
+
adapter.create_checkout(line_items: [{ product_id: item_id, quantity: @qty }],
|
|
193
|
+
idempotency_key: "portage-buy-#{item_id}")
|
|
150
194
|
rescue StandardError
|
|
151
195
|
nil
|
|
152
196
|
end
|
|
@@ -167,8 +211,9 @@ module Portage
|
|
|
167
211
|
message: no_match_message)
|
|
168
212
|
end
|
|
169
213
|
|
|
170
|
-
checkout = session.create_checkout(line_items: [{ product_id:
|
|
171
|
-
fulfillment: requested_fulfillment(fulfillment_adapter)
|
|
214
|
+
checkout = session.create_checkout(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
|
|
215
|
+
fulfillment: requested_fulfillment(fulfillment_adapter),
|
|
216
|
+
meta: agent_meta)
|
|
172
217
|
checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
|
|
173
218
|
finish_checkout(session, source, products, checkout)
|
|
174
219
|
end
|
|
@@ -253,6 +298,20 @@ module Portage
|
|
|
253
298
|
product["id"]
|
|
254
299
|
end
|
|
255
300
|
|
|
301
|
+
# `create_checkout`'s `line_items[].product_id` is the conformance
|
|
302
|
+
# kit's overloaded name (portage-ucp/lib/portage/ucp/rspec.rb) for
|
|
303
|
+
# "whatever id this adapter's cart actually takes" — for a backend
|
|
304
|
+
# where a product's variants have their own id (confirmed live on
|
|
305
|
+
# Shopify: a ProductVariant GID, distinct from the parent Product
|
|
306
|
+
# GID), that's the first/default variant, not the catalog id
|
|
307
|
+
# #product_id_of returns for display/--product-id matching. A
|
|
308
|
+
# product with no variants (or a backend that doesn't distinguish
|
|
309
|
+
# the two) falls back to the product id unchanged (see
|
|
310
|
+
# docs/design-log.md §41).
|
|
311
|
+
def line_item_id_of(product)
|
|
312
|
+
product["variants"]&.first&.dig("id") || product_id_of(product)
|
|
313
|
+
end
|
|
314
|
+
|
|
256
315
|
def finish_checkout(session, source, products, checkout)
|
|
257
316
|
status = checkout["status"]
|
|
258
317
|
return escalation_report(source, products, checkout) if status == "requires_escalation"
|
|
@@ -303,7 +362,15 @@ module Portage
|
|
|
303
362
|
end
|
|
304
363
|
|
|
305
364
|
def safe_search(session)
|
|
306
|
-
CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
|
|
365
|
+
CatalogProducts.from(session.search_catalog(query: @query, limit: 10, meta: agent_meta))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Real UCP servers fetch this URL to verify the caller's identity
|
|
369
|
+
# before answering any call (see Transports::Http) — the own-store
|
|
370
|
+
# loopback path ignores it harmlessly, so it's cheapest to always pass
|
|
371
|
+
# it rather than branch on which transport `session` happens to be.
|
|
372
|
+
def agent_meta
|
|
373
|
+
{ agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
|
|
307
374
|
end
|
|
308
375
|
|
|
309
376
|
# --- Homepage fetch (used by both the manifest-not-found path and the
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
require "portage/ucp/journal"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Read-only REPL helpers over the three local stores design-log §22
|
|
7
|
+
# names (`TransactionLog`, `OrderLedger`, `PurchaseJournal`) — the
|
|
8
|
+
# console remainder of item 6. Deliberately a REPL, not the web/admin
|
|
9
|
+
# panel §16 also described: that panel needs its own session auth
|
|
10
|
+
# because a browser is a new place for a token to leak, on a process
|
|
11
|
+
# that already holds live platform admin credentials in its env. A
|
|
12
|
+
# local REPL run by whoever already holds this machine's `~/.portage`
|
|
13
|
+
# files and env has neither problem, so this is `exe/portage-console`
|
|
14
|
+
# + IRB rather than a server. Every returned record still goes through
|
|
15
|
+
# `Observability.redact` regardless — no new leak surface isn't the
|
|
16
|
+
# same guarantee as never printing a secret.
|
|
17
|
+
#
|
|
18
|
+
# Each store defaults to its own real `~/.portage/*` file, injectable
|
|
19
|
+
# (same posture as `Dispatcher`'s own `transaction_log:`/`order_ledger:`/
|
|
20
|
+
# `journal:` kwargs) so a spec, or a REPL session pointed at a different
|
|
21
|
+
# machine's exported files, doesn't have to touch the real ones.
|
|
22
|
+
#
|
|
23
|
+
# `journal` reads `~/.portage/journal.jsonl` and comes back empty unless
|
|
24
|
+
# whatever built the `Dispatcher` for your purchases passed a `journal:`
|
|
25
|
+
# (core's own default is `nil`) — `transactions`/`orders` don't have
|
|
26
|
+
# that caveat, since `TransactionLog`/`OrderLedger` are Dispatcher's
|
|
27
|
+
# un-opt-out-able defaults.
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# require "portage/cli/console"
|
|
31
|
+
# include Portage::Cli::Console
|
|
32
|
+
# transactions(shop: "example.myshopify.com")
|
|
33
|
+
module Console
|
|
34
|
+
def transactions(shop: nil, transaction_log: Portage::Ucp::Support::TransactionLog.new)
|
|
35
|
+
scope(transaction_log.all, shop: shop)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_transaction(idempotency_key, transaction_log: Portage::Ucp::Support::TransactionLog.new)
|
|
39
|
+
redact(transaction_log.find(idempotency_key))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def transactions_since(since, shop:, transaction_log: Portage::Ucp::Support::TransactionLog.new)
|
|
43
|
+
redact(transaction_log.completed_since(since, shop: shop))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def orders(order_ledger: Portage::Ucp::Support::OrderLedger.new)
|
|
47
|
+
scope(order_ledger.all)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def find_order(order_id, order_ledger: Portage::Ucp::Support::OrderLedger.new)
|
|
51
|
+
redact(order_ledger.find(order_id))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def journal(shop: nil, purchase_journal: Portage::Ucp::Journal::PurchaseJournal.new)
|
|
55
|
+
scope(purchase_journal.all, shop: shop)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def scope(records, shop: nil)
|
|
61
|
+
records = records.select { |record| record["shop"] == shop } if shop
|
|
62
|
+
redact(records)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def redact(records) = Portage::Ucp::Observability.redact(records)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# `portage doctor` — sanity-checks a seller's Portage::Ucp setup for the
|
|
6
|
+
# footguns nothing else catches automatically: an authenticator/rate
|
|
7
|
+
# limiter left at their fail-safe default (Configuration#initialize),
|
|
8
|
+
# no signing keys or payment handlers configured, and (when an adapter
|
|
9
|
+
# class is given) a capability that's only half-implemented — one action
|
|
10
|
+
# overridden, its siblings still raising Adapter's NotImplementedError.
|
|
11
|
+
# Runs against whatever `Portage::Ucp.configuration` looks like in this
|
|
12
|
+
# process, so a host app passes `--require` to load its own initializer
|
|
13
|
+
# first (Rails: `--require ./config/environment`).
|
|
14
|
+
class Doctor
|
|
15
|
+
Finding = Struct.new(:check, :message, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
def initialize(adapter_class: nil)
|
|
18
|
+
@adapter_class = adapter_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
[
|
|
23
|
+
authenticator_finding,
|
|
24
|
+
rate_limiter_finding,
|
|
25
|
+
signing_keys_finding,
|
|
26
|
+
payment_handlers_finding,
|
|
27
|
+
*capability_findings
|
|
28
|
+
].compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def config = Portage::Ucp.configuration
|
|
34
|
+
|
|
35
|
+
def authenticator_finding
|
|
36
|
+
return unless config.authenticator.is_a?(Portage::Ucp::UnconfiguredAuthenticator)
|
|
37
|
+
|
|
38
|
+
Finding.new(check: "authenticator",
|
|
39
|
+
message: "UnconfiguredAuthenticator is still in place — every mutating capability " \
|
|
40
|
+
"call is rejected. Set config.authenticator.")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rate_limiter_finding
|
|
44
|
+
return unless config.rate_limiter.is_a?(Portage::Ucp::NullRateLimiter)
|
|
45
|
+
|
|
46
|
+
Finding.new(check: "rate_limiter",
|
|
47
|
+
message: "NullRateLimiter is still in place — no rate limiting is applied. " \
|
|
48
|
+
"Set config.rate_limiter if that's not intentional.")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def signing_keys_finding
|
|
52
|
+
return unless Array(config.signing_keys).empty?
|
|
53
|
+
|
|
54
|
+
Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def payment_handlers_finding
|
|
58
|
+
return unless Array(config.payment_handlers).empty?
|
|
59
|
+
|
|
60
|
+
Finding.new(check: "payment_handlers",
|
|
61
|
+
message: "No payment_handlers configured — buyers can't complete a " \
|
|
62
|
+
"payment-enrolled checkout.")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Only action-based capabilities (predicate-based ones like discount/
|
|
66
|
+
# fulfillment need a live instance to call their predicate method, and
|
|
67
|
+
# doctor never instantiates an arbitrary adapter — it may need real
|
|
68
|
+
# credentials to construct).
|
|
69
|
+
def capability_findings
|
|
70
|
+
return [] unless @adapter_class
|
|
71
|
+
|
|
72
|
+
Portage::Ucp::Capabilities::ALL.filter_map { |capability| partial_capability_finding(capability) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def partial_capability_finding(capability)
|
|
76
|
+
return if capability.actions.nil? || capability.actions.empty?
|
|
77
|
+
|
|
78
|
+
overridden = capability.actions.values.select { |method_name| overridden?(method_name) }
|
|
79
|
+
return if overridden.empty? || overridden.length == capability.actions.length
|
|
80
|
+
|
|
81
|
+
Finding.new(check: "capability:#{capability.name}",
|
|
82
|
+
message: "#{capability.name} implements #{overridden.length}/#{capability.actions.length} " \
|
|
83
|
+
"actions (#{overridden.join(', ')}) — the rest still raise NotImplementedError.")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def overridden?(method_name)
|
|
87
|
+
@adapter_class.instance_method(method_name).owner != Portage::Ucp::Adapter
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -46,6 +46,10 @@ module Portage
|
|
|
46
46
|
offers = rank(stores.flat_map { |store| offers_for(store) })
|
|
47
47
|
report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
|
|
48
48
|
offers: offers, message: summary(candidates, stores, offers))
|
|
49
|
+
rescue Portage::Ucp::Client::MissingAgentProfileError
|
|
50
|
+
report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
|
|
51
|
+
message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — each store " \
|
|
52
|
+
"verifies it before answering a catalog search.")
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
private
|
|
@@ -135,12 +139,23 @@ module Portage
|
|
|
135
139
|
# --- Step 3: ask the survivors what they stock ---
|
|
136
140
|
|
|
137
141
|
def offers_for(store)
|
|
138
|
-
products = CatalogProducts.from(
|
|
142
|
+
products = CatalogProducts.from(
|
|
143
|
+
store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS, meta: agent_meta)
|
|
144
|
+
)
|
|
139
145
|
products.filter_map { |product| offer(store, product) }
|
|
146
|
+
rescue Portage::Ucp::Client::MissingAgentProfileError
|
|
147
|
+
raise
|
|
140
148
|
rescue StandardError
|
|
141
149
|
[]
|
|
142
150
|
end
|
|
143
151
|
|
|
152
|
+
# Real UCP servers fetch this URL to verify the caller's identity
|
|
153
|
+
# before answering any call (see Transports::Http) — the own-store
|
|
154
|
+
# loopback path ignores it harmlessly.
|
|
155
|
+
def agent_meta
|
|
156
|
+
{ agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
|
|
157
|
+
end
|
|
158
|
+
|
|
144
159
|
def offer(store, product)
|
|
145
160
|
amount, currency = price_of(product)
|
|
146
161
|
return nil if @max_price && amount && amount > @max_price
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "portage/ucp"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
module Generate
|
|
7
|
+
# `portage generate adapter Foo` — scaffolds a new adapter gem shaped
|
|
8
|
+
# like the bundled ones (portage-ucp-etsy is the template this follows
|
|
9
|
+
# most closely: gemspec depending on portage-ucp, Adapter subclass,
|
|
10
|
+
# conformance spec). Every capability method is stubbed as `= super`,
|
|
11
|
+
# reflected straight off Portage::Ucp::Adapter's own signatures rather
|
|
12
|
+
# than hand-copied, so the scaffold can't drift from the real contract
|
|
13
|
+
# the way a hardcoded template would the next time the contract grows.
|
|
14
|
+
class Adapter
|
|
15
|
+
def initialize(name:, dir: nil)
|
|
16
|
+
@module_name = camelize(name)
|
|
17
|
+
@slug = underscore(name)
|
|
18
|
+
@dir = dir || "portage-ucp-#{@slug}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
FileUtils.mkdir_p(File.join(@dir, "lib", "portage", "ucp", @slug))
|
|
23
|
+
FileUtils.mkdir_p(File.join(@dir, "spec", "portage", "ucp", @slug))
|
|
24
|
+
write_gemspec
|
|
25
|
+
write_gemfile
|
|
26
|
+
write_rakefile
|
|
27
|
+
write_rubocop
|
|
28
|
+
write_lib_entrypoint
|
|
29
|
+
write_version
|
|
30
|
+
write_adapter
|
|
31
|
+
write_spec_helper
|
|
32
|
+
write_conformance_spec
|
|
33
|
+
@dir
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def camelize(str) = str.to_s.split(/[_-]/).map { |w| w[0].upcase + w[1..] }.join
|
|
39
|
+
def underscore(str) = str.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
|
|
40
|
+
|
|
41
|
+
def write(relative_path, content)
|
|
42
|
+
File.write(File.join(@dir, relative_path), content)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_gemspec
|
|
46
|
+
write "#{@dir}.gemspec", <<~RUBY
|
|
47
|
+
require_relative "lib/portage/ucp/#{@slug}/version"
|
|
48
|
+
|
|
49
|
+
Gem::Specification.new do |spec|
|
|
50
|
+
spec.name = "portage-ucp-#{@slug}"
|
|
51
|
+
spec.version = Portage::Ucp::#{@module_name}::VERSION
|
|
52
|
+
spec.summary = "#{@module_name} adapter for portage-ucp"
|
|
53
|
+
spec.description = "Implements Portage::Ucp::Adapter against #{@module_name}'s API. " \\
|
|
54
|
+
"Generic only — no merchant-specific business logic."
|
|
55
|
+
spec.authors = ["Tom Whitbread"]
|
|
56
|
+
spec.license = "MIT"
|
|
57
|
+
spec.homepage = "https://github.com/tomtom87/Portage/tree/main/#{@dir}"
|
|
58
|
+
spec.required_ruby_version = ">= 3.2"
|
|
59
|
+
|
|
60
|
+
spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
61
|
+
spec.require_paths = ["lib"]
|
|
62
|
+
|
|
63
|
+
spec.add_dependency "portage-ucp", "~> 0.6"
|
|
64
|
+
|
|
65
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
66
|
+
spec.add_development_dependency "rubocop", "~> 1.88"
|
|
67
|
+
spec.add_development_dependency "webmock", "~> 3.24"
|
|
68
|
+
spec.metadata["source_code_uri"] = "https://github.com/tomtom87/Portage/tree/main/#{@dir}"
|
|
69
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
70
|
+
end
|
|
71
|
+
RUBY
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def write_gemfile
|
|
75
|
+
write "Gemfile", <<~RUBY
|
|
76
|
+
source "https://rubygems.org"
|
|
77
|
+
|
|
78
|
+
gemspec
|
|
79
|
+
|
|
80
|
+
gem "portage-ucp", path: "../portage-ucp"
|
|
81
|
+
RUBY
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write_rakefile
|
|
85
|
+
write "Rakefile", <<~RUBY
|
|
86
|
+
require "bundler/gem_tasks"
|
|
87
|
+
require "rspec/core/rake_task"
|
|
88
|
+
|
|
89
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
90
|
+
|
|
91
|
+
task default: :spec
|
|
92
|
+
RUBY
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write_rubocop
|
|
96
|
+
write ".rubocop.yml", <<~YAML
|
|
97
|
+
AllCops:
|
|
98
|
+
NewCops: enable
|
|
99
|
+
TargetRubyVersion: 3.2
|
|
100
|
+
SuggestExtensions: false
|
|
101
|
+
|
|
102
|
+
Style/FrozenStringLiteralComment:
|
|
103
|
+
Enabled: false
|
|
104
|
+
|
|
105
|
+
Style/StringLiterals:
|
|
106
|
+
EnforcedStyle: double_quotes
|
|
107
|
+
|
|
108
|
+
Style/Documentation:
|
|
109
|
+
Enabled: false
|
|
110
|
+
|
|
111
|
+
Metrics/BlockLength:
|
|
112
|
+
Exclude:
|
|
113
|
+
- "spec/**/*.rb"
|
|
114
|
+
- "*.gemspec"
|
|
115
|
+
|
|
116
|
+
# The scaffolded Adapter subclass is all `= super` stubs until
|
|
117
|
+
# each one is replaced with a real implementation — rubocop
|
|
118
|
+
# would otherwise flag every one of them as a useless override.
|
|
119
|
+
Lint/UselessMethodDefinition:
|
|
120
|
+
Exclude:
|
|
121
|
+
- "lib/**/adapter.rb"
|
|
122
|
+
|
|
123
|
+
Gemspec/DevelopmentDependencies:
|
|
124
|
+
Enabled: false
|
|
125
|
+
|
|
126
|
+
Gemspec/RequireMFA:
|
|
127
|
+
Enabled: false
|
|
128
|
+
YAML
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def write_lib_entrypoint
|
|
132
|
+
write "lib/portage/ucp/#{@slug}.rb", <<~RUBY
|
|
133
|
+
require "portage/ucp"
|
|
134
|
+
require_relative "#{@slug}/version"
|
|
135
|
+
require_relative "#{@slug}/adapter"
|
|
136
|
+
RUBY
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def write_version
|
|
140
|
+
write "lib/portage/ucp/#{@slug}/version.rb", <<~RUBY
|
|
141
|
+
module Portage
|
|
142
|
+
module Ucp
|
|
143
|
+
module #{@module_name}
|
|
144
|
+
VERSION = "0.1.0".freeze
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
RUBY
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def write_adapter
|
|
152
|
+
write "lib/portage/ucp/#{@slug}/adapter.rb", <<~RUBY
|
|
153
|
+
module Portage
|
|
154
|
+
module Ucp
|
|
155
|
+
module #{@module_name}
|
|
156
|
+
# TODO: fill in a real client/constructor, then replace each
|
|
157
|
+
# `= super` stub below with a real implementation. Leave a
|
|
158
|
+
# method as `= super` to leave that capability unadvertised
|
|
159
|
+
# (Portage::Ucp::Capability#advertised_for? — see adapter.rb).
|
|
160
|
+
class Adapter < Portage::Ucp::Adapter
|
|
161
|
+
#{adapter_method_stubs}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
RUBY
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# One `= super` stub per public method Portage::Ucp::Adapter itself
|
|
170
|
+
# defines, with the same keyword signature — reflected via
|
|
171
|
+
# Method#parameters rather than hardcoded so this never drifts from
|
|
172
|
+
# the contract's actual shape.
|
|
173
|
+
def adapter_method_stubs
|
|
174
|
+
Portage::Ucp::Adapter.instance_methods(false).sort.filter_map do |name|
|
|
175
|
+
next if name.to_s.end_with?("_supported?")
|
|
176
|
+
|
|
177
|
+
params = Portage::Ucp::Adapter.instance_method(name).parameters
|
|
178
|
+
" def #{name}(#{signature(params)}) = super"
|
|
179
|
+
end.join("\n")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def signature(params)
|
|
183
|
+
params.map do |type, param_name|
|
|
184
|
+
case type
|
|
185
|
+
when :keyreq then "#{param_name}:"
|
|
186
|
+
when :key then "#{param_name}: nil"
|
|
187
|
+
end
|
|
188
|
+
end.compact.join(", ")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def write_spec_helper
|
|
192
|
+
write "spec/spec_helper.rb", <<~RUBY
|
|
193
|
+
require "portage/ucp/#{@slug}"
|
|
194
|
+
require "webmock/rspec"
|
|
195
|
+
|
|
196
|
+
WebMock.disable_net_connect!
|
|
197
|
+
|
|
198
|
+
RSpec.configure do |config|
|
|
199
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
200
|
+
config.disable_monkey_patching!
|
|
201
|
+
config.order = :random
|
|
202
|
+
end
|
|
203
|
+
RUBY
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def write_conformance_spec
|
|
207
|
+
write "spec/portage/ucp/#{@slug}/conformance_spec.rb", <<~RUBY
|
|
208
|
+
require "spec_helper"
|
|
209
|
+
require "portage/ucp/rspec"
|
|
210
|
+
|
|
211
|
+
RSpec.describe Portage::Ucp::#{@module_name}::Adapter do
|
|
212
|
+
let(:adapter) { described_class.new }
|
|
213
|
+
let(:existing_product_id) { "TODO-a-real-catalog-id" }
|
|
214
|
+
|
|
215
|
+
# TODO: stub whatever HTTP calls the kit's examples below need —
|
|
216
|
+
# see portage-ucp-etsy/spec/.../conformance_spec.rb for a worked
|
|
217
|
+
# example of stubbing just enough for a catalog+checkout adapter.
|
|
218
|
+
it_behaves_like "a portage adapter"
|
|
219
|
+
end
|
|
220
|
+
RUBY
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "portage/ucp"
|
|
7
|
+
|
|
8
|
+
module Portage
|
|
9
|
+
module Cli
|
|
10
|
+
module Generate
|
|
11
|
+
# Generates and (re)publishes portage-cli's own UCP agent-identity
|
|
12
|
+
# profile document — the `meta.ucp-agent.profile` URL real UCP servers
|
|
13
|
+
# fetch and validate before answering any catalog/cart/checkout call
|
|
14
|
+
# (confirmed live against Shopify's 2026-08-25 rollout: an unreachable
|
|
15
|
+
# or malformed profile 422s with `profile_unreachable`/`profile_malformed`
|
|
16
|
+
# before the request's shape is even considered). This class only
|
|
17
|
+
# produces the document and its signing key; hosting it at a stable,
|
|
18
|
+
# public URL (HTTPS, no redirects, `Cache-Control: public, max-age>=60`
|
|
19
|
+
# — see docs/agent-profile.md) is a separate, one-time infra step.
|
|
20
|
+
#
|
|
21
|
+
# Deliberately NOT Portage::Ucp::Manifest, despite the surface
|
|
22
|
+
# similarity: Manifest builds the *business's* /.well-known/ucp
|
|
23
|
+
# document and nests `signing_keys` inside its own "ucp" envelope
|
|
24
|
+
# (portage-ucp/lib/portage/ucp/manifest.rb); the UCP spec's agent
|
|
25
|
+
# profile is a different document describing the *agent* calling in,
|
|
26
|
+
# and puts `signing_keys` as a sibling of "ucp" at the document root.
|
|
27
|
+
# Same-looking key material, structurally different document — not
|
|
28
|
+
# interchangeable, so this doesn't subclass or reuse Manifest.
|
|
29
|
+
#
|
|
30
|
+
# `Portage::Ucp::Manifest::UCP_VERSION` is reused as-is (not
|
|
31
|
+
# redeclared) so the two documents can never drift to different spec
|
|
32
|
+
# versions by accident.
|
|
33
|
+
#
|
|
34
|
+
# Rotation is the future-proofing this exists for: re-running with
|
|
35
|
+
# `rotate: true` keeps every key already published (so a request
|
|
36
|
+
# signed under an older `kid` keeps verifying while callers migrate)
|
|
37
|
+
# and adds one freshly generated key alongside them. Nothing here ever
|
|
38
|
+
# drops a key — retiring one is a deliberate, separate edit once
|
|
39
|
+
# nothing signs with it any more.
|
|
40
|
+
class AgentProfile
|
|
41
|
+
UCP_VERSION = Portage::Ucp::Manifest::UCP_VERSION
|
|
42
|
+
|
|
43
|
+
Key = Struct.new(:kid, :jwk, :private_pem, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
# @param out [String] path to write the public profile JSON document
|
|
46
|
+
# @param key_out [String] path to write the new private key's PEM —
|
|
47
|
+
# caller's responsibility to keep this out of version control
|
|
48
|
+
# @param rotate [Boolean] keep existing signing_keys from `out` (if
|
|
49
|
+
# it already exists) and add a new one, instead of replacing them
|
|
50
|
+
# @return [Hash] { profile_path:, private_key_path:, kid: } — the
|
|
51
|
+
# kid of the newly generated key
|
|
52
|
+
def self.generate(out:, key_out:, rotate: false)
|
|
53
|
+
new(out: out, key_out: key_out, rotate: rotate).generate
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize(out:, key_out:, rotate: false)
|
|
57
|
+
@out = out
|
|
58
|
+
@key_out = key_out
|
|
59
|
+
@rotate = rotate
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def generate
|
|
63
|
+
new_key = generate_key
|
|
64
|
+
doc = build_document(carried_forward_keys + [new_key.jwk])
|
|
65
|
+
|
|
66
|
+
write_profile(doc)
|
|
67
|
+
write_private_key(new_key.private_pem)
|
|
68
|
+
|
|
69
|
+
{ profile_path: @out, private_key_path: @key_out, kid: new_key.kid }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def carried_forward_keys
|
|
75
|
+
return [] unless @rotate && File.exist?(@out)
|
|
76
|
+
|
|
77
|
+
JSON.parse(File.read(@out)).fetch("signing_keys", [])
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_document(signing_keys)
|
|
83
|
+
{
|
|
84
|
+
"ucp" => {
|
|
85
|
+
"version" => UCP_VERSION,
|
|
86
|
+
"services" => {},
|
|
87
|
+
"capabilities" => {},
|
|
88
|
+
"payment_handlers" => {}
|
|
89
|
+
},
|
|
90
|
+
"signing_keys" => signing_keys
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def write_profile(doc)
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(@out))
|
|
96
|
+
File.write(@out, "#{JSON.pretty_generate(doc)}\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_private_key(pem)
|
|
100
|
+
FileUtils.mkdir_p(File.dirname(@key_out))
|
|
101
|
+
File.write(@key_out, pem)
|
|
102
|
+
File.chmod(0o600, @key_out)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A JWK's `kid` is derived from the key material itself (RFC 7638
|
|
106
|
+
# thumbprint) rather than assigned, so it can't drift from the key
|
|
107
|
+
# it names — a caller can't accidentally publish a profile where a
|
|
108
|
+
# `kid` points at the wrong entry.
|
|
109
|
+
def generate_key
|
|
110
|
+
pkey = OpenSSL::PKey::EC.generate("prime256v1")
|
|
111
|
+
x_b64, y_b64 = coordinates(pkey)
|
|
112
|
+
kid = thumbprint(x_b64, y_b64)
|
|
113
|
+
|
|
114
|
+
jwk = { "kid" => kid, "kty" => "EC", "crv" => "P-256", "x" => x_b64, "y" => y_b64,
|
|
115
|
+
"use" => "sig", "alg" => "ES256" }
|
|
116
|
+
|
|
117
|
+
Key.new(kid: kid, jwk: jwk, private_pem: pkey.to_pem)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Raw uncompressed EC point encoding: 0x04 || x (32 bytes) || y (32
|
|
121
|
+
# bytes) for P-256 — see Portage::Ucp::Security::Signature::CURVES,
|
|
122
|
+
# which decodes the same layout in reverse when verifying.
|
|
123
|
+
def coordinates(pkey)
|
|
124
|
+
octets = pkey.public_key.to_bn.to_s(2)
|
|
125
|
+
coord_bytes = 32
|
|
126
|
+
x = octets[1, coord_bytes]
|
|
127
|
+
y = octets[1 + coord_bytes, coord_bytes]
|
|
128
|
+
[url_b64(x), url_b64(y)]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def thumbprint(x_b64, y_b64)
|
|
132
|
+
canonical = JSON.generate({ "crv" => "P-256", "kty" => "EC", "x" => x_b64, "y" => y_b64 })
|
|
133
|
+
url_b64(Digest::SHA256.digest(canonical))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def url_b64(bytes)
|
|
137
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED
data/lib/portage/cli.rb
CHANGED
|
@@ -9,6 +9,9 @@ require_relative "cli/find"
|
|
|
9
9
|
require_relative "cli/compare"
|
|
10
10
|
require_relative "cli/history"
|
|
11
11
|
require_relative "cli/payment_methods"
|
|
12
|
+
require_relative "cli/doctor"
|
|
13
|
+
require_relative "cli/generate/adapter"
|
|
14
|
+
require_relative "cli/generate/agent_profile"
|
|
12
15
|
|
|
13
16
|
module Portage
|
|
14
17
|
# `portage` — the single command-line entrypoint for acting as a shopper's
|
|
@@ -37,23 +40,23 @@ module Portage
|
|
|
37
40
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
38
41
|
[--velocity-count N --velocity-window-seconds N]
|
|
39
42
|
[--allow HOST ...] [--clear-allowlist]
|
|
43
|
+
portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
|
|
44
|
+
portage generate adapter NAME [--dir DIR]
|
|
45
|
+
portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
|
|
40
46
|
USAGE
|
|
41
47
|
|
|
48
|
+
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
49
|
+
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
50
|
+
"doctor" => :run_doctor, "generate" => :run_generate }.freeze
|
|
51
|
+
|
|
42
52
|
# @param argv [Array<String>]
|
|
43
53
|
# @return [Integer] process exit code
|
|
44
54
|
def self.run(argv)
|
|
45
55
|
command, *rest = argv
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
when "history" then run_history(rest)
|
|
51
|
-
when "payment" then run_payment(rest)
|
|
52
|
-
when "policy" then run_policy(rest)
|
|
53
|
-
else
|
|
54
|
-
warn USAGE
|
|
55
|
-
1
|
|
56
|
-
end
|
|
56
|
+
return send(COMMANDS[command], rest) if COMMANDS.key?(command)
|
|
57
|
+
|
|
58
|
+
warn USAGE
|
|
59
|
+
1
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
# --- find ---
|
|
@@ -517,6 +520,84 @@ module Portage
|
|
|
517
520
|
end
|
|
518
521
|
private_class_method :format_payment_enroll
|
|
519
522
|
|
|
523
|
+
# --- doctor ---
|
|
524
|
+
|
|
525
|
+
def self.parse_doctor_options(argv)
|
|
526
|
+
opts = {}
|
|
527
|
+
OptionParser.new do |parser|
|
|
528
|
+
parser.on("--require FILE") { |v| opts[:require] = v }
|
|
529
|
+
parser.on("--adapter CLASS_NAME") { |v| opts[:adapter] = v }
|
|
530
|
+
parser.on("--json") { opts[:json] = true }
|
|
531
|
+
end.parse!(argv)
|
|
532
|
+
opts
|
|
533
|
+
end
|
|
534
|
+
private_class_method :parse_doctor_options
|
|
535
|
+
|
|
536
|
+
def self.run_doctor(argv)
|
|
537
|
+
opts = parse_doctor_options(argv)
|
|
538
|
+
require File.expand_path(opts[:require]) if opts[:require]
|
|
539
|
+
adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
|
|
540
|
+
|
|
541
|
+
findings = Doctor.new(adapter_class: adapter_class).call
|
|
542
|
+
puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
|
|
543
|
+
findings.empty? ? 0 : 1
|
|
544
|
+
end
|
|
545
|
+
private_class_method :run_doctor
|
|
546
|
+
|
|
547
|
+
def self.format_doctor(findings)
|
|
548
|
+
return "No issues found." if findings.empty?
|
|
549
|
+
|
|
550
|
+
findings.map { |f| "[#{f.check}] #{f.message}" }.join("\n")
|
|
551
|
+
end
|
|
552
|
+
private_class_method :format_doctor
|
|
553
|
+
|
|
554
|
+
# --- generate ---
|
|
555
|
+
|
|
556
|
+
def self.run_generate(argv)
|
|
557
|
+
kind, *rest = argv
|
|
558
|
+
case kind
|
|
559
|
+
when "adapter" then run_generate_adapter(rest)
|
|
560
|
+
when "agent-profile" then run_generate_agent_profile(rest)
|
|
561
|
+
else
|
|
562
|
+
warn USAGE
|
|
563
|
+
1
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
private_class_method :run_generate
|
|
567
|
+
|
|
568
|
+
def self.run_generate_adapter(rest)
|
|
569
|
+
name, *rest = rest
|
|
570
|
+
unless name
|
|
571
|
+
warn USAGE
|
|
572
|
+
return 1
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
dir = nil
|
|
576
|
+
OptionParser.new { |parser| parser.on("--dir DIR") { |v| dir = v } }.parse!(rest)
|
|
577
|
+
path = Generate::Adapter.new(name: name, dir: dir).call
|
|
578
|
+
puts "Scaffolded #{path}/"
|
|
579
|
+
0
|
|
580
|
+
end
|
|
581
|
+
private_class_method :run_generate_adapter
|
|
582
|
+
|
|
583
|
+
def self.run_generate_agent_profile(rest)
|
|
584
|
+
out = "agent-profile.json"
|
|
585
|
+
key_out = "agent-profile.key.pem"
|
|
586
|
+
rotate = false
|
|
587
|
+
OptionParser.new do |parser|
|
|
588
|
+
parser.on("--out FILE") { |v| out = v }
|
|
589
|
+
parser.on("--key-out FILE") { |v| key_out = v }
|
|
590
|
+
parser.on("--rotate") { rotate = true }
|
|
591
|
+
end.parse!(rest)
|
|
592
|
+
|
|
593
|
+
result = Generate::AgentProfile.generate(out: out, key_out: key_out, rotate: rotate)
|
|
594
|
+
puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
|
|
595
|
+
puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
|
|
596
|
+
"and off the machine that serves the public profile"
|
|
597
|
+
0
|
|
598
|
+
end
|
|
599
|
+
private_class_method :run_generate_agent_profile
|
|
600
|
+
|
|
520
601
|
# --- output ---
|
|
521
602
|
|
|
522
603
|
def self.format_report(report)
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: portage-ucp
|
|
@@ -16,28 +15,42 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - "~>"
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '0.
|
|
18
|
+
version: '0.8'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - "~>"
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '0.
|
|
25
|
+
version: '0.8'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
27
|
name: portage-ucp-client
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
30
29
|
requirements:
|
|
31
30
|
- - "~>"
|
|
32
31
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0.
|
|
32
|
+
version: '0.4'
|
|
34
33
|
type: :runtime
|
|
35
34
|
prerelease: false
|
|
36
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
36
|
requirements:
|
|
38
37
|
- - "~>"
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0.
|
|
39
|
+
version: '0.4'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: portage-ucp-journal
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.1'
|
|
41
54
|
- !ruby/object:Gem::Dependency
|
|
42
55
|
name: rspec
|
|
43
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -102,11 +115,13 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
|
|
|
102
115
|
shopper. `portage find` covers the no-URL case: ask an allowlist, DuckDuckGo, Brave,
|
|
103
116
|
or a Google Programmable Search engine which stores might sell something, keep the
|
|
104
117
|
ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
|
|
105
|
-
no SERP scraping. Depends on portage-ucp (for platform detection via Resolver)
|
|
106
|
-
portage-ucp-client (for the actual buy calls)
|
|
107
|
-
|
|
118
|
+
no SERP scraping. Depends on portage-ucp (for platform detection via Resolver),
|
|
119
|
+
portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
|
|
120
|
+
read-only view of local purchase/transaction/order state); no single adapter gem
|
|
121
|
+
is a hard dependency.'
|
|
108
122
|
executables:
|
|
109
123
|
- portage
|
|
124
|
+
- portage-console
|
|
110
125
|
extensions: []
|
|
111
126
|
extra_rdoc_files: []
|
|
112
127
|
files:
|
|
@@ -114,11 +129,16 @@ files:
|
|
|
114
129
|
- LICENSE
|
|
115
130
|
- README.md
|
|
116
131
|
- exe/portage
|
|
132
|
+
- exe/portage-console
|
|
117
133
|
- lib/portage/cli.rb
|
|
118
134
|
- lib/portage/cli/buy.rb
|
|
119
135
|
- lib/portage/cli/catalog_products.rb
|
|
120
136
|
- lib/portage/cli/compare.rb
|
|
137
|
+
- lib/portage/cli/console.rb
|
|
138
|
+
- lib/portage/cli/doctor.rb
|
|
121
139
|
- lib/portage/cli/find.rb
|
|
140
|
+
- lib/portage/cli/generate/adapter.rb
|
|
141
|
+
- lib/portage/cli/generate/agent_profile.rb
|
|
122
142
|
- lib/portage/cli/history.rb
|
|
123
143
|
- lib/portage/cli/payment_methods.rb
|
|
124
144
|
- lib/portage/cli/payment_methods/env_backend.rb
|
|
@@ -135,7 +155,6 @@ metadata:
|
|
|
135
155
|
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
|
|
136
156
|
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
|
|
137
157
|
rubygems_mfa_required: 'true'
|
|
138
|
-
post_install_message:
|
|
139
158
|
rdoc_options: []
|
|
140
159
|
require_paths:
|
|
141
160
|
- lib
|
|
@@ -150,8 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
150
169
|
- !ruby/object:Gem::Version
|
|
151
170
|
version: '0'
|
|
152
171
|
requirements: []
|
|
153
|
-
rubygems_version:
|
|
154
|
-
signing_key:
|
|
172
|
+
rubygems_version: 4.0.21
|
|
155
173
|
specification_version: 4
|
|
156
174
|
summary: portage — one CLI command to buy from any store, native UCP or not
|
|
157
175
|
test_files: []
|