portage-cli 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a0e9e7e0b7046fc20978a89fb06056bf1827df5efa45c47ddfb0b16f332d53f
4
- data.tar.gz: 9ab0f0c9caa507147fae5069dda27c86a92e4c26f273c9cbc14cee8fbfddf368
3
+ metadata.gz: 4acb63ece20e24b7d43048315145a96e15ded1c4e5b0cf83c3f045603a033af6
4
+ data.tar.gz: 536057c80badcd44ee1be90679fd7f9233adb3d5a1c07b5b11a12201fbae5701
5
5
  SHA512:
6
- metadata.gz: 247e88dba38ac3171cedccd90d0d1e56ff225cf8aa2526598c3d670391d79fc908fe9fe81735aa71f1c389516ae04c5c71512c61e56bc39802b92bd213d6b40a
7
- data.tar.gz: 07d42f21ba32010310394a39a664599c09e840a58cc1be6dd5bcbed7e9d0adf46639364f2bbd70ee70f63fcbfe9407a6d04af9ea75669cc5ae6a5f0f226c5226
6
+ metadata.gz: 54bc26bf74fe96c2a2154062ab41619e10bf046da1cc06f067fc1f0f7eaec9c29ff6ab461e9cf2907ede4084805b8718f65557e5d4c3c79c486650eea10c1075
7
+ data.tar.gz: deb45f093ce769ebae837bad5af628c8970aac76b6741ea8b083393b45038451a455ece2d603671c3d2884e3974858941ff6c29d96affd7bd3bbcc89f6a689d9
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ 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
+ ## [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
+
7
40
  ## [0.5.1] - 2026-09-16
8
41
 
9
42
  - No behavior change — 0.5.0 was built and pushed with `gem build` run from
@@ -61,6 +61,14 @@ module Portage
61
61
 
62
62
  def discover(url)
63
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
64
72
  rescue Portage::Ucp::Client::DiscoveryError
65
73
  nil
66
74
  end
@@ -71,6 +79,30 @@ module Portage
71
79
  else
72
80
  catalog_only(session)
73
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
74
106
  end
75
107
 
76
108
  def catalog_only(session)
@@ -156,8 +188,9 @@ module Portage
156
188
  def redirect_checkout(adapter, products)
157
189
  return nil if products.empty? || !Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
158
190
 
159
- adapter.create_checkout(line_items: [{ product_id: products.first.id, quantity: @qty }],
160
- idempotency_key: "portage-buy-#{products.first.id}")
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}")
161
194
  rescue StandardError
162
195
  nil
163
196
  end
@@ -178,8 +211,9 @@ module Portage
178
211
  message: no_match_message)
179
212
  end
180
213
 
181
- checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }],
182
- 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)
183
217
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
184
218
  finish_checkout(session, source, products, checkout)
185
219
  end
@@ -264,6 +298,20 @@ module Portage
264
298
  product["id"]
265
299
  end
266
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
+
267
315
  def finish_checkout(session, source, products, checkout)
268
316
  status = checkout["status"]
269
317
  return escalation_report(source, products, checkout) if status == "requires_escalation"
@@ -314,7 +362,15 @@ module Portage
314
362
  end
315
363
 
316
364
  def safe_search(session)
317
- 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) }
318
374
  end
319
375
 
320
376
  # --- Homepage fetch (used by both the manifest-not-found path and the
@@ -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(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
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,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
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.5.1".freeze
3
+ VERSION = "0.6.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "cli/history"
11
11
  require_relative "cli/payment_methods"
12
12
  require_relative "cli/doctor"
13
13
  require_relative "cli/generate/adapter"
14
+ require_relative "cli/generate/agent_profile"
14
15
 
15
16
  module Portage
16
17
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -41,6 +42,7 @@ module Portage
41
42
  [--allow HOST ...] [--clear-allowlist]
42
43
  portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
43
44
  portage generate adapter NAME [--dir DIR]
45
+ portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
44
46
  USAGE
45
47
 
46
48
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
@@ -552,8 +554,20 @@ module Portage
552
554
  # --- generate ---
553
555
 
554
556
  def self.run_generate(argv)
555
- kind, name, *rest = argv
556
- unless kind == "adapter" && name
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
557
571
  warn USAGE
558
572
  return 1
559
573
  end
@@ -564,7 +578,25 @@ module Portage
564
578
  puts "Scaffolded #{path}/"
565
579
  0
566
580
  end
567
- private_class_method :run_generate
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
568
600
 
569
601
  # --- output ---
570
602
 
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.5.1
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: 2026-09-16 00:00:00.000000000 Z
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,28 @@ dependencies:
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '0.7'
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.7'
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.3'
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.3'
39
+ version: '0.4'
41
40
  - !ruby/object:Gem::Dependency
42
41
  name: portage-ucp-journal
43
42
  requirement: !ruby/object:Gem::Requirement
@@ -120,7 +119,6 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
120
119
  portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
121
120
  read-only view of local purchase/transaction/order state); no single adapter gem
122
121
  is a hard dependency.'
123
- email:
124
122
  executables:
125
123
  - portage
126
124
  - portage-console
@@ -140,6 +138,7 @@ files:
140
138
  - lib/portage/cli/doctor.rb
141
139
  - lib/portage/cli/find.rb
142
140
  - lib/portage/cli/generate/adapter.rb
141
+ - lib/portage/cli/generate/agent_profile.rb
143
142
  - lib/portage/cli/history.rb
144
143
  - lib/portage/cli/payment_methods.rb
145
144
  - lib/portage/cli/payment_methods/env_backend.rb
@@ -156,7 +155,6 @@ metadata:
156
155
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
157
156
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
158
157
  rubygems_mfa_required: 'true'
159
- post_install_message:
160
158
  rdoc_options: []
161
159
  require_paths:
162
160
  - lib
@@ -171,8 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
171
169
  - !ruby/object:Gem::Version
172
170
  version: '0'
173
171
  requirements: []
174
- rubygems_version: 3.5.22
175
- signing_key:
172
+ rubygems_version: 4.0.21
176
173
  specification_version: 4
177
174
  summary: portage — one CLI command to buy from any store, native UCP or not
178
175
  test_files: []