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 +4 -4
- data/CHANGELOG.md +12 -1
- data/README.md +25 -0
- data/lib/portage/cli/buy.rb +67 -3
- data/lib/portage/cli/shipping_profile.rb +32 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +1 -0
- metadata +8 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a7b8a3138a4fdb27a32fdd32637d783ded5536e8933ad27a42ba688e550d51e
|
|
4
|
+
data.tar.gz: 7e55c31d7d3ca91b7647152bff300c191b4593ca7e4956baf11488384ba7dd75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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:
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/portage/cli/version.rb
CHANGED
data/lib/portage/cli.rb
CHANGED
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.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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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: []
|