portage-cli 0.1.0 → 0.2.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: a4a32f5894d104c340e147d698772d21492c3172d6b8e102c4508e431d210a9e
4
- data.tar.gz: dbf75ae4f0c5bd7996095d40c0a7e019d4cf9e0376b529356e227d5879363a4a
3
+ metadata.gz: 8a7b8a3138a4fdb27a32fdd32637d783ded5536e8933ad27a42ba688e550d51e
4
+ data.tar.gz: 7e55c31d7d3ca91b7647152bff300c191b4593ca7e4956baf11488384ba7dd75
5
5
  SHA512:
6
- metadata.gz: e41a7814ccdf875fae65a3ec9001c678e6cd0f951c504a0f675d3a34c3126349486df39919e514056b2529305d6560745ca4e028269c69b58e60d6610465f3fc
7
- data.tar.gz: 75503266fb054b39739bcd2786083454e0afdefae6e64aaa2573ebc6b4633fd4c3956b16c61707cdbb3bc688a56e31734c4b04293678473b8ffc776a80ceb4d5
6
+ metadata.gz: 6f081ed17df4df877b15a25d3f0892c1f9fc891ce5fa82a5782acbe5a9deec9636b0b4a9b4c98f0ceea631e9711af9bb321ed1d2e65b34733f114cff35c3a5b1
7
+ data.tar.gz: 120db3f4d0cbf97ce2a24e877ac3ce017ba9db39bb53bcf944249cf3e507c04a49193267253b41940d5688cd4aa55c3c420f2e1ccbc6e15541bde045585013ff
data/CHANGELOG.md CHANGED
@@ -4,7 +4,18 @@ 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.1.0] - Unreleased
7
+ ## [0.2.0] - 2026-08-21
8
+
9
+ - `PORTAGE_SHIP_*` env vars (`Portage::Cli::ShippingProfile`) — configure a
10
+ default shipping address for `portage buy`'s own-store adapter-loopback
11
+ path, the same way adapter credentials already live in env. `portage buy`
12
+ auto-picks the cheapest priced option per fulfillment group once an address
13
+ is submitted; there's no interactive rate picker, since the CLI drives one
14
+ automated purchase rather than a conversation. The native UCP session path
15
+ (a third-party store over stdio/HTTP) isn't wired yet — no real UCP server
16
+ to verify a `fulfillment` wire shape against.
17
+
18
+ ## [0.1.0] - 2026-08-14
8
19
 
9
20
  - Initial pre-release. `portage buy <url>` — native UCP discovery first,
10
21
  adapter fallback only when this process already has that platform's own
data/README.md CHANGED
@@ -82,6 +82,31 @@ Exits `0` when a checkout completed (or a dry-run/browse/search resolved
82
82
  successfully), `1` otherwise — including the "no native manifest, no adapter
83
83
  credentials" dead-end case, so it's scriptable in CI.
84
84
 
85
+ ### Shipping address (own-store checkouts only)
86
+
87
+ When buying against your own store (`portage buy`'s step 2 adapter-credentials
88
+ fallback, described at the top of this file) and that adapter supports
89
+ `dev.ucp.shopping.fulfillment`, set a default shipping address via env
90
+ rather than a flag, same posture as adapter credentials:
91
+
92
+ ```bash
93
+ export PORTAGE_SHIP_STREET="1 Main St"
94
+ export PORTAGE_SHIP_CITY="Erie"
95
+ export PORTAGE_SHIP_REGION="PA" # optional
96
+ export PORTAGE_SHIP_COUNTRY="US"
97
+ export PORTAGE_SHIP_POSTAL_CODE="16501"
98
+ export PORTAGE_SHIP_FIRST_NAME="Ada" # optional
99
+ export PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
100
+ export PORTAGE_SHIP_PHONE="+1..." # optional
101
+ ```
102
+
103
+ `street`/`city`/`country`/`postal_code` are required — a partial profile is
104
+ treated as no profile at all. Once the merchant prices shipping options
105
+ against that address, `portage buy` auto-picks the cheapest per fulfillment
106
+ group; there's no interactive rate picker, since this drives one automated
107
+ purchase. Native (non-adapter) UCP stores don't get this yet — see
108
+ `portage-ucp`'s design log for why.
109
+
85
110
  ## Buying without a URL
86
111
 
87
112
  `portage find` and URL-less `portage buy` share one pipeline:
@@ -113,7 +113,7 @@ module Portage
113
113
 
114
114
  adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
115
115
  if adapter_supports_checkout?(adapter)
116
- full_buy(client_for(adapter), source: "adapter:#{platform.name}")
116
+ full_buy(client_for(adapter), source: "adapter:#{platform.name}", fulfillment_adapter: adapter)
117
117
  else
118
118
  catalog_only_adapter(adapter, platform)
119
119
  end
@@ -152,7 +152,13 @@ module Portage
152
152
 
153
153
  # --- The actual buy, shared by native and adapter-loopback sources ---
154
154
 
155
- def full_buy(session, source:)
155
+ # `fulfillment_adapter:` is only ever set by #adapter_flow (the
156
+ # own-store loopback path) — #native_flow has no raw Adapter object to
157
+ # inspect for `fulfillment_supported?`, just a Session talking to a
158
+ # remote store, so shipping-address/rate selection stays loopback-only
159
+ # for now rather than guessing at a wire shape no real UCP server has
160
+ # confirmed (see docs/design-log.md).
161
+ def full_buy(session, source:, fulfillment_adapter: nil)
156
162
  products = safe_search(session)
157
163
  product = select_product(products)
158
164
  unless product
@@ -160,10 +166,68 @@ module Portage
160
166
  message: no_match_message)
161
167
  end
162
168
 
163
- checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }])
169
+ checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }],
170
+ fulfillment: requested_fulfillment(fulfillment_adapter))
171
+ checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
164
172
  finish_checkout(session, source, products, checkout)
165
173
  end
166
174
 
175
+ # Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
176
+ # checkout's shipping destination, but only when the adapter actually
177
+ # advertises dev.ucp.shopping.fulfillment — an adapter that doesn't
178
+ # override #fulfillment_supported? would just ignore the param anyway
179
+ # (Adapter#create_checkout's default), but there's no point building it
180
+ # at all in that case.
181
+ def requested_fulfillment(adapter)
182
+ return nil unless adapter && Portage::Ucp::Capabilities::FULFILLMENT.advertised_for?(adapter)
183
+
184
+ address = Portage::Cli::ShippingProfile.from_env
185
+ return nil unless address
186
+
187
+ Portage::Ucp::CheckoutFulfillment.new(
188
+ shipping_methods: [Portage::Ucp::FulfillmentMethod.new(
189
+ id: "requested", type: "shipping", line_item_ids: [],
190
+ destinations: [Portage::Ucp::ShippingDestination.new(id: "current", address: address)]
191
+ )]
192
+ )
193
+ end
194
+
195
+ # Once the merchant has priced options against the submitted address,
196
+ # auto-picks the cheapest unselected option per fulfillment group and
197
+ # submits it via #update_checkout — no interactive rate picker; a CLI
198
+ # driving a single automated purchase needs a deterministic default,
199
+ # not a prompt. A checkout with no fulfillment groups at all (no
200
+ # address was submitted, or the merchant hasn't priced anything yet)
201
+ # passes through unchanged.
202
+ def select_cheapest_shipping(session, checkout)
203
+ groups = checkout.dig("fulfillment", "methods", 0, "groups") || []
204
+ selections = groups.filter_map { |g| cheapest_option_selection(g) }
205
+ return checkout if selections.empty?
206
+
207
+ session.update_checkout(
208
+ checkout_id: checkout["id"], line_items: current_line_items(checkout),
209
+ fulfillment: Portage::Ucp::CheckoutFulfillment.new(
210
+ shipping_methods: [Portage::Ucp::FulfillmentMethod.new(id: "requested", type: "shipping",
211
+ line_item_ids: [], groups: selections)]
212
+ )
213
+ )
214
+ end
215
+
216
+ def cheapest_option_selection(group)
217
+ return nil if group["selected_option_id"] || (group["options"] || []).empty?
218
+
219
+ cheapest = group["options"].min_by { |o| o.dig("totals", 0, "amount") || 0 }
220
+ Portage::Ucp::FulfillmentGroup.new(id: group["id"], line_item_ids: group["line_item_ids"],
221
+ selected_option_id: cheapest["id"])
222
+ end
223
+
224
+ # Checkout's line_items are response-shaped ({item: {id, ...}, ...});
225
+ # #update_checkout takes request-shaped hashes, same asymmetry
226
+ # #product_id_of already handles for search results.
227
+ def current_line_items(checkout)
228
+ checkout["line_items"].map { |li| { product_id: li.dig("item", "id"), quantity: li["quantity"] } }
229
+ end
230
+
167
231
  def no_match_message
168
232
  return "No product matched \"#{@query}\"." unless @product_id
169
233
 
@@ -0,0 +1,32 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # Reads a shopper's default shipping address from the process environment
6
+ # — the same "your own credentials live in env, not on the command line"
7
+ # posture Resolver.env_for already uses for adapter credentials, applied
8
+ # here to a buyer-profile concern instead of a platform-credential one
9
+ # (so it lives in portage-cli, not Portage::Ucp::Resolver).
10
+ module ShippingProfile
11
+ ENV_VARS = {
12
+ street_address: "PORTAGE_SHIP_STREET", extended_address: "PORTAGE_SHIP_EXTENDED",
13
+ address_locality: "PORTAGE_SHIP_CITY", address_region: "PORTAGE_SHIP_REGION",
14
+ address_country: "PORTAGE_SHIP_COUNTRY", postal_code: "PORTAGE_SHIP_POSTAL_CODE",
15
+ first_name: "PORTAGE_SHIP_FIRST_NAME", last_name: "PORTAGE_SHIP_LAST_NAME",
16
+ phone_number: "PORTAGE_SHIP_PHONE"
17
+ }.freeze
18
+
19
+ REQUIRED = %i[street_address address_locality address_country postal_code].freeze
20
+
21
+ # @return [Portage::Ucp::PostalAddress, nil] nil unless every required
22
+ # field is set — a partial profile isn't enough to submit, and this
23
+ # module never guesses at a missing field.
24
+ def self.from_env
25
+ attrs = ENV_VARS.filter_map { |key, var| [key, ENV.fetch(var, nil)] if ENV.key?(var) }.to_h
26
+ return nil unless REQUIRED.all? { |key| attrs.key?(key) }
27
+
28
+ Portage::Ucp::PostalAddress.new(**attrs)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.1.0".freeze
3
+ VERSION = "0.2.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -2,6 +2,7 @@ require "optparse"
2
2
  require "json"
3
3
 
4
4
  require_relative "cli/version"
5
+ require_relative "cli/shipping_profile"
5
6
  require_relative "cli/buy"
6
7
  require_relative "cli/find"
7
8
 
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.1.0
4
+ version: 0.2.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-08-14 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.1'
18
+ version: '0.2'
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.1'
25
+ version: '0.2'
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.1'
32
+ version: '0.2'
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.1'
39
+ version: '0.2'
41
40
  - !ruby/object:Gem::Dependency
42
41
  name: rspec
43
42
  requirement: !ruby/object:Gem::Requirement
@@ -104,7 +103,6 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
104
103
  ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
105
104
  no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
106
105
  portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
107
- email:
108
106
  executables:
109
107
  - portage
110
108
  extensions: []
@@ -119,6 +117,7 @@ files:
119
117
  - lib/portage/cli/find.rb
120
118
  - lib/portage/cli/probe_cache.rb
121
119
  - lib/portage/cli/search_backends.rb
120
+ - lib/portage/cli/shipping_profile.rb
122
121
  - lib/portage/cli/version.rb
123
122
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli
124
123
  licenses:
@@ -127,7 +126,6 @@ metadata:
127
126
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
128
127
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
129
128
  rubygems_mfa_required: 'true'
130
- post_install_message:
131
129
  rdoc_options: []
132
130
  require_paths:
133
131
  - lib
@@ -142,8 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
140
  - !ruby/object:Gem::Version
143
141
  version: '0'
144
142
  requirements: []
145
- rubygems_version: 3.5.22
146
- signing_key:
143
+ rubygems_version: 4.0.18
147
144
  specification_version: 4
148
145
  summary: portage — one CLI command to buy from any store, native UCP or not
149
146
  test_files: []