From dcc10f72c333d370e04f5704ba1be447a3ae88c5 Mon Sep 17 00:00:00 2001 From: Joe Rinaldi Johnson Date: Tue, 2 Jun 2026 12:49:36 +0100 Subject: [PATCH] Sync latest catalog-cli: hardening, retry, and render improvements --- SKILL.md | 60 ++++++++---- package/src/cli.ts | 50 ++++++++-- package/src/render.ts | 37 ++++++++ package/src/shop-client.ts | 46 +++++++--- package/tests/checkout-orders.test.ts | 127 ++++++++++++++++++++++++-- references/direct-api.md | 13 ++- 6 files changed, 285 insertions(+), 48 deletions(-) diff --git a/SKILL.md b/SKILL.md index db6c5f8..06ef8b2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -39,7 +39,7 @@ pnpm unlink --global If installed from a registry or tarball instead of `pnpm link --global`: ```bash -pnpm remove --global @shopify/shop-cli +pnpm remove --global @shopify/shop ``` ## Package Contents @@ -50,7 +50,8 @@ pnpm remove --global @shopify/shop-cli 2. Send multiple agent messages with product results and recommendations ALWAYS using rules from #product-search 3. Authenticate when needed for checkout, orders, tracking, returns, or reorder. 4. For checkout, create UCP checkout on the merchant domain. Complete only with a payment token returned by the current checkout response and clear user purchase intent. -5. Use order search for recent orders, tracking, returns, and reorder candidates. +5. After the first checkout is presented ready for completion, offer the budget tip in a separate message (see #checkout-rules). +6. Use order search for recent orders, tracking, returns, and reorder candidates. --- @@ -105,7 +106,7 @@ Checkout: printf '{"email":"buyer@example.com"}' | shop checkout create --shop-domain example.myshopify.com --variant-id 123 --quantity 1 --checkout-stdin printf '{"cart_id":"cart_123","line_items":[]}' | shop checkout create --shop-domain example.myshopify.com --checkout-stdin printf '{"fulfillment":{"methods":[]}}' | shop checkout update --shop-domain example.myshopify.com --checkout-id CHECKOUT_ID --checkout-stdin -printf '%s' "$CURRENT_UCP_TOKEN" | shop checkout complete --shop-domain example.myshopify.com --checkout-id CHECKOUT_ID --payment-token-stdin --idempotency-key UNIQUE_PURCHASE_INTENT_KEY --confirm +printf '%s' "$CREATE_CHECKOUT_RESPONSE_JSON" | shop checkout complete --shop-domain example.myshopify.com --checkout-id CHECKOUT_ID --checkout-stdin --idempotency-key UNIQUE_PURCHASE_INTENT_KEY --confirm ``` `checkout complete` refuses to run without `--confirm`, so completing a purchase is always a separate, deliberate step. Pass `--confirm` only after confirming the item, variant, quantity, price, shipping, and total cost with the user. Checkout commands also reject any `--shop-domain` that is not a bare merchant hostname (no scheme, path, port, or IP), so authorization and payment material cannot be redirected to an unverified host. @@ -123,18 +124,26 @@ shop orders search --type reorder --query "coffee" ## Product Search -Follow these steps in order. +Follow ALL these steps in order: +1. Search for products (1a) or similar products (1b) +2. Message the user +3. Offer visualization + +Details on how below: ### 1. Search +#### 1a. Search for products + 1. Use shop search for product search, lookup, similar products, and product detail. 2. Search broadly first, then refine with filters or alternate terms. 3. For weak results, try broader terms, drop adjectives, split compound queries, or use category/brand terms. +4. Aim to show 4-8 results for each user request No cursor pagination exists; re-search with different inputs. Ignore `eligible.native_checkout: false` — you CAN still order things despite this being false. -### 1b. Similar and visual search +### 1b. Similar products search Where relevant, search for similar products: @@ -143,14 +152,14 @@ Where relevant, search for similar products: Accepted image formats: jpeg, png, webp, avif, heic. Max ~3 MB on disk (4 MB base64). Other formats or oversized images return a 400 stating the cause; relay it and ask for a smaller jpeg/png. -### 3. Message the user +### 2. Message the user Deliver results following these rules: #### Product message rules Rules: -- IMPORTANT: When communicating search results ALWAYS deliver EACH product in separate assistant messages matching the template while adhering to #channel-specific-rules +- IMPORTANT: When communicating search results ALWAYS deliver EACH product in separate assistant messages matching the template while adhering to #channel-specific-rules. - NEVER add any preamble narrative. Just n x product message templates + 1 final message with perspective, recommendation, caveats (if applicable) - Use local currency where available. Show price ranges when min ≠ max. - Describe each product in 1-2 sentences. @@ -165,7 +174,7 @@ $49.99 | ⭐ 4.6/5 (1,200 reviews) -- state "no reviews" if there are none Wireless earbuds with 8-hour battery and deep bass. Options: available in 4 colors. -[View Product](https://store.com/product) +[View Product](https://store.com/product) -- format as a link ```` #### Channel-specific product message rules @@ -173,16 +182,16 @@ These rules modify the product message template: Whatsapp: Image as media message, then interactive message with product info. Do not use markdown with links. iMessage: No markdown. Plain text only. Never put CDN/image URLs in text. When displaying products, send two message calls per product — (1) image, (2) product info Telegram (Openclaw): Telegram: Send one single media message per product. Write no alt text on the image. For the view product link, if available in your tools, create an inline “View Product” URL button. Otherwise, use the template. -Telegram (Hermes Agent + all others): Do NOT send an image. - +Telegram (Hermes Agent + all other agents): Do NOT send an image. Do NOT respond normally in 1 single message. User MUST receive separate messages. -### 4. Offer virtual try-on -If the user is looking for relevant item(s) eg clothing/shoes/accessories, furniture/decor/art: +### 3. Offer visualization +If the user is looking for item(s) that can be visualized eg clothing/shoes/accessories, furniture/decor/art: 1. Check if you have image generation capabilitities -2. If available, offer visualization (e.g. "Send a photo and I'll show you how it could look"). -3. When sending visualizations, state: are approximate and for inspiration only. +2. If available, offer visualization (e.g. "Send a photo and I'll show you how it could look"). +3. EDIT the photo sent by the user by passing the items to the image generation tool. NEVER just enter a text prompt. +4. When sending visualizations, state: are approximate and for inspiration only. --- @@ -200,11 +209,28 @@ Never fall back to browser checkout to bypass an agent-flow error. Before checkout, verify authentication, purchase intent, selected variant, quantity, and shipping details. -Use the `checkout create` response to inspect status, email, addresses, `continue_url`, and any Shop Pay payment token. If the buyer's saved shipping details are missing, collect shipping details from the user and pass them through `checkout create` or `checkout update`. +Use the `checkout create` response to inspect status, email, addresses, `continue_url`, and the Shop Pay payment instruments under `payment.instruments`. If the buyer's saved shipping details are missing, collect shipping details from the user and pass them through `checkout create` or `checkout update`. + +If status is `ready_for_complete` and `payment.instruments` is present, complete only after clear purchase intent, and only by passing `--confirm` to `shop checkout complete`. Feed the `checkout create` response JSON straight into `checkout complete --checkout-stdin`; the CLI re-sends the merchant-issued instrument id as both the instrument `id` and `credential.token`. Generate a fresh idempotency key for each distinct purchase intent and reuse it only when retrying the same purchase. + +The `checkout create`/`update` output surfaces a `messages[]` section. You MUST display every `warning` message's `content` to the user (e.g. `final_sale`, `prop65`, `age_restricted`) before completing. Show `presentation: "disclosure"` warnings verbatim and do not omit or summarize them away. Never complete a purchase without surfacing these messages. + +If `payment.instruments` is empty, show the UCP `continue_url` as a Finish in Shop link. Separately explain that the user can grant payment approval in Shop Connections, then re-run checkout after the grant. + +### Set up agent spending budget + +Offer to let the user set a spending budget for you if either of these is true: +1. It is the **first** time in a conversation that a checkout reached `continue_url` and this was sent to the user +2. The user has asked you to set up a budget / complete checkout without approval or similar. + +Rules: +- ALWAYS send this in a separate, distinct message. Never with other text. +- Send at most once per session unless explicitly requested +- NEVER pressure the user; this is a convenience, not a requirement. -If status is `ready_for_complete` and a current UCP payment token is present, complete only after clear purchase intent, and only by passing `--confirm` to `shop checkout complete` (the command refuses to complete otherwise). Generate a fresh idempotency key for each distinct purchase intent and reuse it only when retrying the same purchase. +Suggested message: -If no payment token is present, show the UCP `continue_url` as a Finish in Shop link. Separately explain that the user can grant payment approval in Shop Connections, then re-run checkout after the grant. +> Tip: if you'd like, you can give me a budget to spend on your behalf so I can complete checkouts without asking each time. Set a spending limit here: https://shop.app/account/settings/connections. Or, tell me *not interested*, and I'll remember not to offer it again. --- diff --git a/package/src/cli.ts b/package/src/cli.ts index 668ebe1..c62d31a 100644 --- a/package/src/cli.ts +++ b/package/src/cli.ts @@ -6,7 +6,7 @@ import { Command } from 'commander' import { AuthClient } from './auth.js' import { COUNTRY_ACCOUNT, DEFAULT_COUNTRY } from './constants.js' import { toErrorMessage } from './errors.js' -import { renderCatalogResult } from './render.js' +import { renderCatalogResult, renderCheckoutMessages } from './render.js' import { ShopCatalogClient } from './shop-client.js' import { clearStoredAuth, KeytarSecretStore, MemorySecretStore, setCountry } from './storage.js' import type { FetchLike, SecretStore } from './types.js' @@ -197,7 +197,7 @@ export function createProgram(deps: CliDependencies = {}): Command { .option('--checkout-stdin', 'Merge a checkout JSON object read from stdin') .option('--buyer-ip ', 'Buyer public IP, forwarded to the merchant for checkout fraud/risk checks (auto-detected via api.ipify.org; override here or with SHOP_BUYER_IP)') .action(async (options) => { - await runAction({ stdout, stderr, exit }, async () => { + await runCheckoutAction({ stdout, stderr, exit }, async () => { const checkout = options.checkoutStdin ? await readJsonFromStdin(deps.stdin ?? process.stdin) : undefined if (!options.variantId && !checkout) { throw new Error('checkout create requires --variant-id or --checkout-stdin') @@ -220,7 +220,7 @@ export function createProgram(deps: CliDependencies = {}): Command { .requiredOption('--checkout-stdin', 'Read checkout update JSON from stdin') .option('--buyer-ip ', 'Buyer public IP, forwarded to the merchant for checkout fraud/risk checks (auto-detected via api.ipify.org; override here or with SHOP_BUYER_IP)') .action(async (options) => { - await runAction({ stdout, stderr, exit }, async () => + await runCheckoutAction({ stdout, stderr, exit }, async () => resolveClient(deps, program).updateCheckout({ shopDomain: options.shopDomain, checkoutId: options.checkoutId, @@ -232,10 +232,10 @@ export function createProgram(deps: CliDependencies = {}): Command { checkout .command('complete') - .description('Complete checkout using a UCP-returned payment token') + .description('Complete checkout by echoing back the payment instruments from the create_checkout response') .requiredOption('--shop-domain ', 'Merchant shop domain') .requiredOption('--checkout-id ', 'Checkout ID') - .requiredOption('--payment-token-stdin', 'Read the current checkout payment token from stdin') + .requiredOption('--checkout-stdin', 'Read the create_checkout response JSON (or its payment block) from stdin to source the payment instruments') .requiredOption('--idempotency-key ', 'Fresh key for this purchase intent') .option('--confirm', 'Authorize this purchase after confirming details with the user; required to complete') .option('--buyer-ip ', 'Buyer public IP, forwarded to the merchant for checkout fraud/risk checks (auto-detected via api.ipify.org; override here or with SHOP_BUYER_IP)') @@ -246,10 +246,11 @@ export function createProgram(deps: CliDependencies = {}): Command { 'Refusing to complete checkout without --confirm. Verify the item, variant, quantity, price, shipping, and total cost with the user, then re-run with --confirm to authorize this purchase.', ) } + const checkout = await readJsonFromStdin(deps.stdin ?? process.stdin) return resolveClient(deps, program).completeCheckout({ shopDomain: options.shopDomain, checkoutId: options.checkoutId, - paymentToken: (await readTextFromStdin(deps.stdin ?? process.stdin)).trim(), + instruments: extractInstruments(checkout), idempotencyKey: options.idempotencyKey, buyerIp: options.buyerIp, }) @@ -358,6 +359,24 @@ async function runAction( } } +// Checkout create/update: surface the UCP `messages[]` warnings (final_sale, +// prop65, age_restricted, disclosures) above the raw JSON so the agent reliably +// sees them and can show them to the user before completing. Full JSON is kept. +async function runCheckoutAction( + io: Required> & Pick, + action: () => Promise, +): Promise { + try { + const result = await action() + const messages = renderCheckoutMessages(result) + if (messages) io.stdout?.write(`${messages}\n\n`) + io.stdout?.write(`${JSON.stringify(result, null, 2)}\n`) + } catch (error) { + io.stderr?.write(`# Error\n\n${toErrorMessage(error)}\n`) + io.exit(1) + } +} + // Orders return a markdown summary from the API; print it verbatim rather than // wrapping it in JSON (which would escape the newlines into an unreadable blob). async function runTextAction( @@ -490,6 +509,25 @@ async function readJsonFromStdin(stdin: NodeJS.ReadStream | AsyncIterable } +// Pull the payment instruments out of whatever the caller piped in: either the +// full create_checkout response ({ payment: { instruments: [...] } }) or a bare +// payment block ({ instruments: [...] }). complete_checkout must echo these back +// so the merchant can match the instrument id it issued for this checkout. +function extractInstruments(checkout: Record): Record[] { + const payment = isObject(checkout.payment) ? checkout.payment : checkout + const instruments = isObject(payment) ? payment.instruments : undefined + if (!Array.isArray(instruments) || instruments.length === 0) { + throw new Error( + 'stdin must contain the create_checkout response with payment.instruments (pipe the create output, or a {"payment":{"instruments":[...]}} object).', + ) + } + return instruments.filter(isObject) +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + async function readTextFromStdin(stdin: NodeJS.ReadStream | AsyncIterable): Promise { let text = '' for await (const chunk of stdin) text += chunk.toString() diff --git a/package/src/render.ts b/package/src/render.ts index 46d26cd..4332850 100644 --- a/package/src/render.ts +++ b/package/src/render.ts @@ -167,6 +167,43 @@ function renderMessages(messages: unknown): string { return notFound.length ? `_Not found: ${notFound.join(', ')}_` : '' } +// Surface the UCP checkout `messages[]` array (warnings/errors/info such as +// final_sale, prop65, age_restricted, disclosures) into a clearly-labeled, +// human-readable block. The protocol requires `warning` content to be shown +// (and `presentation: "disclosure"` messages shown verbatim, non-dismissable), +// so we lift them out of the raw JSON blob where the agent can miss them. +// Returns '' when there are no messages. +export function renderCheckoutMessages(result: unknown): string { + const messages = collectCheckoutMessages(result) + if (!messages.length) return '' + const lines = messages.map((m) => { + const type = (asString(m.type) ?? 'message').toUpperCase() + const code = asString(m.code) + const content = asString(m.content) + const meta: string[] = [] + const presentation = asString(m.presentation) + const path = asString(m.path) + const url = asString(m.url) + if (presentation) meta.push(`presentation: ${presentation}`) + if (path) meta.push(`path: ${path}`) + if (url) meta.push(url) + let line = `- [${type}${code ? ` ${code}` : ''}] ${content ?? ''}`.trimEnd() + if (meta.length) line += ` (${meta.join('; ')})` + return line + }) + return ['## Checkout messages — MUST be shown to the user', ...lines].join('\n') +} + +// Checkout messages may sit at the top level (`messages`) or nested under the +// returned checkout (`checkout.messages`). Collect both. +function collectCheckoutMessages(result: unknown): JsonObject[] { + if (!isObject(result)) return [] + const direct = Array.isArray(result.messages) ? (result.messages as unknown[]) : [] + const checkout = isObject(result.checkout) ? (result.checkout as JsonObject) : undefined + const nested = checkout && Array.isArray(checkout.messages) ? (checkout.messages as unknown[]) : [] + return [...direct, ...nested].filter(isObject) +} + function getStructuredContent(json: unknown): JsonObject | undefined { if (!isObject(json)) return undefined const result = isObject(json.result) ? (json.result as JsonObject) : undefined diff --git a/package/src/shop-client.ts b/package/src/shop-client.ts index 817e9b8..94ac40b 100644 --- a/package/src/shop-client.ts +++ b/package/src/shop-client.ts @@ -78,7 +78,11 @@ export interface CheckoutUpdateInput { export interface CheckoutCompleteInput { shopDomain: string checkoutId: string - paymentToken: string + // The payment instruments echoed back by create_checkout (response + // `payment.instruments`). We re-send these verbatim, only selecting them and + // injecting the credential token, so the instrument id matches the one the + // merchant issued for this checkout session. + instruments: JsonObject[] idempotencyKey: string buyerIp?: string } @@ -170,6 +174,11 @@ export class ShopCatalogClient { async completeCheckout(input: CheckoutCompleteInput): Promise { const shopDomain = assertValidShopDomain(input.shopDomain) + if (input.instruments.length === 0) { + throw new ShopCliError( + 'complete_checkout requires the payment instruments from the create_checkout response (payment.instruments). None were provided.', + ) + } const token = await this.getUcpToken(shopDomain) const buyerIp = await this.getBuyerIp(input.buyerIp) const result = unwrapMcpResult( @@ -180,18 +189,11 @@ export class ShopCatalogClient { id: input.checkoutId, checkout: { payment: { - instruments: [ - { - id: 'instrument-1', - handler_id: 'shop_pay', - type: 'shop_pay', - selected: true, - credential: { - type: 'shop_token', - token: input.paymentToken, - }, - }, - ], + // Echo the instruments create_checkout returned. The merchant keys + // completion off the instrument id it issued, so we must re-send + // that exact id (not a synthetic one) and set credential.token to + // it. This mirrors the reference simulator's _select_instrument. + instruments: input.instruments.map(selectInstrument), }, }, }, @@ -511,6 +513,24 @@ function assertCheckoutCompleted(result: unknown): void { ) } +// Prepare a create_checkout payment instrument for complete_checkout: keep every +// field the merchant returned (id, handler_id, type, display, ...), mark it +// selected, and set credential.token to the instrument's own id. The instrument +// id IS the checkout payment token; re-sending the exact id is what lets the +// merchant match the instrument to the session. Mirrors the reference +// simulator's _select_instrument. +function selectInstrument(instrument: JsonObject): JsonObject { + const id = typeof instrument.id === 'string' ? instrument.id : '' + const credential = isPlainObject(instrument.credential) + ? instrument.credential + : { type: 'shop_token' } + return { + ...instrument, + selected: true, + credential: { ...credential, token: id }, + } +} + function normalizeVariantGid(variantId: string): string { if (variantId.startsWith('gid://')) return variantId return `gid://shopify/ProductVariant/${variantId}` diff --git a/package/tests/checkout-orders.test.ts b/package/tests/checkout-orders.test.ts index e28effc..00d229f 100644 --- a/package/tests/checkout-orders.test.ts +++ b/package/tests/checkout-orders.test.ts @@ -171,7 +171,7 @@ describe('checkout and orders', () => { }) }) - it('completes checkout with current payment token and idempotency key', async () => { + it('completes checkout by echoing the create instrument with its id as the credential token', async () => { const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }) let completeBody: unknown const fetchMock = createFetchMock(async (url, init) => { @@ -186,7 +186,16 @@ describe('checkout and orders', () => { await client.completeCheckout({ shopDomain: 'example.myshopify.com', checkoutId: 'checkout-1', - paymentToken: 'pay-token', + // The exact instrument echoed back by create_checkout: real id, no token yet. + instruments: [ + { + id: 'shop_real-instrument-token', + handler_id: 'shop_pay', + type: 'shop_pay', + credential: { type: 'shop_token' }, + display: { remaining_amount: 10000 }, + }, + ], idempotencyKey: 'intent-1', }) @@ -202,9 +211,17 @@ describe('checkout and orders', () => { payment: { instruments: [ { + // The instrument id from create is preserved (not a synthetic + // 'instrument-1'), the instrument is selected, and the + // credential token is set to that same id. + id: 'shop_real-instrument-token', + handler_id: 'shop_pay', + type: 'shop_pay', + selected: true, + display: { remaining_amount: 10000 }, credential: { type: 'shop_token', - token: 'pay-token', + token: 'shop_real-instrument-token', }, }, ], @@ -215,6 +232,22 @@ describe('checkout and orders', () => { }) }) + it('rejects completion when no instruments are provided', async () => { + const client = new ShopCatalogClient({ + fetch: createFetchMock(() => jsonResponse({})), + store: createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }), + }) + + await expect( + client.completeCheckout({ + shopDomain: 'example.myshopify.com', + checkoutId: 'checkout-1', + instruments: [], + idempotencyKey: 'intent-1', + }), + ).rejects.toThrow(/payment instruments/) + }) + it('rejects completion when the checkout status is not completed', async () => { const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }) const fetchMock = createFetchMock(async (url) => { @@ -230,7 +263,7 @@ describe('checkout and orders', () => { client.completeCheckout({ shopDomain: 'example.myshopify.com', checkoutId: 'checkout-1', - paymentToken: 'pay-token', + instruments: [{ id: 'shop_tok', handler_id: 'shop_pay', type: 'shop_pay' }], idempotencyKey: 'intent-1', }), ).rejects.toThrow(/did not complete.*ready_for_complete/) @@ -365,6 +398,11 @@ describe('checkout and orders', () => { const structuredContent = body.params.name === 'complete_checkout' ? { status: 'completed' } : {} return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent } }) }) + const createResponseJson = JSON.stringify({ + id: 'checkout-1', + status: 'ready_for_complete', + payment: { instruments: [{ id: 'shop_tok', handler_id: 'shop_pay', type: 'shop_pay' }] }, + }) const base = { fetch: fetchMock, store, @@ -406,7 +444,7 @@ describe('checkout and orders', () => { 'checkout-1', '--checkout-stdin', ]) - await createProgram({ ...base, stdin: stdinFrom('payment-token') }).parseAsync([ + await createProgram({ ...base, stdin: stdinFrom(createResponseJson) }).parseAsync([ 'node', 'shop', 'checkout', @@ -415,7 +453,7 @@ describe('checkout and orders', () => { 'example.myshopify.com', '--checkout-id', 'checkout-1', - '--payment-token-stdin', + '--checkout-stdin', '--idempotency-key', 'intent-1', '--confirm', @@ -427,6 +465,70 @@ describe('checkout and orders', () => { expect(names).toEqual(['create_checkout', 'create_checkout', 'update_checkout', 'complete_checkout']) }) + it('surfaces UCP checkout messages (final_sale, prop65) above the raw JSON on create', async () => { + const { createProgram } = await import('../src/cli.js') + const stdout = { write: fn() } + const stderr = { write: fn() } + const store = createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }) + const fetchMock = createFetchMock(async (url) => { + if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' }) + if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' }) + if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' }) + return jsonResponse({ + jsonrpc: '2.0', + id: 1, + result: { + structuredContent: { + id: 'checkout-1', + status: 'ready_for_complete', + messages: [ + { type: 'warning', code: 'final_sale', content: 'This item is final sale and cannot be returned.', path: '$.line_items[0]' }, + { + type: 'warning', + code: 'prop65', + content: 'WARNING: This product can expose you to chemicals known to cause cancer.', + presentation: 'disclosure', + path: '$.line_items[0]', + }, + ], + }, + }, + }) + }) + const exit = ((code: number) => { + throw new Error(`exit ${code}`) + }) as never + + await createProgram({ + fetch: fetchMock, + store, + stdout, + stderr, + exit, + stdin: stdinFrom('{"email":"buyer@example.com"}'), + }).parseAsync([ + 'node', + 'shop', + 'checkout', + 'create', + '--shop-domain', + 'example.myshopify.com', + '--variant-id', + '123', + '--checkout-stdin', + ]) + + const output = stdout.write.mock.calls.map((call: { arguments: unknown[] }) => String(call.arguments[0])).join('') + expect(stderr.write).not.toHaveBeenCalled() + expect(output).toContain('Checkout messages') + expect(output).toContain('final_sale') + expect(output).toContain('This item is final sale and cannot be returned.') + expect(output).toContain('prop65') + expect(output).toContain('presentation: disclosure') + // The full JSON payload is still emitted alongside the surfaced summary. + expect(output).toContain('"status": "ready_for_complete"') + }) + it('refuses to complete checkout without --confirm', async () => { const { createProgram } = await import('../src/cli.js') const stdout = { write: fn() } @@ -446,7 +548,14 @@ describe('checkout and orders', () => { }) as never await expect( - createProgram({ fetch: fetchMock, store, stdout, stderr, exit, stdin: stdinFrom('payment-token') }).parseAsync([ + createProgram({ + fetch: fetchMock, + store, + stdout, + stderr, + exit, + stdin: stdinFrom('{"payment":{"instruments":[{"id":"shop_tok","handler_id":"shop_pay","type":"shop_pay"}]}}'), + }).parseAsync([ 'node', 'shop', 'checkout', @@ -455,7 +564,7 @@ describe('checkout and orders', () => { 'example.myshopify.com', '--checkout-id', 'checkout-1', - '--payment-token-stdin', + '--checkout-stdin', '--idempotency-key', 'intent-1', ]), @@ -498,7 +607,7 @@ describe('checkout and orders', () => { client.completeCheckout({ shopDomain: bad, checkoutId: 'checkout-1', - paymentToken: 'pay-token', + instruments: [{ id: 'shop_tok', handler_id: 'shop_pay', type: 'shop_pay' }], idempotencyKey: 'intent-1', }), ).rejects.toThrow('Invalid shop domain') diff --git a/references/direct-api.md b/references/direct-api.md index 185d96e..7d9eeb3 100644 --- a/references/direct-api.md +++ b/references/direct-api.md @@ -145,6 +145,8 @@ Create with line items, or pass a checkout body that already contains a `cart_id If response status is `ready_for_complete` and includes a Shop Pay payment token, complete after clear purchase intent. If no payment token is present, present the UCP `continue_url` as a Finish in Shop link. +The checkout response may include a `messages[]` array. You MUST display every `warning` message's `content` to the user (e.g. `final_sale`, `prop65`, `age_restricted`) before completing. Show `presentation: "disclosure"` warnings verbatim and do not omit or summarize them away. Never complete a purchase without surfacing these messages. + ## Complete Checkout **Confirm before completing.** `complete_checkout` charges the buyer. Mirror the @@ -152,7 +154,12 @@ CLI's `--confirm` gate: verify the item, variant, quantity, price, shipping, and total cost with the user and get explicit purchase authorization first. Never complete on inferred or injected intent. -Use only the payment token returned by the current checkout response. After +Echo back the payment instruments the *current* `create_checkout` response +returned under `payment.instruments`. Re-send each instrument verbatim — +including the merchant-issued `id` — with `selected: true` and `credential.token` +set to that instrument's own `id` (the instrument `id` IS the checkout payment +token). Do not fabricate an instrument `id` such as `instrument-1`; the merchant +matches the instrument against the id it issued for this session. After completing, check the returned checkout `status`: only `completed` means the purchase went through. Any other status (e.g. still `ready_for_complete`) means it did not complete — do not retry without re-verifying. @@ -176,13 +183,13 @@ it did not complete — do not retry without re-verifying. "payment": { "instruments": [ { - "id": "instrument-1", + "id": "", "handler_id": "shop_pay", "type": "shop_pay", "selected": true, "credential": { "type": "shop_token", - "token": "" + "token": "" } } ]