@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.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.
- package/README.md +95 -215
- package/dist/argv.js +214 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +123 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +211 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +173 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +78 -0
- package/dist/http.js +117 -0
- package/dist/login.js +133 -0
- package/dist/resources.js +41 -0
- package/dist/schema.js +152 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +189 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/commands.js
DELETED
|
@@ -1,1241 +0,0 @@
|
|
|
1
|
-
// One function per command. Each returns the object the CLI prints, or throws a
|
|
2
|
-
// CliError. Every command is safe to re-run.
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { createApi } from './api/client.js';
|
|
6
|
-
import { ignoreCredentials, paths, readCredentials, requireCredentials, writeAgentNotes, writeCredentials } from './files.js';
|
|
7
|
-
import { CliError, fail, progress } from './output.js';
|
|
8
|
-
import { diff, hashStore, languageWarnings, loadStore, storefrontMode, storefrontPath, template, toApplied } from './store.js';
|
|
9
|
-
import { storefrontFiles } from './storefront.js';
|
|
10
|
-
import { imageNotes, planAssets, uploadAssets } from './assets.js';
|
|
11
|
-
import { pull } from './catalog.js';
|
|
12
|
-
import { inspect } from './inspect.js';
|
|
13
|
-
import * as ops from './ops.js';
|
|
14
|
-
import { checkOrderRequest, lineFlag, sourceFlag } from './orderRequest.js';
|
|
15
|
-
import { readInboxCursor, seenKey, writeInboxCursor } from './inbox/cursor.js';
|
|
16
|
-
import { openMailbox } from './inbox/mailbox.js';
|
|
17
|
-
import { parseMessage, writeMessage } from './inbox/message.js';
|
|
18
|
-
import { checkLink } from './links.js';
|
|
19
|
-
import { ACTIVE_LIMIT, activeWithoutCoupon, checkPromotion } from './promotions.js';
|
|
20
|
-
import YAML from 'yaml';
|
|
21
|
-
import { storefrontCheck } from './check.js';
|
|
22
|
-
import { buy } from './ucp/buy.js';
|
|
23
|
-
import { check } from './ucp/check.js';
|
|
24
|
-
import { discover } from './ucp/client.js';
|
|
25
|
-
import { MAX_IMAGES, withImages } from './ucp/images.js';
|
|
26
|
-
import * as shop from './ucp/shop.js';
|
|
27
|
-
import { agentProfile, AGENT_PROFILE_URL } from './ucp/platform.js';
|
|
28
|
-
import { LATEST_HINT, VERSION } from './version.js';
|
|
29
|
-
export { VERSION } from './version.js';
|
|
30
|
-
export const HELP = {
|
|
31
|
-
init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
|
|
32
|
-
plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, language, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku). Products may carry other languages: name, description and category as { <ll>: text } maps (the base language, store.yml language:, default en, among them), or name_<ll>, description_<ll>, category_<ll> CSV columns; at most 6 languages. warnings lists a language no country of the store speaks.',
|
|
33
|
-
apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the storefront mode (storefront.mode: hosted, the default, or agent-only: UCP and the pages it needs, no catalog pages), the theme (its settings, badge included, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
|
|
34
|
-
'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant | website, url, table, limit }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one price column per market currency, in lowercase (eur, usd). A Merchant feed adds other languages with catalog.translations: { it: <feed url> }, merged by id. Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV. source website reads a brand\'s site for a demo store (url: the site, limit: default 100, at most 500): Shopify products.json, then the WooCommerce Store API, then the sitemap\'s product pages and their JSON-LD, first that yields products wins (strategy). One row per product, first available variant (variants_skipped counts the others); prices in the site\'s currency, never converted: currency_mismatch when the markets sell in another, currency_unknown when the site does not say (ask the human, then --currency <code>). It honours robots.txt (robots_skipped), reads one request at a time, at most 2 a second, 60 s in all (budget_exhausted). Images stay on the brand\'s servers: image_url links them, nothing is copied. Prints strategy, source_site { url, title, platform, product_count_seen, currency, languages }, pulled, categories and the skipped counts: tell the human how many products the site has and how many were pulled. no_products_found: ask the human for a feed or a sheet instead.',
|
|
35
|
-
'storefront init': 'Custom storefronts only: the hosted theme needs none of this (set theme: in store.yml and apply). Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js. Product images are the store\'s, at /img/ (.commercelayer/img/, uploaded by apply); the site\'s own files go in storefront/assets/. Refuses to overwrite unless --force is given.',
|
|
36
|
-
'storefront check': 'Custom storefronts only. Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
|
|
37
|
-
'storefront inspect': "Read a brand's website for a demo store that looks like it, read-only, nothing written: c11r storefront inspect <url>. Returns { url, title, site_name, description, language, languages, theme_color, icons, og_image, og_image_width, og_image_height, og_image_is_logo (true: the og:image is the logo, not a hero photo), logo_candidates: [{ url, source: json_ld|header_img|img|header_svg, alt, width, height }], colors: [{ hex, source: theme_color|css, count, neutral }], currencies_seen, countries_seen, platform: shopify|woocommerce|unknown, robots: { products_allowed, paths } }. Pick theme.color (the first colour that is not neutral, usually) and theme.logo (a logo candidate's url: hot-linked, never downloaded; an inline SVG has url null) and show the human. robots.products_allowed false: catalog pull cannot read the products, ask for a feed or a sheet. Same polite reading as catalog pull (robots.txt, 2 requests a second). Errors: site_unreachable, site_error, robots_disallowed.",
|
|
38
|
-
'storefront publish': 'Custom storefronts only: upload the storefront folder (store.yml storefront.path, default storefront) to the preview URL, where it replaces the hosted theme.',
|
|
39
|
-
'promotions list': 'The store\'s promotions, live in Commerce Layer (not in store files): id, name, status (active, scheduled, expired, disabled, used_up), rules, actions, uses, starts, ends; managed: false marks one made in the dashboard (its id is Commerce Layer\'s), which can be enabled, disabled or deleted but not updated. warnings flags more than 10 active promotion actions without a coupon (Commerce Layer applies 10).',
|
|
40
|
-
'promotions show': 'One promotion by id, as list shows it: c11r promotions show <id>.',
|
|
41
|
-
'promotions create': "Create a promotion from a spec in YAML or JSON, on stdin (c11r promotions create <<'EOF' ... EOF) or with -f <file>: { id, name, market, starts, ends, limit, priority, exclusive, enabled, rules: [{ coupon | order_amount: { min } | skus | category }], actions: [{ percentage | fixed_amount | free_shipping | fixed_price | free_gift | buy_x_pay_y }] } or flex: { rules } in place of rules and actions. id is the handle, unique. Amounts are in the market currency, in major units. A category becomes the skus of its products in store.yml now: after adding products to it, run update again. Errors: invalid_promotion (the message starts with the path in the spec), promotion_exists, flex_unavailable (no Rules Engine: nothing is created). Check it with orders test --code.",
|
|
42
|
-
'promotions update': 'Replace a promotion with a whole spec, same as create, on stdin or -f <file>: c11r promotions update <id>. The id stays the same. What show prints can be edited and sent back. A promotion made in the dashboard (managed: false) cannot be updated: promotion_not_managed; enable, disable and delete work on it.',
|
|
43
|
-
'promotions enable': 'Turn a promotion on: c11r promotions enable <id>.',
|
|
44
|
-
'promotions disable': 'Turn a promotion off without deleting it: c11r promotions disable <id>.',
|
|
45
|
-
'promotions delete': 'Delete a promotion from Commerce Layer: c11r promotions delete <id>. Ask the human first; to pause one, disable it.',
|
|
46
|
-
'links list': 'The store\'s links, live in Commerce Layer (not in store files): shareable URLs that open a hosted microstore for one sku, a set of skus or a category, or the hosted checkout for an order. For each: id, name, url, status (pending, active, expired, disabled), for (sku | skus | category with its skus | order), markets, starts, ends, params; managed: false marks one made in the dashboard (its id is Commerce Layer\'s).',
|
|
47
|
-
'links show': 'One link by id, as list shows it: c11r links show <id>.',
|
|
48
|
-
'links create': "Create a link from a spec in YAML or JSON, on stdin (c11r links create <<'EOF' ... EOF) or with -f <file>: { id, name, for: { sku } | { skus: [...] } | { category: \"A > B\" } | { order: <number> | cart_<id> | chk_<id> }, market (a code or a list; default every market; none for an order), starts (default now), ends (default a month after starts), params }. id is the handle, unique. A category becomes the skus of its products in store.yml now (at most 100 skus). An order link opens the checkout of a draft or pending order: its number, or the id of a UCP cart or checkout. One Commerce Layer link per market: urls has each, url the first market's. A link cannot be changed: delete it and create it again. Errors: invalid_link (the message starts with the path in the spec), link_exists.",
|
|
49
|
-
'links enable': 'Turn a link on: c11r links enable <id>.',
|
|
50
|
-
'links disable': 'Turn a link off without deleting it: its url stops opening. c11r links disable <id>.',
|
|
51
|
-
'links delete': 'Delete a link from Commerce Layer: c11r links delete <id>. Ask the human first; to pause one, disable it.',
|
|
52
|
-
'orders test': 'Buy from the store over UCP as a shopping agent would, paying with the sandbox handler: discover, search, checkout, address, pay, read the order. Options: --sku <id> --quantity <n> --country <CC> --code <discount> --via-cart --decline. The order is placed, payment authorized; approve and capture it with `c11r orders approve <id> --capture`. discounts lists what promotions and codes took off; a code that applied nothing is a discount_code_invalid warning in messages.',
|
|
53
|
-
'orders list': "The store's orders, live in Commerce Layer, newest first: by default the placed and approved ones, not archived, 25 at most. c11r orders list [--status pending|placed|approved|cancelled|editing|all] [--channel message|link] [--payment authorized|paid|refunded|partially_refunded|voided|...] [--fulfillment unfulfilled|in_progress|fulfilled|not_required] [--archived [all]] [--limit 1-100]; a comma list takes several (--status placed,cancelled). Returns { count, orders: [{ id, number, status, payment_status, fulfillment_status, placed_at, market, country, currency, total: { amount, currency, major, display }, email, items, shipments: [{ id, number, status, tracking }], archived }], next }. Orders arrive placed with the payment authorized, not taken: they wait for the human to accept them. pending orders were made from emails or messages (orders create) and are not placed yet: placed_at is null, created_at says when; --status pending lists them (all is every status but pending: --status all,pending for both), --channel message every order from a message, --channel link those from checkout links (agent and web: insights --by channel). A pending order cancelled stays archived with them: --status pending --archived.",
|
|
54
|
-
'orders show': 'One order as the merchant sees it: c11r orders show <number, #number or id>. Returns { order: { ...as in orders list, line_items: [{ sku, name, quantity, unit_price, total }], totals: { subtotal, discount, shipping, taxes, total, codes }, shipping_address, billing_address, shipping_method, payment: { method, source, status, captured, refundable }, transactions: [{ type, amount, succeeded, at }], language, archived_at, ucp: { id, permalink_url } }, next }. next says what the order is waiting for.',
|
|
55
|
-
'orders create': "Make an order from an email or a message the human received (an order by email, a chat, a PDF purchase order the agent read): c11r orders create -f <file> (or the request on stdin) --source email:<Message-ID> [--place | --hold] [--force], or with flags: --email --first-name --last-name [--company] --phone --item <sku>:<qty> (repeat it) --query \"<words>:<qty>\" (repeat it) --street [--line-2] --city --postal-code [--region] --country [--reference <PO number>] [--notes <text>] [--language <ll>] --source <kind>:<id>. source is required: email:<Message-ID> for an email, message:<id> for anything else (a chat or form message id); it keeps a message from making two orders. The request: { source: { kind: email|message, id, from, subject, received_at }, customer: { email, first_name, last_name, company, phone }, ship_to: { line_1, line_2, city, postal_code, region, country, phone }, bill_to, items: [{ sku, quantity } | { query, quantity }], notes, reference, language }. Everything comes from the message or the human, never invented: a missing phone, postal code or country is invalid_argument with path naming it (ask the human). quantity is 1 to 10,000; two lines of the same sku become one. Prices are always the catalog's: never put the email's prices in; tell the human when they differ. A query line is resolved by the store's search; a line it cannot match surely leaves status needs_review with nothing created (exit 0): resolution lists each line { requested, sku, name, confidence, candidates: [{ sku, name, price }], status: matched|ambiguous|not_found|unavailable }; ask the human which product, then run it again with --item <sku>:<qty>, or with --force to leave those lines out (line_dropped in warnings). The order is created pending (not placed) for the human's yes, then c11r orders place <number>; mail_orders: { auto_place: true } in .commercelayer/ops.yml (or --place) places it at once, --hold keeps it pending whatever the policy says. The same --source again returns the order it made (duplicate_source in warnings), never a second one. Other errors: country_not_served (with the countries the store ships to), too_many_items (25 lines at most), store_unavailable (the store's search failed: nothing created, try again). Warnings: duplicate_source, line_dropped, no_shipping_method. Returns { status: pending|placed|needs_review, order, resolution, warnings, next }.",
|
|
56
|
-
'orders place': "Place a pending order made by orders create (channel message), once the human said yes to its lines and total: c11r orders place <number>. A shopper's own checkout in progress is theirs to finish: invalid_transition. It is paid by wire transfer: placed with the payment authorized, then approved and captured like any other order. Returns { status, order, next }.",
|
|
57
|
-
'orders approve': "Accept a placed order, only once the human said to: c11r orders approve <number> [--capture]. With --capture the authorized payment is taken too; without, take it later with orders capture. Returns { status, order, next } with the order after it. An order that cannot be approved answers invalid_transition: its hint lists what it allows now.",
|
|
58
|
-
'orders capture': 'Take the authorized payment of an order, once the human said to: c11r orders capture <number>. Usually after approving it (or at once with orders approve --capture); a placed order can be captured too. Returns { status, order, next }; invalid_transition when nothing authorized is left to capture.',
|
|
59
|
-
'orders cancel': "Cancel an order the human does not want, before it ships: c11r orders cancel <number>. An authorized payment is voided, a captured one refunded in full. A shipped order cannot be cancelled (refund it with orders refund): invalid_transition, with what is allowed in the hint. Returns { status, order, next }. A pending order (from orders create) is dropped: it reads cancelled and archived, and lists with --status pending --archived.",
|
|
60
|
-
'orders refund': "Give money back on a captured order, only the amount the human said: c11r orders refund <number> [--amount <n>]. --amount is in major units (4.50); without it, everything still refundable. Refunding the last of the payment cancels the order. Returns { status, order, next }; refund_exceeds when the amount is more than payment.refundable.",
|
|
61
|
-
'orders archive': 'Hide an order from orders list (any status), for orders that need nothing more: c11r orders archive <number>. Idempotent. orders list --archived shows them.',
|
|
62
|
-
'orders unarchive': 'Bring an archived order back into orders list: c11r orders unarchive <number>. Idempotent.',
|
|
63
|
-
'fulfillment list': "The store's shipments, live in Commerce Layer, newest first: by default the ones not shipped yet (upcoming, on_hold, picking, packing, ready_to_ship). c11r fulfillment list [--status upcoming|on_hold|picking|packing|ready_to_ship|shipped|delivered|cancelled|all] [--limit 1-100]. Returns { count, shipments: [{ id, number (1001/S/001), status, order: { id, number, status }, country, shipping_method, items, tracking: { carrier, number, url } | null, dates }], next }.",
|
|
64
|
-
'fulfillment show': 'One shipment, with what goes in it and where: c11r fulfillment show <shipment number or id, or the order number when it has one shipment>. Returns { shipment: { ...as in fulfillment list, line_items: [{ sku, name, quantity }], address, parcels }, next }.',
|
|
65
|
-
'fulfillment hold': 'Put a shipment on hold, for an order the human wants to wait on: c11r fulfillment hold <shipment>. Works on a placed order too. Returns { status, shipment, next }.',
|
|
66
|
-
'fulfillment pick': 'Mark a shipment as being picked: c11r fulfillment pick <shipment>. Needs the order approved. on_hold, picking, packing and ready_to_ship move freely between each other. Returns { status, shipment, next }; invalid_transition says what is allowed now.',
|
|
67
|
-
'fulfillment pack': 'Mark a shipment as being packed: c11r fulfillment pack <shipment>. Needs the order approved. Returns { status, shipment, next }.',
|
|
68
|
-
'fulfillment ready': 'Mark a shipment ready to ship: c11r fulfillment ready <shipment>. Needs the order approved. Returns { status, shipment, next }.',
|
|
69
|
-
'fulfillment ship': "Mark a shipment shipped, with the carrier's tracking when the human has it: c11r fulfillment ship <shipment> [--carrier <name>] [--tracking <number>] [--tracking-url <https url>]. Needs the order approved and the shipment picked first (c11r fulfillment pick <shipment>): an upcoming shipment cannot ship. Ask the human for the tracking, never invent it. Returns { status, shipment, next }.",
|
|
70
|
-
'fulfillment deliver': 'Mark a shipped shipment delivered: c11r fulfillment deliver <shipment>. Returns { status, shipment, next }.',
|
|
71
|
-
'ops check': "Run the store on a schedule, step 1: what is new and what the policy would do, without changing anything. c11r ops check [--since 24h|7d|<ISO>]. Returns { checked_at, since, policy, new_orders, actions, held, waiting, awaiting_payment, stuck, counts, truncated, next }: new_orders are placed orders not reported before (each check remembers what it reported, so the next one does not repeat them; --since looks back from a time instead and remembers nothing); each decision is { order: { id, number, placed_at, country (the shipping country), channel, email, items, total, payment_status }, action: approve_capture | approve | capture | hold | wait, reason }; held are orders the policy keeps for the human (review_above, hold_countries), waiting are those it leaves to them (auto_approve or auto_capture off); awaiting_payment are orders with no payment yet; stuck are shipments picking, packing or ready to ship longer than stuck_days, or shipped longer than transit_days and not delivered, with { shipment, days, since, reason } (on hold is the human's choice, never stuck); counts are the real totals, and truncated says a list was cut. Ages are by the store's clock (checked_at). The policy is .commercelayer/ops.yml, agreed with the human and committed with the store, never sent to the API (c11r plan checks it): auto_approve and auto_capture (true or false, default false: set them only when the human says so), review_above (hold orders above it, in major units of the order's currency), hold_countries (hold orders shipping there, ISO codes), stuck_days (2), transit_days (7), and for orders from emails and messages mail_orders: { auto_place (false: they stay pending for the human), labels: { pending, ordered, declined } (the email labels, those names by default) }. Without the file nothing is approved: every order waits for the human. Pass next on: it says what ops run would do and what to tell the human.",
|
|
72
|
-
'ops run': "Run the store on a schedule, step 2: apply the policy in .commercelayer/ops.yml (see ops check). c11r ops run [--dry-run]. Approves, and captures when auto_capture is on, every placed order the policy does not hold; captures approved orders when auto_capture is on. It never ships, cancels or refunds: those stay with the human. Returns { ran_at, policy, new_orders, done: [{ action, order }], failed, held, waiting, awaiting_payment, stuck, next }; it acts on every order, beyond the 50 a check shows; with --dry-run, would_do instead of done, and nothing changes. failed lists orders that changed meanwhile (invalid_transition): nothing else stops. Tell the human about held and stuck, with the reasons.",
|
|
73
|
-
'ops digest': "A summary for the human of how the store is doing: c11r ops digest [--since 24h|7d|<ISO>] (default 24h). Returns { since, orders: { count, approved, cancelled }, revenue: [Money per currency], refunds, held, waiting, stuck, top_products: [{ sku, name, units, revenue }], summary, next }: count is the orders placed in the span that are placed or approved now, cancelled the ones cancelled since; refunds are apart from revenue. summary is plain text written for the human: give it to them as it is.",
|
|
74
|
-
'insights': "Sales over a time span, by one dimension: c11r insights [--since 24h|7d|30d|<ISO>] [--until <ISO>] [--by product|day|market|country|channel|promotion] (default 30d, at most 366 days). Returns { since, until, by, as_of, totals: { orders, units, revenue, average_order, refunds, cancellations }, rows: [{ key, label, orders, units, revenue, discount }], top_products: [{ sku, name, orders, units, revenue }], channel: { agent, link, web }, partial, summary, next }: orders are those placed in the span that are placed or approved now; revenue includes taxes, shipping and discounts, refunds are apart, cancellations count the orders cancelled since; rows only with --by (day has every day, country the shipping country, promotion its discount); partial: only the 1,000 newest orders are counted; money is per currency, never summed across currencies. Give the human the summary; the rows have the numbers.",
|
|
75
|
-
'inbox check': 'The inbox orders arrive in, over IMAP: c11r inbox check. Returns { host, user, folder, exists, unseen, next }. The connection is in .commercelayer/credentials (gitignored): C11R_IMAP_HOST, C11R_IMAP_USER, C11R_IMAP_PASSWORD (for Gmail an app password, never the account password), optionally C11R_IMAP_PORT (993) and C11R_IMAP_FOLDER (INBOX). Missing ones answer missing_credentials with how to set them: the human writes the password in the file, never in the chat; no output ever shows it. A mail connector the agent already has does the same job: use it first. The CLI never sends email: there is no inbox reply, replies stay with the human.',
|
|
76
|
-
'inbox list': 'The messages in the inbox, newest first: c11r inbox list [--unseen] [--since 24h|7d|<ISO>] [--limit 20]. Returns { folder, count, new, messages: [{ id, message_id, from, subject, date, seen, labels, new }], next }: new marks the messages not pulled before. Nothing is marked read.',
|
|
77
|
-
'inbox pull': 'Save messages for the agent to read: c11r inbox pull <id> | --unseen (every unseen message not pulled yet, --limit 20). Each goes to .commercelayer/inbox/<id>/ (gitignored): message.json { id, message_id, from, to, subject, date, labels, text, html, attachments: [{ file, name, content_type, size }] }, body.txt, and attachments/ with each file as it came (25 MB at most; PDFs, sheets and documents are not converted: open them yourself). Nothing is marked read. Returns { count, pulled: [{ id, dir, message, body, subject, from, message_id, attachments }], next }.',
|
|
78
|
-
'inbox tag': 'Mark what was done with an email: c11r inbox tag <id> [--label <name>]... [--unlabel <name>]... [--move <folder>] [--seen], for example --label ordered --unlabel pending --seen. On Gmail labels are labels; elsewhere keyword flags when the server keeps them, else labels_unsupported: use --move <folder>. The label names are mail_orders.labels in .commercelayer/ops.yml (pending, ordered, declined by default). Returns { status: tagged, id, labels_as: gmail_label|keyword|null, labeled, unlabeled, moved_to, seen, notes }.',
|
|
79
|
-
'ucp profile': 'Show the store\'s UCP business profile (/.well-known/ucp). With --agent, this CLI\'s own platform profile instead.',
|
|
80
|
-
'ucp check': 'Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way. Exits 1 when a check fails.',
|
|
81
|
-
'ucp search': "Buy for the human, step 1: search a store's catalog over UCP. c11r ucp search [<words>] [--category <name>] [--min-price <n>] [--max-price <n>] [--country CC] [--images] [--store <host>]. Search with the words that matter, in any order (\"dark roast coffee\" or \"coffee dark\"): the store ranks products with every word first; no words lists the whole catalog (up to 50). --category narrows to a category or its subcategories (\"Coffee\" includes \"Coffee > Single origin\"), repeat it for any of several; --min-price and --max-price are in major units of the store's currency (15 means 15.00); --country is the human's country: prices, shipping and availability for it. Returns { store, query, prices: { market, market_name, currency, countries, tax_included }, filters, count, total_count, has_more, products: [{ id, title, description, url, image_url, categories: [\"Coffee > Single origin\"], partial, matched_on, variants: [{ id, sku, title, price: { amount, currency, major, display }, list_price, available, availability, shipping: { to, option, price, earliest, latest, ships_to_country } }] }], messages, signature }. list_price is the price before a sale, only when there is one; partial: true marks a product with only some of the words. Show the human the titles, categories and price.display (amount is in minor units: never show it), with shipping.price.display and the arrival dates when they ask what it costs delivered; let them pick, then c11r ucp checkout create --item <variant id>. Read messages and pass on what they say: with no results or a partial match they list the store's categories, and prices says which market the prices are for. --store is any store that speaks UCP (hostname or https origin); the default is this folder's store. --images is for agents that can read image files and want to look at or describe a product: it saves each product's image (the first 24) and adds image_file, the file's absolute path, or image_error when the download failed; showing a thumbnail in a chat needs only image_url.",
|
|
82
|
-
'ucp product': "Look at one product over UCP, with every variant and the full description: c11r ucp product <product id> [--country CC] [--images] [--store <host>]. Returns { store, product: { id, title, description, url, image_url, variants: [...] }, signature }, variants as in ucp search. --images is for agents that can read image files and want to look at or describe a product: it saves the product's image and adds image_file, the file's absolute path, or image_error when the download failed; showing a thumbnail in a chat needs only image_url.",
|
|
83
|
-
'ucp checkout create': "Buy for the human, step 2: open a checkout with the items the human picked. c11r ucp checkout create --item <variant id>[:<qty>] [--item ...] [--code <discount>] [--country CC] [--store <host>]. Returns the checkout: { id, status, store, currency, line_items: [{ id, item_id, title, image_url, quantity, unit_price, total }], buyer, ship_to, shipping, discounts, codes, totals: [{ type, label, amount, major, display }], total, messages, continue_url, expires_at, links, signature }. status is incomplete until the buyer's email and shipping address are given: messages say what is missing. Ask the human for them, never invent them, then c11r ucp checkout update <id>. The id is remembered with its store: later steps need no --store.",
|
|
84
|
-
'ucp checkout update': "Buy for the human, step 3: give the buyer's details, change items or codes. c11r ucp checkout update <id> [--email --first-name --last-name --phone] [--street --city --postal-code --region --country] [--item <variant id>:<qty> (0 removes)] [--code <discount> (adds)] [--shipping <option id>]; or the same as YAML or JSON on stdin or -f <file>: { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: [\"ID:2\"], codes: [...] (replaces), shipping }. Only what is given changes. Returns the checkout as in create: status ready_for_complete once nothing is missing, with shipping and the total including it; or incomplete with messages (address_undeliverable says where the store ships). Show the human the line items, shipping and total.display, ask for an explicit yes, then c11r ucp checkout complete <id>.",
|
|
85
|
-
'ucp checkout show': 'Show a checkout as the store has it now: c11r ucp checkout show <id> [--store <host>]. Returns the checkout as in ucp checkout create.',
|
|
86
|
-
'ucp checkout cancel': 'Cancel a checkout the human no longer wants: c11r ucp checkout cancel <id> [--store <host>]. Returns the checkout, status canceled.',
|
|
87
|
-
'ucp checkout complete': "Buy for the human, step 4, only after the human said yes to the total: pay the checkout. c11r ucp checkout complete <id> [--decline] [--store <host>]. Pays with the ai.commercelayer.sandbox test handler (no money moves): only stores that advertise it can be paid; --decline uses the declined test card. Returns the checkout with status completed and order: { id, label, permalink_url }, plus payment_handler. Tell the human the order label and give them permalink_url. A store without that handler (any store taking real payments) answers payment_handler_unsupported: give the human continue_url to pay there. A declined payment leaves status ready_for_complete with a payment_failed message.",
|
|
88
|
-
'ucp order': 'Read an order this CLI placed: c11r ucp order <id> [--store <host>]. Returns { id, label, permalink_url, store, checkout_id, currency, line_items: [{ item_id, title, image_url, quantity, status, total }], fulfillment: [{ method, destination, description }], events, totals, signature }.',
|
|
89
|
-
'ucp ask': 'Ask a store a question in plain language over UCP (shipping, returns, what it sells): c11r ucp ask "<question>" [--store <host>]. Returns { store, question, answer, links, messages, signature }. The answer is the store\'s: quote it, do not embellish it. Ask only answers; buy with ucp checkout.',
|
|
90
|
-
status: 'Show the sandbox, what is applied, and whether the store files have changes not yet applied (the same changes plan lists). search is the store\'s semantic search index: { model, status: indexing|ready|failed|too_large, vectors, embedded, updated_at, error }, or null before it first runs; until it is ready the store\'s search matches words only.',
|
|
91
|
-
claim: 'Start keeping this sandbox store: returns a URL and a code for the human to approve in the browser.',
|
|
92
|
-
help: 'Describe the commands. `c11r help <command>` for one command, `c11r help <group>` (catalog, fulfillment, links, orders, promotions, storefront, ucp) for a group.',
|
|
93
|
-
};
|
|
94
|
-
export function help(name) {
|
|
95
|
-
if (name && !HELP[name]) {
|
|
96
|
-
// A group (catalog, fulfillment, links, orders, promotions, storefront, ucp): its commands.
|
|
97
|
-
const group = Object.entries(HELP).filter(([k]) => k.startsWith(`${name} `));
|
|
98
|
-
if (group.length)
|
|
99
|
-
return { group: name, commands: Object.fromEntries(group) };
|
|
100
|
-
}
|
|
101
|
-
if (name) {
|
|
102
|
-
if (!HELP[name])
|
|
103
|
-
fail('unknown_command', `There is no command "${name}".`, `Commands: ${Object.keys(HELP).join(', ')}.`);
|
|
104
|
-
const showsFormat = name === 'apply' || name === 'plan';
|
|
105
|
-
return { command: name, description: HELP[name], store_format: showsFormat ? template('<name>') : undefined };
|
|
106
|
-
}
|
|
107
|
-
return { version: VERSION, latest_hint: LATEST_HINT, commands: HELP, note: 'Every command prints JSON. The store format is documented in the comments of .commercelayer/store.yml; large catalogs go in .commercelayer/products/ as .yml or .csv files.' };
|
|
108
|
-
}
|
|
109
|
-
export async function init(values) {
|
|
110
|
-
const existing = readCredentials();
|
|
111
|
-
if (existing && fs.existsSync(paths.store)) {
|
|
112
|
-
const s = await createApi(existing).getStore();
|
|
113
|
-
const notes = writeAgentNotes();
|
|
114
|
-
return { status: 'reused', org: s.org, claim_url: s.claim_url, expires_at: s.expires_at, preview_url: s.preview_url, files: notes.length ? notes : undefined };
|
|
115
|
-
}
|
|
116
|
-
// The business name becomes the organization's name; the platform derives the
|
|
117
|
-
// org slug (and so the store hostname) from it. The folder name is the fallback.
|
|
118
|
-
if (values.name !== undefined && !values.name.trim())
|
|
119
|
-
fail('invalid_argument', '--name needs the business name.', 'Example: c11r init --name "The Blue Brand". Leave it out to use the folder name.');
|
|
120
|
-
const name = values.name?.trim() || path.basename(process.cwd());
|
|
121
|
-
const sandbox = await createApi(null).createSandbox(name);
|
|
122
|
-
fs.mkdirSync(paths.dir, { recursive: true });
|
|
123
|
-
if (!fs.existsSync(paths.store))
|
|
124
|
-
fs.writeFileSync(paths.store, template(name));
|
|
125
|
-
writeCredentials({ org: sandbox.org, token: sandbox.token });
|
|
126
|
-
const files = [paths.store, paths.credentials];
|
|
127
|
-
if (ignoreCredentials())
|
|
128
|
-
files.push('.gitignore');
|
|
129
|
-
files.push(...writeAgentNotes());
|
|
130
|
-
return {
|
|
131
|
-
status: 'created',
|
|
132
|
-
org: sandbox.org,
|
|
133
|
-
name,
|
|
134
|
-
claim_url: sandbox.claim_url,
|
|
135
|
-
expires_at: sandbox.expires_at,
|
|
136
|
-
preview_url: sandbox.preview_url,
|
|
137
|
-
files,
|
|
138
|
-
next: 'Describe the store in .commercelayer/store.yml (products there or in .commercelayer/products/*.yml|csv, the theme under theme:, images in .commercelayer/img/), then run c11r plan and c11r apply: the website is live at preview_url.',
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
export async function plan() {
|
|
142
|
-
const api = createApi(requireCredentials());
|
|
143
|
-
const desired = loadStore();
|
|
144
|
-
// The ops policy is checked here too, so a mistake shows before a scheduled run trips on it.
|
|
145
|
-
ops.loadPolicy();
|
|
146
|
-
const state = await api.getStore();
|
|
147
|
-
const assets = planAssets(state, desired);
|
|
148
|
-
const changes = [...diff(state, desired), ...assets.changes];
|
|
149
|
-
return {
|
|
150
|
-
status: changes.length ? 'changes' : 'up_to_date',
|
|
151
|
-
changes,
|
|
152
|
-
deletes: changes.filter((c) => c.action === 'delete' && c.type !== 'asset').length,
|
|
153
|
-
...imageNotes(assets),
|
|
154
|
-
warnings: languageWarnings(desired).length ? languageWarnings(desired) : undefined,
|
|
155
|
-
next: assets.missing.length ? 'Put the missing images in .commercelayer/img/ (img/<file> in store.yml is .commercelayer/img/<file>), or fix the references: apply refuses until they are there.' : undefined,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
/** How often apply reads a running import, and when it stops waiting. Env overrides are for tests. */
|
|
159
|
-
const POLL_MS = Number(process.env.C11R_APPLY_POLL_MS) || 2000;
|
|
160
|
-
const WAIT_MS = Number(process.env.C11R_APPLY_WAIT_MS) || 10 * 60 * 1000;
|
|
161
|
-
/** Errors that say nothing about the import itself: apply keeps polling through them. */
|
|
162
|
-
const TRANSIENT = new Set(['commercelayer_busy', 'commercelayer_unavailable', 'api_error', 'api_unreachable']);
|
|
163
|
-
/** The longest wait between reads while Commerce Layer answers busy (429). */
|
|
164
|
-
const MAX_BACKOFF_MS = 30_000;
|
|
165
|
-
const jobSummary = (j) => ({ id: j.id, status: j.status, step: j.step, processed: j.processed, total: j.total });
|
|
166
|
-
/** Read the store until its apply job is no longer in progress, with a progress line per change. */
|
|
167
|
-
async function follow(api, state) {
|
|
168
|
-
const until = Date.now() + WAIT_MS;
|
|
169
|
-
let s = state;
|
|
170
|
-
let last = '';
|
|
171
|
-
let wait = POLL_MS;
|
|
172
|
-
while (s.apply?.status === 'in_progress') {
|
|
173
|
-
const line = JSON.stringify(jobSummary(s.apply));
|
|
174
|
-
if (line !== last)
|
|
175
|
-
progress({ apply: jobSummary(s.apply) });
|
|
176
|
-
last = line;
|
|
177
|
-
if (Date.now() >= until) {
|
|
178
|
-
const j = s.apply;
|
|
179
|
-
fail('apply_pending', `The apply is still running: ${j.processed} of ${j.total} items done (step ${j.step}).`, 'The apply keeps going; run `c11r status` to follow it.');
|
|
180
|
-
}
|
|
181
|
-
await new Promise((r) => setTimeout(r, Math.min(wait, Math.max(0, until - Date.now()))));
|
|
182
|
-
try {
|
|
183
|
-
s = await api.getStore();
|
|
184
|
-
wait = POLL_MS;
|
|
185
|
-
}
|
|
186
|
-
catch (e) {
|
|
187
|
-
// The import runs on regardless: report the error and read again, backing off while Commerce Layer is busy.
|
|
188
|
-
if (!(e instanceof CliError) || !TRANSIENT.has(e.code))
|
|
189
|
-
throw e;
|
|
190
|
-
wait = e.code === 'commercelayer_busy' ? Math.min(MAX_BACKOFF_MS, wait * 2) : POLL_MS;
|
|
191
|
-
progress({ retrying: { code: e.code, message: e.message, next_read_ms: wait } });
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return s;
|
|
195
|
-
}
|
|
196
|
-
export async function apply(flags) {
|
|
197
|
-
const api = createApi(requireCredentials());
|
|
198
|
-
const desired = loadStore();
|
|
199
|
-
// An apply still running is finished first, so the changes are counted from what it installs.
|
|
200
|
-
let state = await api.getStore();
|
|
201
|
-
if (state.apply?.status === 'in_progress')
|
|
202
|
-
state = await follow(api, state);
|
|
203
|
-
const assets = planAssets(state, desired);
|
|
204
|
-
const changes = [...diff(state, desired), ...assets.changes];
|
|
205
|
-
// Images nothing uses any more are dropped without asking: they are copies of files in the repo.
|
|
206
|
-
const deletes = changes.filter((c) => c.action === 'delete' && c.type !== 'asset');
|
|
207
|
-
if (deletes.length && !flags.has('--allow-delete')) {
|
|
208
|
-
fail('deletes_need_approval', `Apply would delete ${deletes.map((d) => `${d.type} ${d.id}`).join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
|
|
209
|
-
}
|
|
210
|
-
if (assets.missing.length) {
|
|
211
|
-
fail('missing_images', `The store references images that are not in .commercelayer/img/: ${assets.missing.join('; ')}.`, 'Put the files there (img/<file> in store.yml is .commercelayer/img/<file>), or fix or remove the references, then apply again.');
|
|
212
|
-
}
|
|
213
|
-
// Images first, so the theme and products never point at a file the store host does not have yet.
|
|
214
|
-
const uploaded = assets.supported ? await uploadAssets(api, assets) : 0;
|
|
215
|
-
const body = {
|
|
216
|
-
applied: toApplied(desired),
|
|
217
|
-
hash: hashStore(),
|
|
218
|
-
name: desired.name,
|
|
219
|
-
policies: desired.policies,
|
|
220
|
-
theme: desired.theme ?? {},
|
|
221
|
-
storefront_mode: storefrontMode(desired),
|
|
222
|
-
...(assets.supported && { assets: assets.keep }),
|
|
223
|
-
...(flags.has('--allow-delete') && { allow_delete: true }),
|
|
224
|
-
...(flags.has('--full') && { full: true }),
|
|
225
|
-
};
|
|
226
|
-
const started = await api.applyStore(body);
|
|
227
|
-
const job = started.apply?.status === 'in_progress' ? started.apply : null;
|
|
228
|
-
const done = job ? await follow(api, started) : started;
|
|
229
|
-
if (job && done.apply?.id === job.id && done.apply.status === 'failed') {
|
|
230
|
-
const errors = done.apply.errors;
|
|
231
|
-
const list = errors.map((e) => (e.sku ? `${e.sku}: ${e.message}` : e.message)).join('; ');
|
|
232
|
-
const what = errors.length === 1 ? '1 product was refused' : `${errors.length} products were refused`;
|
|
233
|
-
fail('apply_failed', errors.length ? `${what}: ${list}` : 'The apply failed in Commerce Layer.', 'Fix those products in store.yml or the catalog source and run `c11r apply` again; the rest of the store is unchanged.');
|
|
234
|
-
}
|
|
235
|
-
return {
|
|
236
|
-
status: 'applied',
|
|
237
|
-
changes,
|
|
238
|
-
preview_url: done.preview_url,
|
|
239
|
-
markets: desired.markets.length,
|
|
240
|
-
products: desired.products.length,
|
|
241
|
-
payment_gateway: 'sandbox (test payments only)',
|
|
242
|
-
images_uploaded: uploaded || undefined,
|
|
243
|
-
unused_images: assets.unused.length ? assets.unused : undefined,
|
|
244
|
-
apply: job && done.apply ? jobSummary(done.apply) : undefined,
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
export async function catalogPull(values) {
|
|
248
|
-
return pull(values);
|
|
249
|
-
}
|
|
250
|
-
export async function storefrontInspect(url) {
|
|
251
|
-
return inspect(url);
|
|
252
|
-
}
|
|
253
|
-
export async function storefrontInit(flags) {
|
|
254
|
-
const api = createApi(requireCredentials());
|
|
255
|
-
const desired = loadStore();
|
|
256
|
-
const dir = 'storefront';
|
|
257
|
-
if (fs.existsSync(dir) && !flags.has('--force')) {
|
|
258
|
-
fail('storefront_exists', `${dir}/ already exists.`, 'Edit the files in it, or run `c11r storefront init --force` to regenerate them from store.yml (this overwrites your changes).');
|
|
259
|
-
}
|
|
260
|
-
const state = await api.getStore();
|
|
261
|
-
const written = [];
|
|
262
|
-
for (const [name, content] of Object.entries(storefrontFiles(desired, state.sales_channel_client_id))) {
|
|
263
|
-
const file = path.join(dir, name);
|
|
264
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
265
|
-
fs.writeFileSync(file, content);
|
|
266
|
-
written.push(file);
|
|
267
|
-
}
|
|
268
|
-
return {
|
|
269
|
-
status: 'created',
|
|
270
|
-
files: written,
|
|
271
|
-
markets: desired.markets.map((m) => m.code),
|
|
272
|
-
products: desired.products.length,
|
|
273
|
-
next: `Set storefront.path: ${dir} in ${paths.store}, make it the human's (restyle through the tokens at the top of styles.css; each product has a page at #/p/<sku>, built from #product-template in index.html; product images come from .commercelayer/img/ at /img/, and this site's own files go in ${dir}/assets/), then run c11r storefront publish.`,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
export async function storefrontCheckCmd() {
|
|
277
|
-
return storefrontCheck(createApi(requireCredentials()));
|
|
278
|
-
}
|
|
279
|
-
export async function storefrontPublish() {
|
|
280
|
-
const api = createApi(requireCredentials());
|
|
281
|
-
const dir = storefrontPath();
|
|
282
|
-
if (!fs.existsSync(path.join(dir, 'index.html'))) {
|
|
283
|
-
fail('no_storefront', `There is no ${path.join(dir, 'index.html')}.`, 'Run `c11r storefront init` to create one, or set storefront.path in store.yml to the folder that has index.html.');
|
|
284
|
-
}
|
|
285
|
-
// Dotfiles (.gitkeep, .DS_Store, .env) and dot folders stay on this machine.
|
|
286
|
-
const files = fs
|
|
287
|
-
.readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
288
|
-
.filter((e) => e.isFile())
|
|
289
|
-
.map((e) => path.relative(dir, path.join(e.parentPath, e.name)).split(path.sep).join('/'))
|
|
290
|
-
.filter((rel) => !rel.split('/').some((part) => part.startsWith('.')))
|
|
291
|
-
.map((rel) => ({ path: rel, content_base64: fs.readFileSync(path.join(dir, rel)).toString('base64') }));
|
|
292
|
-
// /img/ on the store host is the store's images (.commercelayer/img/, uploaded by apply): files here under img/ are never served.
|
|
293
|
-
const shadowed = files.filter((f) => f.path.startsWith('img/')).map((f) => f.path);
|
|
294
|
-
const published = await api.publishStorefront(dir, files);
|
|
295
|
-
const state = await api.getStore();
|
|
296
|
-
return {
|
|
297
|
-
status: 'published',
|
|
298
|
-
preview_url: state.preview_url,
|
|
299
|
-
path: dir,
|
|
300
|
-
files: published.files,
|
|
301
|
-
published_at: published.published_at,
|
|
302
|
-
not_served: shadowed.length ? shadowed : undefined,
|
|
303
|
-
next: shadowed.length ? `/img/ is the store's images, from .commercelayer/img/ (uploaded by apply): move product images there and this site's own files to ${dir}/assets/.` : undefined,
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
/** The store's origin on the web: where it speaks UCP. */
|
|
307
|
-
async function storeOrigin() {
|
|
308
|
-
const api = createApi(requireCredentials());
|
|
309
|
-
const s = await api.getStore();
|
|
310
|
-
if (!s.applied)
|
|
311
|
-
fail('not_applied', 'The store has nothing applied yet.', 'Run `c11r apply` first.');
|
|
312
|
-
return s.preview_url;
|
|
313
|
-
}
|
|
314
|
-
export async function ordersTest(flags, values) {
|
|
315
|
-
const origin = await storeOrigin();
|
|
316
|
-
const quantity = values.quantity === undefined ? undefined : Number(values.quantity);
|
|
317
|
-
if (quantity !== undefined && (!Number.isInteger(quantity) || quantity < 1))
|
|
318
|
-
fail('invalid_argument', '--quantity must be a positive integer.', 'Example: --quantity 2.');
|
|
319
|
-
const p = await buy(origin, { sku: values.sku, quantity, country: values.country, code: values.code, viaCart: flags.has('--via-cart'), decline: flags.has('--decline') });
|
|
320
|
-
return {
|
|
321
|
-
// The store places the order with its payment authorized; the merchant approves and captures it.
|
|
322
|
-
status: p.checkout.status === 'completed' ? 'placed' : p.checkout.status,
|
|
323
|
-
order: p.order,
|
|
324
|
-
// What the promotions took off, in minor units like the totals; a code that applied nothing is in messages.
|
|
325
|
-
discounts: (p.checkout.discounts ?? p.ready.discounts)?.applied?.length ? (p.checkout.discounts ?? p.ready.discounts).applied : undefined,
|
|
326
|
-
checkout_id: p.checkout.id,
|
|
327
|
-
messages: p.checkout.messages ?? p.ready.messages,
|
|
328
|
-
payment_handler: 'ai.commercelayer.sandbox (test payments only)',
|
|
329
|
-
steps: p.steps,
|
|
330
|
-
next: p.checkout.status === 'completed' ? `Placed, payment authorized: approve and capture it with c11r orders approve ${String(p.order?.label ?? p.order?.id).replace(/^#/, '')} --capture, then ship it with c11r fulfillment pick and ship.` : undefined,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
// Orders and shipments, live in Commerce Layer through the API. An order lands placed with its
|
|
334
|
-
// payment authorized; the merchant approves, captures, ships. Each answer says what comes next.
|
|
335
|
-
const LIST_LIMIT = (raw) => {
|
|
336
|
-
if (raw === undefined)
|
|
337
|
-
return undefined;
|
|
338
|
-
const n = Number(raw);
|
|
339
|
-
if (!Number.isInteger(n) || n < 1 || n > 100)
|
|
340
|
-
fail('invalid_argument', `--limit ${raw} is not a number from 1 to 100.`, 'Example: --limit 50.');
|
|
341
|
-
return String(n);
|
|
342
|
-
};
|
|
343
|
-
function orderNext(o) {
|
|
344
|
-
const n = o.number;
|
|
345
|
-
const shipment = o.shipments?.[0];
|
|
346
|
-
if (o.status === 'pending')
|
|
347
|
-
return `Order ${n} is pending: made from an email or a message, not placed yet. Show the human its lines and total (c11r orders show ${n}); on their yes, place it: c11r orders place ${n}. If they don't want it: c11r orders cancel ${n}.`;
|
|
348
|
-
if (o.status === 'cancelled' && o.archived)
|
|
349
|
-
return `Order ${n} is cancelled and archived (payment ${o.payment_status}). Nothing more to do.`;
|
|
350
|
-
if (o.status === 'cancelled')
|
|
351
|
-
return `Order ${n} is cancelled (payment ${o.payment_status}). Nothing more to do; archive it to keep the list short: c11r orders archive ${n}.`;
|
|
352
|
-
if (o.status === 'placed' && o.payment_status !== 'authorized')
|
|
353
|
-
return `Order ${n} is placed; its payment is ${o.payment_status?.replace(/_/g, ' ')} (taken before approving). When the human accepts it, approve it: c11r orders approve ${n}. If they don't want it: c11r orders cancel ${n} (Commerce Layer refunds what was taken).`;
|
|
354
|
-
if (o.status === 'placed')
|
|
355
|
-
return `Order ${n} is placed and its payment is authorized, not taken. When the human accepts it, approve it and take the payment: c11r orders approve ${n} --capture (without --capture to take it later). If they don't want it: c11r orders cancel ${n}.`;
|
|
356
|
-
if (o.payment_status === 'authorized')
|
|
357
|
-
return `Order ${n} is approved; the payment is authorized but not taken. Take it when the human says so: c11r orders capture ${n}.`;
|
|
358
|
-
if (o.fulfillment_status === 'fulfilled')
|
|
359
|
-
return `Order ${n} has shipped${shipment?.tracking?.number ? ` (tracking ${shipment.tracking.number})` : ''}. Mark it delivered when it arrives: c11r fulfillment deliver ${shipment?.number ?? n}; refund all or part if the human asks: c11r orders refund ${n} [--amount <n>].`;
|
|
360
|
-
return `Order ${n} is approved and paid (${o.total?.display}). Pick it, then ship it: c11r fulfillment pick ${shipment?.number ?? n}, then c11r fulfillment ship ${shipment?.number ?? n} [--carrier <name> --tracking <number> --tracking-url <url>] (pack and ready in between if the human tracks them).`;
|
|
361
|
-
}
|
|
362
|
-
export async function ordersList(values, flags) {
|
|
363
|
-
const api = createApi(requireCredentials());
|
|
364
|
-
const archived = values.archived ?? (flags.has('--archived') ? 'true' : undefined);
|
|
365
|
-
if (archived !== undefined && !['true', 'false', 'all'].includes(archived))
|
|
366
|
-
fail('invalid_argument', `--archived ${archived} is not a choice.`, 'Use --archived for only the archived orders, or --archived all for every order.');
|
|
367
|
-
const query = { status: values.status, payment: values.payment, fulfillment: values.fulfillment, channel: values.channel, archived, limit: LIST_LIMIT(values.limit) };
|
|
368
|
-
const { orders } = await api.listMerchantOrders(query);
|
|
369
|
-
const waiting = orders.filter((o) => o.status === 'placed').length;
|
|
370
|
-
const pending = orders.filter((o) => o.status === 'pending').length;
|
|
371
|
-
return {
|
|
372
|
-
count: orders.length,
|
|
373
|
-
orders,
|
|
374
|
-
next: orders.length
|
|
375
|
-
? `Show the human the orders (number, status, payment_status, total.display, email).${pending ? ` ${pending} pending order(s) from emails or messages wait for the human's yes: c11r orders show <number>, then c11r orders place <number>.` : ''}${waiting ? ` ${waiting} placed order(s) wait for the human to approve them: c11r orders show <number> for one, then c11r orders approve <number> --capture once they say so.` : ''}`
|
|
376
|
-
: 'No orders match. Without --status the list has placed and approved orders only: --status all shows every one, --archived the archived ones.',
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
export async function ordersShow(id) {
|
|
380
|
-
if (!id)
|
|
381
|
-
fail('missing_argument', 'orders show needs an order id or number.', 'Run `c11r orders list` to see the orders, then `c11r orders show <number>`.');
|
|
382
|
-
const order = await createApi(requireCredentials()).getMerchantOrder(id);
|
|
383
|
-
return { order, next: orderNext(order) };
|
|
384
|
-
}
|
|
385
|
-
export async function ordersAct(action, id, values, flags) {
|
|
386
|
-
if (!id)
|
|
387
|
-
fail('missing_argument', `orders ${action} needs an order id or number.`, `Run \`c11r orders list\` to see the orders, then \`c11r orders ${action} <number>\`.`);
|
|
388
|
-
const body = {};
|
|
389
|
-
if (action === 'approve' && flags.has('--capture'))
|
|
390
|
-
body.capture = true;
|
|
391
|
-
if (action === 'refund' && values.amount !== undefined) {
|
|
392
|
-
const amount = Number(values.amount);
|
|
393
|
-
if (values.amount.trim() === '' || !Number.isFinite(amount) || amount <= 0)
|
|
394
|
-
fail('invalid_argument', `--amount ${values.amount} is not an amount.`, 'Give it in major units, for example --amount 4.50; leave it out to refund everything still refundable.');
|
|
395
|
-
body.amount = amount;
|
|
396
|
-
}
|
|
397
|
-
const order = await createApi(requireCredentials()).orderAction(id, action, body);
|
|
398
|
-
return { status: action === 'archive' ? 'archived' : action === 'unarchive' ? 'unarchived' : order.status, order, next: orderNext(order) };
|
|
399
|
-
}
|
|
400
|
-
// Orders from emails and messages: the agent reads the message, the API resolves the lines
|
|
401
|
-
// against the catalog (catalog prices only) and creates the order pending, or placed under the
|
|
402
|
-
// policy; a line it cannot resolve comes back for the human, with nothing created.
|
|
403
|
-
/** The flags that write the request itself; with -f or stdin only --source may be added. */
|
|
404
|
-
const REQUEST_FLAGS = ['email', 'first-name', 'last-name', 'company', 'phone', 'item', 'query', 'street', 'line-2', 'city', 'postal-code', 'region', 'country', 'reference', 'notes', 'language'];
|
|
405
|
-
const CREATE_USAGE = "Give the order as YAML or JSON with -f <file> or on stdin (c11r orders create <<'EOF' ... EOF), or with flags: --email --first-name --last-name --phone --item <sku>:<qty> --street --city --postal-code --country.";
|
|
406
|
-
function requestFromFlags(values, lists) {
|
|
407
|
-
const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
408
|
-
return drop({
|
|
409
|
-
customer: drop({ email: values.email, first_name: values['first-name'], last_name: values['last-name'], company: values.company, phone: values.phone }),
|
|
410
|
-
ship_to: drop({ line_1: values.street, line_2: values['line-2'], city: values.city, postal_code: values['postal-code'], region: values.region, country: values.country }),
|
|
411
|
-
items: [...(lists.item ?? []).map((x) => lineFlag(x, 'sku')), ...(lists.query ?? []).map((x) => lineFlag(x, 'query'))],
|
|
412
|
-
notes: values.notes,
|
|
413
|
-
reference: values.reference,
|
|
414
|
-
language: values.language,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
const RESOLUTION_WORDS = { ambiguous: 'could be several products', not_found: 'matches nothing in the catalog', unavailable: 'is not sold in that country' };
|
|
418
|
-
function createdNext(r, placedBy) {
|
|
419
|
-
const order = r.order;
|
|
420
|
-
if (r.status === 'needs_review' || !order) {
|
|
421
|
-
const open = r.resolution.filter((x) => x.status !== 'matched');
|
|
422
|
-
const lines = open.map((x) => `"${x.requested}" ${RESOLUTION_WORDS[x.status] ?? x.status}${x.candidates.length ? ` (${x.candidates.map((c) => `${c.name}, ${c.sku}`).join('; ')})` : ''}`);
|
|
423
|
-
return `Nothing was created: ${ops.plural(open.length, 'line needs', 'lines need')} the human: ${lines.join('; ')}. Show them the candidates and ask which they meant; then run orders create again with --item <sku>:<quantity> for those lines (the same --source), or with --force to create the order without them.`;
|
|
424
|
-
}
|
|
425
|
-
const dup = r.warnings?.find((w) => w.code === 'duplicate_source');
|
|
426
|
-
const dropped = r.warnings?.filter((w) => w.code === 'line_dropped') ?? [];
|
|
427
|
-
const before = [
|
|
428
|
-
dup ? `This source already made order ${order.number}: nothing new was created.` : '',
|
|
429
|
-
dropped.length ? `Tell the human ${ops.plural(dropped.length, 'line was', 'lines were')} left out (warnings).` : '',
|
|
430
|
-
!dup && order.status === 'placed' && placedBy ? `Placed at once (${placedBy}).` : '',
|
|
431
|
-
].filter(Boolean);
|
|
432
|
-
return [...before, orderNext(order)].join(' ');
|
|
433
|
-
}
|
|
434
|
-
export async function ordersCreate(values, lists, flags) {
|
|
435
|
-
if (flags.has('--place') && flags.has('--hold'))
|
|
436
|
-
fail('invalid_argument', '--place and --hold say opposite things.', 'Use --place to place it at once, --hold to leave it pending whatever the policy says, or neither to follow mail_orders.auto_place.');
|
|
437
|
-
const byFlags = REQUEST_FLAGS.some((k) => values[k] !== undefined);
|
|
438
|
-
if (byFlags && values.file)
|
|
439
|
-
fail('invalid_argument', 'The order is given both in a file and with flags.', 'Give it in the file (-f) or with flags, not both; --source, --place, --hold and --force go with either.');
|
|
440
|
-
const raw = byFlags ? requestFromFlags(values, lists) : await readText(values, 'order request', CREATE_USAGE);
|
|
441
|
-
if (raw && typeof raw === 'object' && !Array.isArray(raw) && values.source !== undefined)
|
|
442
|
-
raw.source = sourceFlag(values.source);
|
|
443
|
-
const request = checkOrderRequest(raw);
|
|
444
|
-
const policy = ops.loadPolicy();
|
|
445
|
-
// --hold wins; then --place or place: true in the request; then the policy.
|
|
446
|
-
const placedBy = flags.has('--hold') ? null : flags.has('--place') ? '--place' : request.place ? 'place: true in the request' : policy.mail_orders.auto_place ? 'mail_orders.auto_place in .commercelayer/ops.yml' : null;
|
|
447
|
-
delete request.place;
|
|
448
|
-
if (placedBy)
|
|
449
|
-
request.place = true;
|
|
450
|
-
if (flags.has('--force'))
|
|
451
|
-
request.force = true;
|
|
452
|
-
const r = await createApi(requireCredentials()).createOrder(request);
|
|
453
|
-
return { status: r.status, order: r.order, resolution: r.resolution, warnings: r.warnings?.length ? r.warnings : undefined, next: createdNext(r, placedBy) };
|
|
454
|
-
}
|
|
455
|
-
function shipmentNext(s) {
|
|
456
|
-
const n = s.number;
|
|
457
|
-
if (s.status === 'delivered')
|
|
458
|
-
return `Shipment ${n} is delivered. Nothing more to do.`;
|
|
459
|
-
if (s.status === 'cancelled')
|
|
460
|
-
return `Shipment ${n} is cancelled with its order ${s.order?.number}.`;
|
|
461
|
-
if (s.status === 'shipped')
|
|
462
|
-
return `Shipment ${n} is on its way${s.tracking?.number ? ` (tracking ${s.tracking.number})` : ''}. Mark it delivered when it arrives: c11r fulfillment deliver ${n}.`;
|
|
463
|
-
if (s.order?.status === 'placed')
|
|
464
|
-
return `Shipment ${n} waits for its order ${s.order.number} to be approved: c11r orders approve ${s.order.number} --capture, once the human accepts it. It can be held meanwhile: c11r fulfillment hold ${n}.`;
|
|
465
|
-
if (s.status === 'upcoming')
|
|
466
|
-
return `Shipment ${n} is upcoming. Pick it first: c11r fulfillment pick ${n}; then ship it when it leaves: c11r fulfillment ship ${n} [--carrier <name> --tracking <number> --tracking-url <url>].`;
|
|
467
|
-
return `Shipment ${n} is ${s.status.replace(/_/g, ' ')}. Ship it when it leaves: c11r fulfillment ship ${n} [--carrier <name> --tracking <number> --tracking-url <url>]; or move it on: c11r fulfillment pick, pack, ready or hold ${n}.`;
|
|
468
|
-
}
|
|
469
|
-
export async function fulfillmentList(values) {
|
|
470
|
-
const { shipments } = await createApi(requireCredentials()).listShipments({ status: values.status, limit: LIST_LIMIT(values.limit) });
|
|
471
|
-
return {
|
|
472
|
-
count: shipments.length,
|
|
473
|
-
shipments,
|
|
474
|
-
next: shipments.length
|
|
475
|
-
? 'Show the human the shipments (number, status, order.number, country, items). c11r fulfillment show <number> for the address and items.'
|
|
476
|
-
: 'No shipments match. Without --status the list has the ones not shipped yet: --status shipped, delivered or all for the others.',
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
export async function fulfillmentShow(id) {
|
|
480
|
-
if (!id)
|
|
481
|
-
fail('missing_argument', 'fulfillment show needs a shipment id or number, or an order number.', 'Run `c11r fulfillment list` to see the shipments.');
|
|
482
|
-
const shipment = await createApi(requireCredentials()).getShipment(id);
|
|
483
|
-
return { shipment, next: shipmentNext(shipment) };
|
|
484
|
-
}
|
|
485
|
-
export async function fulfillmentAct(action, id, values) {
|
|
486
|
-
if (!id)
|
|
487
|
-
fail('missing_argument', `fulfillment ${action} needs a shipment id or number, or an order number.`, 'Run `c11r fulfillment list` to see the shipments.');
|
|
488
|
-
const body = {};
|
|
489
|
-
if (action === 'ship') {
|
|
490
|
-
if (values.carrier !== undefined)
|
|
491
|
-
body.carrier = values.carrier;
|
|
492
|
-
if (values.tracking !== undefined)
|
|
493
|
-
body.tracking = values.tracking;
|
|
494
|
-
if (values['tracking-url'] !== undefined) {
|
|
495
|
-
if (!/^https:\/\/\S+$/.test(values['tracking-url']))
|
|
496
|
-
fail('invalid_argument', `--tracking-url ${values['tracking-url']} is not an https address.`, "Give the carrier's tracking page, starting with https://.");
|
|
497
|
-
body.tracking_url = values['tracking-url'];
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
const shipment = await createApi(requireCredentials()).shipmentAction(id, action, body);
|
|
501
|
-
return { status: shipment.status, shipment, next: shipmentNext(shipment) };
|
|
502
|
-
}
|
|
503
|
-
export async function ucpProfile(flags) {
|
|
504
|
-
if (flags.has('--agent'))
|
|
505
|
-
return { url: AGENT_PROFILE_URL, profile: agentProfile() };
|
|
506
|
-
const origin = await storeOrigin();
|
|
507
|
-
const store = await discover(origin);
|
|
508
|
-
return { url: `${origin}/.well-known/ucp`, profile: store.profile };
|
|
509
|
-
}
|
|
510
|
-
export async function ucpCheck() {
|
|
511
|
-
const origin = await storeOrigin();
|
|
512
|
-
const report = await check(origin);
|
|
513
|
-
return {
|
|
514
|
-
...report,
|
|
515
|
-
note: report.order ? `The checkout checks placed a real test order (${report.order.label ?? report.order.id}) with the sandbox handler, payment authorized; it appears in \`c11r orders list\` like one from \`orders test\`.` : undefined,
|
|
516
|
-
next: report.status === 'pass' ? 'Any shopping agent can buy from this store.' : 'Fix the failed checks and run `c11r ucp check` again.',
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
export async function status() {
|
|
520
|
-
const api = createApi(requireCredentials());
|
|
521
|
-
const s = await api.getStore();
|
|
522
|
-
// Pending means exactly what plan would list, so the two never disagree. If
|
|
523
|
-
// the store files do not even load, that is pending too, with the error.
|
|
524
|
-
let pending = [];
|
|
525
|
-
let storeError;
|
|
526
|
-
try {
|
|
527
|
-
const desired = loadStore();
|
|
528
|
-
pending = [...diff(s, desired), ...planAssets(s, desired).changes];
|
|
529
|
-
}
|
|
530
|
-
catch (e) {
|
|
531
|
-
if (!(e instanceof CliError))
|
|
532
|
-
throw e;
|
|
533
|
-
storeError = { code: e.code, message: e.message, hint: e.hint };
|
|
534
|
-
}
|
|
535
|
-
return {
|
|
536
|
-
org: s.org,
|
|
537
|
-
sandbox: s.sandbox,
|
|
538
|
-
claimed: s.claimed,
|
|
539
|
-
expires_at: s.expires_at,
|
|
540
|
-
preview_url: s.preview_url,
|
|
541
|
-
sales_channel_client_id: s.sales_channel_client_id,
|
|
542
|
-
storefront: s.storefront,
|
|
543
|
-
applied: Boolean(s.applied),
|
|
544
|
-
apply: s.apply ?? undefined,
|
|
545
|
-
pending_changes: pending.length > 0 || storeError !== undefined,
|
|
546
|
-
pending: pending.length ? pending : undefined,
|
|
547
|
-
store_error: storeError,
|
|
548
|
-
markets: s.applied ? Object.keys(s.applied.markets) : [],
|
|
549
|
-
products: s.applied ? Object.keys(s.applied.products).length : 0,
|
|
550
|
-
orders: s.orders.map((o) => ({ id: o.id, label: o.label })),
|
|
551
|
-
// Semantic search: until it is ready, the store's search matches words only.
|
|
552
|
-
search: s.search ? { model: s.search.model, status: s.search.status, vectors: s.search.vectors, embedded: s.search.embedded, updated_at: s.search.updated_at, error: s.search.error } : null,
|
|
553
|
-
ucp_profile: `${s.preview_url}/.well-known/ucp`,
|
|
554
|
-
next: s.apply?.status === 'in_progress' ? 'An apply is running: run `c11r status` again to follow it, or `c11r apply` to wait for it.' : storeError ? 'Fix the store files (see store_error), then run `c11r plan`.' : pending.length ? 'Run `c11r plan` to see the changes, then `c11r apply`.' : undefined,
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
export async function claim() {
|
|
558
|
-
const api = createApi(requireCredentials());
|
|
559
|
-
const s = await api.getStore();
|
|
560
|
-
const started = await api.startClaim();
|
|
561
|
-
return {
|
|
562
|
-
status: 'pending',
|
|
563
|
-
verification_url: started.verification_url,
|
|
564
|
-
user_code: started.user_code,
|
|
565
|
-
claim_url: s.claim_url,
|
|
566
|
-
next: 'Ask the human to open verification_url and enter user_code, then run `c11r status`.',
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
// ---------- ucp: buying for the human, step by step, from this store or any UCP store
|
|
570
|
-
/** The store to buy from: --store, else the one an id was made at, else this folder's. */
|
|
571
|
-
async function shopOrigin(values, id) {
|
|
572
|
-
if (values.store)
|
|
573
|
-
return shop.originOf(values.store);
|
|
574
|
-
const known = id ? shop.remembered(id) : undefined;
|
|
575
|
-
if (known)
|
|
576
|
-
return known;
|
|
577
|
-
if (!readCredentials())
|
|
578
|
-
fail('no_store', 'There is no store in this folder, and no --store was given.', 'Pass the store to buy from, any store that speaks UCP: --store <hostname or https origin>.');
|
|
579
|
-
return storeOrigin();
|
|
580
|
-
}
|
|
581
|
-
const needId = (id, command) => {
|
|
582
|
-
if (!id)
|
|
583
|
-
fail('missing_argument', `${command} needs a checkout id.`, 'Use the id that c11r ucp checkout create printed (chk_...).');
|
|
584
|
-
return id;
|
|
585
|
-
};
|
|
586
|
-
const checkoutNext = (ck) => {
|
|
587
|
-
if (ck.status === 'completed')
|
|
588
|
-
return `Placed. Tell the human: order ${ck.order?.label ?? ck.order?.id}, and give them ${ck.order?.permalink_url}.`;
|
|
589
|
-
if (ck.status === 'canceled')
|
|
590
|
-
return 'Canceled. Open a new one with c11r ucp checkout create --item <variant id> if the human wants to buy.';
|
|
591
|
-
if (ck.status === 'ready_for_complete') {
|
|
592
|
-
const declined = (ck.messages ?? []).some((m) => m.code === 'payment_failed');
|
|
593
|
-
if (declined)
|
|
594
|
-
return `The payment failed: tell the human. Try again only if they ask: c11r ucp checkout complete ${ck.id}.`;
|
|
595
|
-
return `Show the human the items, shipping and total (${ck.total?.display}), ask for an explicit yes, then run c11r ucp checkout complete ${ck.id}.`;
|
|
596
|
-
}
|
|
597
|
-
const errors = (ck.messages ?? []).filter((m) => m.type === 'error').map((m) => m.content);
|
|
598
|
-
return `Still needed: ${errors.join(' ') || 'see messages'} Ask the human (never invent buyer details), then run c11r ucp checkout update ${ck.id} with what they said.`;
|
|
599
|
-
};
|
|
600
|
-
/** --images: each product's image saved to a file (image_file, or image_error), at most MAX_IMAGES. */
|
|
601
|
-
async function addImages(r, list) {
|
|
602
|
-
const { products, skipped } = await withImages(r.store, list);
|
|
603
|
-
return skipped
|
|
604
|
-
? { products, warnings: [{ code: 'images_capped', message: `Images were saved for the first ${MAX_IMAGES} products only; ${skipped} more have none.`, hint: 'Narrow the search (words, --category, prices) to get the images of the others.' }] }
|
|
605
|
-
: { products };
|
|
606
|
-
}
|
|
607
|
-
export async function ucpSearch(query, values, lists = {}, flags = new Set()) {
|
|
608
|
-
const price = (flag) => {
|
|
609
|
-
const raw = values[flag];
|
|
610
|
-
if (raw === undefined)
|
|
611
|
-
return undefined;
|
|
612
|
-
const n = Number(raw);
|
|
613
|
-
if (raw.trim() === '' || !Number.isFinite(n) || n < 0)
|
|
614
|
-
fail('invalid_argument', `--${flag} ${raw} is not a price.`, `Give it in major units, for example --${flag} 15 or --${flag} 9.90.`);
|
|
615
|
-
return n;
|
|
616
|
-
};
|
|
617
|
-
const min = price('min-price');
|
|
618
|
-
const max = price('max-price');
|
|
619
|
-
if (min !== undefined && max !== undefined && min > max)
|
|
620
|
-
fail('invalid_argument', `--min-price ${min} is above --max-price ${max}.`, 'Swap them.');
|
|
621
|
-
const r = await shop.search(await shopOrigin(values), query, { country: values.country, categories: lists.category, min, max });
|
|
622
|
-
const next = r.count
|
|
623
|
-
? 'Show the human the products (title, categories, price.display, and list_price.display when it is on sale), let them pick, then c11r ucp checkout create --item <variant id>[:<qty>] (variants[].id).'
|
|
624
|
-
: 'Nothing matched: read messages (the store may list its categories there), then try other words, a --category, wider prices, or no query for the whole catalog.';
|
|
625
|
-
return flags.has('--images') ? { ...r, ...(await addImages(r, r.products)), next } : { ...r, next };
|
|
626
|
-
}
|
|
627
|
-
export async function ucpProduct(id, values, flags = new Set()) {
|
|
628
|
-
if (!id)
|
|
629
|
-
fail('missing_argument', 'ucp product needs a product id.', 'Use a product id from c11r ucp search.');
|
|
630
|
-
const r = await shop.product(await shopOrigin(values), id, values.country);
|
|
631
|
-
if (!flags.has('--images'))
|
|
632
|
-
return r;
|
|
633
|
-
const [product] = (await withImages(r.store, [r.product])).products;
|
|
634
|
-
return { ...r, product };
|
|
635
|
-
}
|
|
636
|
-
export async function ucpCheckoutCreate(values, lists) {
|
|
637
|
-
const items = (lists.item ?? []).map(shop.parseItem);
|
|
638
|
-
if (!items.length)
|
|
639
|
-
fail('missing_argument', 'ucp checkout create needs at least one --item.', 'Example: c11r ucp checkout create --item ETH-250:2, with variant ids from c11r ucp search (variants[].id).');
|
|
640
|
-
if (items.some((l) => l.quantity < 1))
|
|
641
|
-
fail('invalid_argument', 'An --item quantity must be 1 or more.', 'Example: --item ETH-250:2.');
|
|
642
|
-
const ck = await shop.createCheckout(await shopOrigin(values), items, lists.code ?? [], values.country);
|
|
643
|
-
return { ...ck, next: checkoutNext(ck) };
|
|
644
|
-
}
|
|
645
|
-
const BUYER_FLAGS = { email: 'email', 'first-name': 'first_name', 'last-name': 'last_name', phone: 'phone_number' };
|
|
646
|
-
const ADDRESS_FLAGS = { street: 'street_address', city: 'address_locality', 'postal-code': 'postal_code', region: 'address_region', country: 'address_country' };
|
|
647
|
-
export async function ucpCheckoutUpdate(id, values, lists) {
|
|
648
|
-
needId(id, 'ucp checkout update');
|
|
649
|
-
const pick = (map) => {
|
|
650
|
-
const out = {};
|
|
651
|
-
for (const [flag, key] of Object.entries(map))
|
|
652
|
-
if (values[flag] !== undefined)
|
|
653
|
-
out[key] = values[flag];
|
|
654
|
-
return Object.keys(out).length ? out : undefined;
|
|
655
|
-
};
|
|
656
|
-
const changes = { buyer: pick(BUYER_FLAGS), address: pick(ADDRESS_FLAGS), items: lists.item?.map(shop.parseItem), add_codes: lists.code, shipping: values.shipping };
|
|
657
|
-
const flagged = Object.values(changes).some((v) => v !== undefined);
|
|
658
|
-
if (!flagged || values.file) {
|
|
659
|
-
// No flags: the changes as YAML or JSON on stdin or -f.
|
|
660
|
-
const raw = await readText(values, 'checkout update', `Give the changes as flags (--email ..., --street ...) or as YAML or JSON on stdin or -f <file>: { buyer: {...}, address: {...}, items, codes, shipping }.`);
|
|
661
|
-
const bad = (msg) => fail('invalid_argument', `checkout update: ${msg}`, 'The input is { buyer: { email, first_name, last_name, phone_number }, address: { street_address, address_locality, postal_code, address_region, address_country }, items: ["ID:2"], codes: [...], shipping }.');
|
|
662
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
663
|
-
bad('the input is not a map.');
|
|
664
|
-
const r = raw;
|
|
665
|
-
for (const k of Object.keys(r))
|
|
666
|
-
if (!['buyer', 'address', 'items', 'codes', 'shipping'].includes(k))
|
|
667
|
-
bad(`unknown key ${k}.`);
|
|
668
|
-
const map = (o, keys, what) => {
|
|
669
|
-
if (o === undefined)
|
|
670
|
-
return undefined;
|
|
671
|
-
if (!o || typeof o !== 'object' || Array.isArray(o))
|
|
672
|
-
bad(`${what} is not a map.`);
|
|
673
|
-
for (const [k, v] of Object.entries(o))
|
|
674
|
-
if (!keys.includes(k) || typeof v !== 'string')
|
|
675
|
-
bad(`${what}.${k} is not one of ${keys.join(', ')} with a text value.`);
|
|
676
|
-
return o;
|
|
677
|
-
};
|
|
678
|
-
if (r.buyer !== undefined)
|
|
679
|
-
changes.buyer = { ...map(r.buyer, Object.values(BUYER_FLAGS), 'buyer'), ...changes.buyer };
|
|
680
|
-
if (r.address !== undefined)
|
|
681
|
-
changes.address = { ...map(r.address, ['first_name', 'last_name', 'extended_address', ...Object.values(ADDRESS_FLAGS)], 'address'), ...changes.address };
|
|
682
|
-
if (r.items !== undefined) {
|
|
683
|
-
if (!Array.isArray(r.items))
|
|
684
|
-
bad('items is not a list.');
|
|
685
|
-
changes.items = [...r.items.map((x) => (typeof x === 'string' ? shop.parseItem(x) : x && typeof x === 'object' && typeof x.id === 'string' && Number.isInteger(x.quantity) ? { id: x.id, quantity: x.quantity } : bad('items holds "ID:QTY" or { id, quantity }, with variant ids.'))), ...(changes.items ?? [])];
|
|
686
|
-
}
|
|
687
|
-
if (r.codes !== undefined) {
|
|
688
|
-
if (!Array.isArray(r.codes) || r.codes.some((c) => typeof c !== 'string'))
|
|
689
|
-
bad('codes is not a list of codes.');
|
|
690
|
-
changes.codes = [...r.codes, ...(changes.add_codes ?? [])];
|
|
691
|
-
}
|
|
692
|
-
if (r.shipping !== undefined)
|
|
693
|
-
changes.shipping = changes.shipping ?? String(r.shipping);
|
|
694
|
-
}
|
|
695
|
-
if (changes.items?.some((l) => !Number.isInteger(l.quantity) || l.quantity < 0))
|
|
696
|
-
fail('invalid_argument', 'An item quantity must be 0 (remove it) or more.', 'Example: --item ETH-250:2.');
|
|
697
|
-
const ck = await shop.updateCheckout(await shopOrigin(values, id), id, changes);
|
|
698
|
-
return { ...ck, next: checkoutNext(ck) };
|
|
699
|
-
}
|
|
700
|
-
export async function ucpCheckoutShow(id, values) {
|
|
701
|
-
const ck = await shop.getCheckout(await shopOrigin(values, needId(id, 'ucp checkout show')), id);
|
|
702
|
-
return { ...ck, next: checkoutNext(ck) };
|
|
703
|
-
}
|
|
704
|
-
export async function ucpCheckoutCancel(id, values) {
|
|
705
|
-
const ck = await shop.cancelCheckout(await shopOrigin(values, needId(id, 'ucp checkout cancel')), id);
|
|
706
|
-
return { ...ck, next: checkoutNext(ck) };
|
|
707
|
-
}
|
|
708
|
-
export async function ucpCheckoutComplete(id, values, flags) {
|
|
709
|
-
const ck = await shop.completeCheckout(await shopOrigin(values, needId(id, 'ucp checkout complete')), id, flags.has('--decline'));
|
|
710
|
-
return { ...ck, payment_handler: 'ai.commercelayer.sandbox (test payments only)', next: checkoutNext(ck) };
|
|
711
|
-
}
|
|
712
|
-
export async function ucpOrder(id, values) {
|
|
713
|
-
if (!id)
|
|
714
|
-
fail('missing_argument', 'ucp order needs an order id.', 'Use order.id from c11r ucp checkout complete.');
|
|
715
|
-
const o = await shop.getOrder(await shopOrigin(values, id), id);
|
|
716
|
-
return { ...o, next: `Tell the human: order ${o.label}, details at ${o.permalink_url}.` };
|
|
717
|
-
}
|
|
718
|
-
export async function ucpAsk(question, values) {
|
|
719
|
-
if (!question.trim())
|
|
720
|
-
fail('missing_argument', 'ucp ask needs a question.', 'Example: c11r ucp ask "Do you ship to Canada?".');
|
|
721
|
-
return shop.ask(await shopOrigin(values), question);
|
|
722
|
-
}
|
|
723
|
-
// ---------- promotions: live settings in Commerce Layer, not store files
|
|
724
|
-
/** What list and show print beyond the spec: dropped when it is sent back. */
|
|
725
|
-
const READ_ONLY = ['status', 'uses', 'managed', 'url', 'urls', 'created_at'];
|
|
726
|
-
/**
|
|
727
|
-
* A spec from -f <file> or stdin, YAML or JSON, for promotions and links. What show prints
|
|
728
|
-
* can be edited and sent back: the read-only fields are dropped.
|
|
729
|
-
*/
|
|
730
|
-
async function readInput(values, what) {
|
|
731
|
-
const raw = await readText(values, what, `Give the ${what} as YAML or JSON on stdin (c11r ${what}s create <<'EOF' ... EOF) or with -f <file>.`, `invalid_${what}`);
|
|
732
|
-
if (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
733
|
-
for (const k of READ_ONLY)
|
|
734
|
-
delete raw[k];
|
|
735
|
-
return raw;
|
|
736
|
-
}
|
|
737
|
-
/** YAML or JSON from -f <file> or stdin, parsed. */
|
|
738
|
-
async function readText(values, what, usage, invalid = 'invalid_argument') {
|
|
739
|
-
let text = '';
|
|
740
|
-
if (values.file) {
|
|
741
|
-
if (!fs.existsSync(values.file))
|
|
742
|
-
fail('missing_argument', `There is no file ${values.file}.`, `Give the ${what} as YAML or JSON with -f <file>, or on stdin.`);
|
|
743
|
-
text = fs.readFileSync(values.file, 'utf8');
|
|
744
|
-
}
|
|
745
|
-
else if (!process.stdin.isTTY) {
|
|
746
|
-
// A stream read: readFileSync(0) fails with EAGAIN on a pipe whose writer is still running.
|
|
747
|
-
const chunks = [];
|
|
748
|
-
for await (const chunk of process.stdin)
|
|
749
|
-
chunks.push(chunk);
|
|
750
|
-
text = Buffer.concat(chunks).toString('utf8');
|
|
751
|
-
}
|
|
752
|
-
else
|
|
753
|
-
fail('missing_argument', `No ${what} given.`, usage);
|
|
754
|
-
if (!text.trim())
|
|
755
|
-
fail('missing_argument', `No ${what} given: the input is empty.`, usage);
|
|
756
|
-
let raw;
|
|
757
|
-
try {
|
|
758
|
-
raw = YAML.parse(text);
|
|
759
|
-
}
|
|
760
|
-
catch (e) {
|
|
761
|
-
fail(invalid, `${what}: not valid YAML or JSON: ${e.message.split('\n')[0]}`, 'Fix the syntax and run the command again.');
|
|
762
|
-
}
|
|
763
|
-
return raw;
|
|
764
|
-
}
|
|
765
|
-
/** A promotion spec, validated against store.yml's markets and products. */
|
|
766
|
-
async function readSpec(values) {
|
|
767
|
-
const raw = await readInput(values, 'promotion');
|
|
768
|
-
const store = loadStore();
|
|
769
|
-
return checkPromotion(raw, store.markets, store.products);
|
|
770
|
-
}
|
|
771
|
-
/** Warn when more promotion actions without a coupon are live than Commerce Layer applies. */
|
|
772
|
-
async function activeWarning(api) {
|
|
773
|
-
const { promotions } = await api.listPromotions();
|
|
774
|
-
const live = promotions.filter((p) => p.status === 'active' || p.status === 'scheduled');
|
|
775
|
-
const active = activeWithoutCoupon(live);
|
|
776
|
-
return active.count > ACTIVE_LIMIT ? [`${active.count} promotions without a coupon are active or scheduled (one per action, in ${active.ids.join(', ')}): Commerce Layer applies at most ${ACTIVE_LIMIT}, the ones with the lowest priority number. Give the rest a coupon, an end date, or disable them.`] : undefined;
|
|
777
|
-
}
|
|
778
|
-
const checkNext = (p) => p.rules?.coupons?.length ? `Check it: c11r orders test --code ${p.rules.coupons[0]}, and read discounts.` : p.flex ? undefined : 'Check it: c11r orders test with an order that matches the rules (--sku, --quantity), and read discounts.';
|
|
779
|
-
export async function promotionsList() {
|
|
780
|
-
const api = createApi(requireCredentials());
|
|
781
|
-
const { promotions } = await api.listPromotions();
|
|
782
|
-
return { count: promotions.length, promotions, warnings: await activeWarning(api) };
|
|
783
|
-
}
|
|
784
|
-
export async function promotionsShow(id) {
|
|
785
|
-
if (!id)
|
|
786
|
-
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions show <id>`; `c11r promotions list` has the ids.');
|
|
787
|
-
return createApi(requireCredentials()).getPromotion(id);
|
|
788
|
-
}
|
|
789
|
-
export async function promotionsCreate(values) {
|
|
790
|
-
const api = createApi(requireCredentials());
|
|
791
|
-
const spec = await readSpec(values);
|
|
792
|
-
const created = await api.createPromotion(spec);
|
|
793
|
-
return { status: 'created', promotion: created, warnings: await activeWarning(api), next: checkNext(created) };
|
|
794
|
-
}
|
|
795
|
-
export async function promotionsUpdate(id, values) {
|
|
796
|
-
if (!id)
|
|
797
|
-
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions update <id>` with the whole promotion on stdin or -f <file>.');
|
|
798
|
-
const api = createApi(requireCredentials());
|
|
799
|
-
// A promotion made outside c11r has Commerce Layer's id, which is no spec id: say what it is first.
|
|
800
|
-
if ((await api.getPromotion(id)).managed === false)
|
|
801
|
-
fail('promotion_not_managed', `Promotion ${id} was made outside c11r, in the dashboard.`, 'Create a c11r promotion with `c11r promotions create` and delete this one with `c11r promotions delete`, or edit it in the dashboard.');
|
|
802
|
-
const spec = await readSpec(values);
|
|
803
|
-
if (spec.id !== id)
|
|
804
|
-
fail('invalid_promotion', `id: the spec has id ${spec.id}, the command ${id}.`, 'An id never changes: use the same id, or create a new promotion and delete the old one.');
|
|
805
|
-
const updated = await api.updatePromotion(id, spec);
|
|
806
|
-
return { status: 'updated', promotion: updated, warnings: await activeWarning(api), next: checkNext(updated) };
|
|
807
|
-
}
|
|
808
|
-
export async function promotionsEnable(id, enabled) {
|
|
809
|
-
if (!id)
|
|
810
|
-
fail('missing_argument', 'Which promotion?', `Use \`c11r promotions ${enabled ? 'enable' : 'disable'} <id>\`.`);
|
|
811
|
-
const api = createApi(requireCredentials());
|
|
812
|
-
const p = await api.setPromotionEnabled(id, enabled);
|
|
813
|
-
return { status: enabled ? 'enabled' : 'disabled', promotion: p, warnings: enabled ? await activeWarning(api) : undefined };
|
|
814
|
-
}
|
|
815
|
-
export async function promotionsDelete(id) {
|
|
816
|
-
if (!id)
|
|
817
|
-
fail('missing_argument', 'Which promotion?', 'Use `c11r promotions delete <id>`, after asking the human.');
|
|
818
|
-
await createApi(requireCredentials()).deletePromotion(id);
|
|
819
|
-
return { status: 'deleted', id };
|
|
820
|
-
}
|
|
821
|
-
// ---------- links: live settings in Commerce Layer, not store files
|
|
822
|
-
/** A link as list, show and the writes print it: url and urls first. */
|
|
823
|
-
const urlFirst = ({ url, urls, ...l }) => ({ url, urls, ...l });
|
|
824
|
-
const linkNext = (l) => {
|
|
825
|
-
const what = 'order' in l.for ? 'the hosted checkout for the order' : 'a microstore for what it sells';
|
|
826
|
-
const which = Object.keys(l.urls ?? {}).length > 1 ? `the url of each market in urls (url is ${Object.keys(l.urls)[0]}'s)` : 'the url';
|
|
827
|
-
if (l.status === 'pending')
|
|
828
|
-
return `Give the human ${which}: it opens ${what} from ${l.starts}; until then it shows a not-found page.`;
|
|
829
|
-
if (l.status === 'active')
|
|
830
|
-
return `Give the human ${which}: it opens ${what} until ${l.ends}. Payment is manual (wire transfer) unless a gateway is added.`;
|
|
831
|
-
return undefined;
|
|
832
|
-
};
|
|
833
|
-
export async function linksList() {
|
|
834
|
-
const { links } = await createApi(requireCredentials()).listLinks();
|
|
835
|
-
return { count: links.length, links: links.map(urlFirst) };
|
|
836
|
-
}
|
|
837
|
-
export async function linksShow(id) {
|
|
838
|
-
if (!id)
|
|
839
|
-
fail('missing_argument', 'Which link?', 'Use `c11r links show <id>`; `c11r links list` has the ids.');
|
|
840
|
-
return urlFirst(await createApi(requireCredentials()).getLink(id));
|
|
841
|
-
}
|
|
842
|
-
export async function linksCreate(values) {
|
|
843
|
-
const api = createApi(requireCredentials());
|
|
844
|
-
const raw = await readInput(values, 'link');
|
|
845
|
-
const store = loadStore();
|
|
846
|
-
const created = await api.createLink(checkLink(raw, store.markets, store.products));
|
|
847
|
-
return { status: 'created', link: urlFirst(created), next: linkNext(created) };
|
|
848
|
-
}
|
|
849
|
-
export async function linksEnable(id, enabled) {
|
|
850
|
-
if (!id)
|
|
851
|
-
fail('missing_argument', 'Which link?', `Use \`c11r links ${enabled ? 'enable' : 'disable'} <id>\`.`);
|
|
852
|
-
const link = await createApi(requireCredentials()).setLinkEnabled(id, enabled);
|
|
853
|
-
return { status: enabled ? 'enabled' : 'disabled', link: urlFirst(link), next: enabled ? linkNext(link) : undefined };
|
|
854
|
-
}
|
|
855
|
-
export async function linksDelete(id) {
|
|
856
|
-
if (!id)
|
|
857
|
-
fail('missing_argument', 'Which link?', 'Use `c11r links delete <id>`, after asking the human.');
|
|
858
|
-
await createApi(requireCredentials()).deleteLink(id);
|
|
859
|
-
return { status: 'deleted', id };
|
|
860
|
-
}
|
|
861
|
-
// ---------- ops: the merchant agent on a schedule, and insights
|
|
862
|
-
/** Pages read at most when a snapshot list is cut (50) and the run needs every order. */
|
|
863
|
-
const MAX_PAGES = 10;
|
|
864
|
-
/** Every order a list query matches, oldest first, page by page (at most MAX_PAGES of 100). */
|
|
865
|
-
async function allOrders(api, query) {
|
|
866
|
-
const out = [];
|
|
867
|
-
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
868
|
-
const r = await api.listMerchantOrders({ ...query, direction: 'asc', limit: '100', page: String(page) });
|
|
869
|
-
out.push(...r.orders);
|
|
870
|
-
if (!r.has_more)
|
|
871
|
-
return { orders: out, truncated: false };
|
|
872
|
-
}
|
|
873
|
-
return { orders: out, truncated: true };
|
|
874
|
-
}
|
|
875
|
-
/**
|
|
876
|
-
* What the loop works on: the API's snapshot (one call, server time), each cut list read in full
|
|
877
|
-
* when the run needs it; on an API without the snapshot, one page of each list.
|
|
878
|
-
*/
|
|
879
|
-
async function opsSnapshot(api, policy, full) {
|
|
880
|
-
try {
|
|
881
|
-
const r = await api.getOps({ stuck_days: String(policy.stuck_days), transit_days: String(policy.transit_days) });
|
|
882
|
-
const placed = full && r.has_more.placed_unapproved ? await allOrders(api, { status: 'placed' }) : { orders: r.orders.placed_unapproved, truncated: r.has_more.placed_unapproved };
|
|
883
|
-
const uncaptured = full && r.has_more.approved_uncaptured ? await allOrders(api, { status: 'approved', payment: 'authorized,partially_authorized' }) : { orders: r.orders.approved_uncaptured, truncated: r.has_more.approved_uncaptured };
|
|
884
|
-
return {
|
|
885
|
-
as_of: r.as_of,
|
|
886
|
-
placed: placed.orders,
|
|
887
|
-
uncaptured: uncaptured.orders,
|
|
888
|
-
awaiting_payment: r.orders.awaiting_payment,
|
|
889
|
-
stuck: r.shipments.stuck.map((x) => ops.stuckView(x, x.since_days, policy)),
|
|
890
|
-
counts: { placed: r.counts.placed_unapproved, uncaptured: r.counts.approved_uncaptured, awaiting_payment: r.counts.awaiting_payment, stuck: r.counts.stuck },
|
|
891
|
-
truncated: placed.truncated || uncaptured.truncated || r.has_more.awaiting_payment || r.has_more.stuck,
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
catch (e) {
|
|
895
|
-
if (!(e instanceof CliError) || e.code !== 'ops_unsupported')
|
|
896
|
-
throw e;
|
|
897
|
-
}
|
|
898
|
-
// An API before the snapshot: one page of 25 of each list, ages by this machine's clock.
|
|
899
|
-
const [placed, uncaptured, shipments] = await Promise.all([
|
|
900
|
-
api.listMerchantOrders({ status: 'placed', limit: '25' }),
|
|
901
|
-
api.listMerchantOrders({ status: 'approved', payment: 'authorized', limit: '25' }),
|
|
902
|
-
api.listShipments({ status: 'picking,packing,ready_to_ship,shipped', limit: '25' }),
|
|
903
|
-
]);
|
|
904
|
-
const stuck = ops.stuckShipments(shipments.shipments, policy);
|
|
905
|
-
const oldest = (l) => [...l].sort((a, b) => Date.parse(a.placed_at) - Date.parse(b.placed_at));
|
|
906
|
-
return {
|
|
907
|
-
as_of: new Date().toISOString(),
|
|
908
|
-
placed: oldest(placed.orders),
|
|
909
|
-
uncaptured: oldest(uncaptured.orders),
|
|
910
|
-
awaiting_payment: [],
|
|
911
|
-
stuck,
|
|
912
|
-
counts: { placed: placed.orders.length, uncaptured: uncaptured.orders.length, awaiting_payment: 0, stuck: stuck.length },
|
|
913
|
-
truncated: [placed.orders, uncaptured.orders, shipments.shipments].some((l) => l.length >= 25),
|
|
914
|
-
};
|
|
915
|
-
}
|
|
916
|
-
function opsPlan(snap, policy) {
|
|
917
|
-
const placed = snap.placed.map((o) => ops.decidePlaced(o, policy));
|
|
918
|
-
const uncaptured = snap.uncaptured.map((o) => ops.decideApproved(o, policy));
|
|
919
|
-
const all = [...placed, ...uncaptured];
|
|
920
|
-
return {
|
|
921
|
-
placed,
|
|
922
|
-
actions: all.filter((d) => d.action !== 'hold' && d.action !== 'wait'),
|
|
923
|
-
held: all.filter((d) => d.action === 'hold'),
|
|
924
|
-
waiting: all.filter((d) => d.action === 'wait'),
|
|
925
|
-
stuck: snap.stuck,
|
|
926
|
-
unpaid: snap.awaiting_payment,
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
const VERB = { approve_capture: ['approve and capture', 'approved and captured'], approve: ['approve', 'approved'], capture: ['capture', 'captured'] };
|
|
930
|
-
const heldText = (held) => held.map((d) => `#${d.order.number} (${d.order.total.display}): ${d.reason}`).join('; ');
|
|
931
|
-
const stuckText = (stuck) => stuck.map((s) => `${s.shipment.number} ${s.reason}`).join('; ');
|
|
932
|
-
/** What the actions do, grouped by verb: "approve and capture 3 orders (#1, #2, #3)". */
|
|
933
|
-
function actionsText(actions, past = false) {
|
|
934
|
-
return Object.entries(VERB)
|
|
935
|
-
.map(([k, [now, then]]) => {
|
|
936
|
-
const verb = past ? then : now;
|
|
937
|
-
const these = actions.filter((d) => d.action === k);
|
|
938
|
-
return these.length ? `${verb} ${ops.plural(these.length, 'order')} (${ops.numbers(these)})` : '';
|
|
939
|
-
})
|
|
940
|
-
.filter(Boolean)
|
|
941
|
-
.join(', ');
|
|
942
|
-
}
|
|
943
|
-
/** The rest of a next: what waits for the human, what is held, what is stuck. */
|
|
944
|
-
function forTheHuman(p) {
|
|
945
|
-
const out = [];
|
|
946
|
-
if (p.held.length)
|
|
947
|
-
out.push(`Tell the human ${ops.plural(p.held.length, 'order is', 'orders are')} held for them: ${heldText(p.held)}. If they agree, c11r orders approve <number> --capture.`);
|
|
948
|
-
const approve = p.waiting.filter((d) => p.placed.includes(d));
|
|
949
|
-
const capture = p.waiting.filter((d) => !p.placed.includes(d));
|
|
950
|
-
if (approve.length)
|
|
951
|
-
out.push(`${ops.plural(approve.length, 'placed order waits', 'placed orders wait')} for the human to approve (auto_approve is off): ${ops.numbers(approve)}; c11r orders approve <number> --capture once they say so.`);
|
|
952
|
-
if (capture.length)
|
|
953
|
-
out.push(`${ops.plural(capture.length, 'approved order has', 'approved orders have')} the payment still to take (auto_capture is off): ${ops.numbers(capture)}; c11r orders capture <number> when the human says so.`);
|
|
954
|
-
if (p.unpaid.length)
|
|
955
|
-
out.push(`${ops.plural(p.unpaid.length, 'order has', 'orders have')} no payment yet: ${p.unpaid.map((o) => `#${o.number}`).join(', ')}.`);
|
|
956
|
-
if (p.stuck.length)
|
|
957
|
-
out.push(`${ops.plural(p.stuck.length, 'shipment looks', 'shipments look')} stuck: ${stuckText(p.stuck)}. Tell the human; shipping and cancelling are theirs to decide.`);
|
|
958
|
-
return out;
|
|
959
|
-
}
|
|
960
|
-
const policyView = (p) => ({ auto_approve: p.auto_approve, auto_capture: p.auto_capture, review_above: p.review_above, hold_countries: p.hold_countries, stuck_days: p.stuck_days, transit_days: p.transit_days });
|
|
961
|
-
const brief = (o) => ({ id: o.id, number: o.number, placed_at: o.placed_at, total: o.total, payment_status: o.payment_status });
|
|
962
|
-
export async function opsCheck(values) {
|
|
963
|
-
const policy = ops.loadPolicy();
|
|
964
|
-
const api = createApi(requireCredentials());
|
|
965
|
-
const cursor = ops.readCursor();
|
|
966
|
-
const snap = await opsSnapshot(api, policy, false);
|
|
967
|
-
const now = Date.parse(snap.as_of);
|
|
968
|
-
const since = values.since === undefined ? undefined : ops.parseSince(values.since, 'since', now);
|
|
969
|
-
const p = opsPlan(snap, policy);
|
|
970
|
-
const fresh = p.placed.filter((d) => (since ? Date.parse(d.order.placed_at) >= since.getTime() : !cursor.seen.includes(d.order.id)));
|
|
971
|
-
// --since looks back without moving the cursor; a plain check marks what it reported as seen.
|
|
972
|
-
if (!since)
|
|
973
|
-
ops.writeCursor({ last_run: snap.as_of, seen: [...fresh.map((d) => d.order.id), ...cursor.seen] });
|
|
974
|
-
const parts = [];
|
|
975
|
-
if (p.actions.length)
|
|
976
|
-
parts.push(`Run c11r ops run to ${actionsText(p.actions)}.`);
|
|
977
|
-
parts.push(...forTheHuman(p));
|
|
978
|
-
return {
|
|
979
|
-
checked_at: snap.as_of,
|
|
980
|
-
since: since?.toISOString() ?? cursor.last_run,
|
|
981
|
-
policy: policyView(policy),
|
|
982
|
-
new_orders: fresh,
|
|
983
|
-
actions: p.actions,
|
|
984
|
-
held: p.held,
|
|
985
|
-
waiting: p.waiting,
|
|
986
|
-
awaiting_payment: p.unpaid.length ? p.unpaid.map(brief) : undefined,
|
|
987
|
-
stuck: p.stuck,
|
|
988
|
-
counts: snap.counts,
|
|
989
|
-
truncated: snap.truncated || undefined,
|
|
990
|
-
next: parts.length ? parts.join(' ') : `Nothing to do: ${fresh.length ? ops.plural(fresh.length, 'new order') + ', all handled' : 'no new orders'}, nothing held or stuck.`,
|
|
991
|
-
};
|
|
992
|
-
}
|
|
993
|
-
export async function opsRun(flags) {
|
|
994
|
-
const policy = ops.loadPolicy();
|
|
995
|
-
const api = createApi(requireCredentials());
|
|
996
|
-
const dry = flags.has('--dry-run');
|
|
997
|
-
const cursor = ops.readCursor();
|
|
998
|
-
const snap = await opsSnapshot(api, policy, true);
|
|
999
|
-
const p = opsPlan(snap, policy);
|
|
1000
|
-
const fresh = p.placed.filter((d) => !cursor.seen.includes(d.order.id));
|
|
1001
|
-
const done = [];
|
|
1002
|
-
const failed = [];
|
|
1003
|
-
if (!dry) {
|
|
1004
|
-
// One at a time: a refusal (the human, or another run, moved the order meanwhile) stops nothing else.
|
|
1005
|
-
for (const d of p.actions) {
|
|
1006
|
-
const [action, body] = d.action === 'approve_capture' ? ['approve', { capture: true }] : d.action === 'approve' ? ['approve', {}] : ['capture', {}];
|
|
1007
|
-
try {
|
|
1008
|
-
const o = await api.orderAction(d.order.number, action, body);
|
|
1009
|
-
done.push({ action: d.action, order: { id: o.id, number: o.number, status: o.status, payment_status: o.payment_status, total: o.total } });
|
|
1010
|
-
}
|
|
1011
|
-
catch (e) {
|
|
1012
|
-
if (!(e instanceof CliError) || !['invalid_transition', 'not_found'].includes(e.code))
|
|
1013
|
-
throw e;
|
|
1014
|
-
failed.push({ action: d.action, order: d.order, error: { code: e.code, message: e.message } });
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
ops.writeCursor({ last_run: snap.as_of, seen: [...fresh.map((d) => d.order.id), ...cursor.seen] });
|
|
1018
|
-
}
|
|
1019
|
-
const parts = [];
|
|
1020
|
-
if (dry)
|
|
1021
|
-
parts.push(p.actions.length ? `Dry run: c11r ops run would ${actionsText(p.actions)}. Run it without --dry-run to do it.` : 'Dry run: nothing to approve or capture.');
|
|
1022
|
-
else if (done.length)
|
|
1023
|
-
parts.push(`Done: ${actionsText(p.actions.filter((d) => !failed.some((f) => f.order.id === d.order.id)), true)}. Ship them when they are ready: c11r fulfillment list.`);
|
|
1024
|
-
if (failed.length)
|
|
1025
|
-
parts.push(`${ops.plural(failed.length, 'order was', 'orders were')} refused (see failed): the order changed since; c11r orders show <number> for where it is now.`);
|
|
1026
|
-
parts.push(...forTheHuman(p));
|
|
1027
|
-
return {
|
|
1028
|
-
ran_at: snap.as_of,
|
|
1029
|
-
dry_run: dry || undefined,
|
|
1030
|
-
policy: policyView(policy),
|
|
1031
|
-
new_orders: fresh.length,
|
|
1032
|
-
...(dry ? { would_do: p.actions } : { done, failed: failed.length ? failed : undefined }),
|
|
1033
|
-
held: p.held,
|
|
1034
|
-
waiting: p.waiting,
|
|
1035
|
-
awaiting_payment: p.unpaid.length ? p.unpaid.map(brief) : undefined,
|
|
1036
|
-
stuck: p.stuck,
|
|
1037
|
-
truncated: snap.truncated || undefined,
|
|
1038
|
-
next: parts.length ? parts.join(' ') : 'Nothing to do: nothing to approve or capture, nothing held or stuck.',
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
/** "The last 24 hours", "The last 7 days", "Since 2026-11-01", "From 2026-11-01 to 2026-11-05". */
|
|
1042
|
-
function spanText(raw, until) {
|
|
1043
|
-
if (until)
|
|
1044
|
-
return `From ${raw.trim()} to ${until.trim()}`;
|
|
1045
|
-
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*([hdw])$/i);
|
|
1046
|
-
if (!m)
|
|
1047
|
-
return `Since ${raw.trim()}`;
|
|
1048
|
-
const n = Number(m[1]);
|
|
1049
|
-
const unit = { h: 'hour', d: 'day', w: 'week' }[m[2].toLowerCase()];
|
|
1050
|
-
return n === 1 ? `The last ${unit}` : `The last ${n} ${unit}s`;
|
|
1051
|
-
}
|
|
1052
|
-
const INSIGHTS_BY = ['product', 'day', 'market', 'country', 'channel', 'promotion'];
|
|
1053
|
-
/** The period's numbers from insights (one call), or from one page of the order list on an API without it. */
|
|
1054
|
-
async function periodNumbers(api, since) {
|
|
1055
|
-
try {
|
|
1056
|
-
const r = await api.insights({ since: since.toISOString() });
|
|
1057
|
-
return { orders: r.totals.orders, revenue: r.totals.revenue, refunds: r.totals.refunds, cancellations: r.totals.cancellations, top: r.top_products.map((t) => ({ sku: t.sku, name: t.name, units: t.units, revenue: t.revenue })), partial: r.partial, note: undefined };
|
|
1058
|
-
}
|
|
1059
|
-
catch (e) {
|
|
1060
|
-
if (!(e instanceof CliError) || e.code !== 'insights_unsupported')
|
|
1061
|
-
throw e;
|
|
1062
|
-
}
|
|
1063
|
-
const all = await api.listMerchantOrders({ status: 'all', archived: 'all', limit: '25' });
|
|
1064
|
-
const inWindow = all.orders.filter((o) => Date.parse(o.placed_at) >= since.getTime());
|
|
1065
|
-
const kept = inWindow.filter((o) => o.status !== 'cancelled');
|
|
1066
|
-
return { orders: kept.length, revenue: ops.totalsByCurrency(kept.map((o) => o.total)), refunds: [], cancellations: inWindow.length - kept.length, top: undefined, partial: all.orders.length >= 25, note: 'Top products and refunds need insights, which this API does not report yet.' };
|
|
1067
|
-
}
|
|
1068
|
-
export async function opsDigest(values) {
|
|
1069
|
-
const policy = ops.loadPolicy();
|
|
1070
|
-
const api = createApi(requireCredentials());
|
|
1071
|
-
const raw = values.since ?? '24h';
|
|
1072
|
-
const since = ops.parseSince(raw, 'since');
|
|
1073
|
-
const [n, snap, approved] = await Promise.all([periodNumbers(api, since), opsSnapshot(api, policy, false), api.listMerchantOrders({ status: 'approved', since: since.toISOString(), limit: '1' }).catch(() => null)]);
|
|
1074
|
-
const p = opsPlan(snap, policy);
|
|
1075
|
-
// total is every match; an API before paging gives only the page.
|
|
1076
|
-
const approvedCount = approved ? (approved.total ?? approved.orders.length) : undefined;
|
|
1077
|
-
const at = n.partial ? 'at least ' : '';
|
|
1078
|
-
const s = [];
|
|
1079
|
-
s.push(`${spanText(raw)}: ${at}${ops.plural(n.orders, 'order')}${n.orders ? `, ${ops.moneyText(n.revenue)}` : ''}${n.cancellations ? ` (${n.cancellations} more cancelled)` : ''}.`);
|
|
1080
|
-
if (approvedCount)
|
|
1081
|
-
s.push(`${approvedCount} approved.`);
|
|
1082
|
-
if (n.refunds.length)
|
|
1083
|
-
s.push(`Refunded: ${ops.moneyText(n.refunds)}.`);
|
|
1084
|
-
const waitingApproval = p.waiting.filter((d) => p.placed.includes(d));
|
|
1085
|
-
const toCapture = p.waiting.filter((d) => !p.placed.includes(d));
|
|
1086
|
-
if (p.held.length)
|
|
1087
|
-
s.push(`Held for you: ${heldText(p.held)}.`);
|
|
1088
|
-
if (waitingApproval.length)
|
|
1089
|
-
s.push(`Waiting for your approval: ${ops.numbers(waitingApproval)}.`);
|
|
1090
|
-
if (toCapture.length)
|
|
1091
|
-
s.push(`Approved, payment still to take: ${ops.numbers(toCapture)}.`);
|
|
1092
|
-
if (p.stuck.length)
|
|
1093
|
-
s.push(`Stuck: ${stuckText(p.stuck)}.`);
|
|
1094
|
-
if (n.top?.length)
|
|
1095
|
-
s.push(`Top products: ${n.top.map((t) => `${t.name} (${t.units})`).join(', ')}.`);
|
|
1096
|
-
if (!p.held.length && !waitingApproval.length && !toCapture.length && !p.stuck.length)
|
|
1097
|
-
s.push('Nothing waits for you.');
|
|
1098
|
-
return {
|
|
1099
|
-
since: since.toISOString(),
|
|
1100
|
-
orders: { count: n.orders, approved: approvedCount, cancelled: n.cancellations, partial: n.partial || undefined },
|
|
1101
|
-
revenue: n.revenue,
|
|
1102
|
-
refunds: n.refunds,
|
|
1103
|
-
held: p.held,
|
|
1104
|
-
waiting: p.waiting,
|
|
1105
|
-
stuck: p.stuck,
|
|
1106
|
-
top_products: n.top,
|
|
1107
|
-
notes: n.note ? [n.note] : undefined,
|
|
1108
|
-
summary: s.join(' '),
|
|
1109
|
-
next: 'Give the human the summary as it is (it is written for them); act only on what they answer.',
|
|
1110
|
-
};
|
|
1111
|
-
}
|
|
1112
|
-
/** A summary of insights: totals, refunds and cancellations, channels, top products, then the rows. */
|
|
1113
|
-
function insightsSummary(r, raw, until) {
|
|
1114
|
-
const t = r.totals;
|
|
1115
|
-
const out = [];
|
|
1116
|
-
out.push(`${spanText(raw, until)}: ${r.partial ? 'at least ' : ''}${ops.plural(t.orders, 'order')}, ${ops.plural(t.units, 'unit')}, ${ops.moneyText(t.revenue)}${t.orders ? ` (average ${ops.moneyText(t.average_order)})` : ''}.`);
|
|
1117
|
-
const apart = [t.refunds.length ? `${ops.moneyText(t.refunds)} refunded` : '', t.cancellations ? `${ops.plural(t.cancellations, 'order')} cancelled` : ''].filter(Boolean);
|
|
1118
|
-
if (apart.length)
|
|
1119
|
-
out.push(`Apart from that: ${apart.join(', ')}.`);
|
|
1120
|
-
const ch = r.channel;
|
|
1121
|
-
if (ch && ch.agent + ch.link + ch.web + (ch.message ?? 0) && r.by !== 'channel')
|
|
1122
|
-
out.push(`By channel: ${[['agent', 'by agents'], ['link', 'through links'], ['web', 'on the website'], ['message', 'from emails and messages']].filter(([k]) => ch[k]).map(([k, w]) => `${ch[k]} ${w}`).join(', ')}.`);
|
|
1123
|
-
if (r.top_products?.length && r.by !== 'product')
|
|
1124
|
-
out.push(`Top products: ${r.top_products.map((x) => `${x.name} (${x.units})`).join(', ')}.`);
|
|
1125
|
-
const rows = r.rows.filter((x) => x.orders);
|
|
1126
|
-
if (r.by && rows.length)
|
|
1127
|
-
out.push(`By ${r.by}: ${rows.slice(0, 5).map((x) => `${x.label} ${ops.plural(x.orders, 'order')}, ${ops.plural(x.units, 'unit')}, ${ops.moneyText(x.revenue)}`).join('; ')}${rows.length > 5 ? `; and ${rows.length - 5} more` : ''}.`);
|
|
1128
|
-
return out.join(' ');
|
|
1129
|
-
}
|
|
1130
|
-
export async function insights(values) {
|
|
1131
|
-
const raw = values.since ?? '30d';
|
|
1132
|
-
const since = ops.parseSince(raw, 'since');
|
|
1133
|
-
const until = values.until === undefined ? undefined : ops.parseSince(values.until, 'until');
|
|
1134
|
-
if (until && until <= since)
|
|
1135
|
-
fail('invalid_argument', `--until ${values.until} is not after --since ${raw}.`, 'Give an --until later than --since, or leave it out for now.');
|
|
1136
|
-
const by = values.by;
|
|
1137
|
-
if (by !== undefined && !INSIGHTS_BY.includes(by))
|
|
1138
|
-
fail('invalid_argument', `--by ${by} is not a choice.`, `Use --by with one of ${INSIGHTS_BY.join(', ')}.`);
|
|
1139
|
-
const r = await createApi(requireCredentials()).insights({ since: since.toISOString(), ...(until ? { until: until.toISOString() } : {}), ...(by ? { by } : {}) });
|
|
1140
|
-
return {
|
|
1141
|
-
...r,
|
|
1142
|
-
summary: insightsSummary(r, raw, values.until),
|
|
1143
|
-
next: `Give the human the summary.${by ? ` The rows have the numbers per ${by}.` : ' For a breakdown, --by product, day, market, country, channel or promotion.'}${r.partial ? ' partial: only the 1,000 newest orders of the period are counted; ask for a shorter period for exact numbers.' : ''}`,
|
|
1144
|
-
};
|
|
1145
|
-
}
|
|
1146
|
-
// ---------- inbox: order emails over IMAP
|
|
1147
|
-
/** Run with the mailbox open; it is closed (logged out) whatever happens. */
|
|
1148
|
-
async function withMailbox(run) {
|
|
1149
|
-
const mb = await openMailbox();
|
|
1150
|
-
try {
|
|
1151
|
-
return await run(mb);
|
|
1152
|
-
}
|
|
1153
|
-
finally {
|
|
1154
|
-
await mb.close();
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
const INBOX_LIMIT = (raw) => (raw === undefined ? 20 : Number(LIST_LIMIT(raw)));
|
|
1158
|
-
export async function inboxCheck() {
|
|
1159
|
-
return withMailbox(async (mb) => {
|
|
1160
|
-
const c = await mb.check();
|
|
1161
|
-
return {
|
|
1162
|
-
host: mb.host,
|
|
1163
|
-
user: mb.user,
|
|
1164
|
-
folder: mb.folder,
|
|
1165
|
-
exists: c.exists,
|
|
1166
|
-
unseen: c.unseen,
|
|
1167
|
-
next: c.unseen ? `${ops.plural(c.unseen, 'message is', 'messages are')} unseen: c11r inbox list --unseen.` : 'Nothing unseen in the inbox.',
|
|
1168
|
-
};
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
export async function inboxList(values, flags) {
|
|
1172
|
-
const limit = INBOX_LIMIT(values.limit);
|
|
1173
|
-
const since = values.since === undefined ? undefined : ops.parseSince(values.since, 'since');
|
|
1174
|
-
return withMailbox(async (mb) => {
|
|
1175
|
-
const list = await mb.list({ unseen: flags.has('--unseen'), since, limit });
|
|
1176
|
-
const cursor = readInboxCursor();
|
|
1177
|
-
const messages = list.map((m) => ({ ...m, new: !cursor.seen.includes(seenKey(m)) }));
|
|
1178
|
-
writeInboxCursor({ ...cursor, last_check: new Date().toISOString() });
|
|
1179
|
-
const fresh = messages.filter((m) => m.new).length;
|
|
1180
|
-
return {
|
|
1181
|
-
folder: mb.folder,
|
|
1182
|
-
count: messages.length,
|
|
1183
|
-
new: fresh,
|
|
1184
|
-
messages,
|
|
1185
|
-
next: fresh
|
|
1186
|
-
? 'Read the senders and subjects. For each new message that may be an order (someone asking to buy items), c11r inbox pull <id> and read it. Leave the others alone; tell the human about questions and quote requests.'
|
|
1187
|
-
: `No new messages${flags.has('--unseen') ? ' unseen' : ''}: nothing to do.`,
|
|
1188
|
-
};
|
|
1189
|
-
});
|
|
1190
|
-
}
|
|
1191
|
-
export async function inboxPull(id, values, flags) {
|
|
1192
|
-
const all = flags.has('--unseen');
|
|
1193
|
-
if (!id && !all)
|
|
1194
|
-
fail('missing_argument', 'Which message?', 'Use `c11r inbox pull <id>` with an id from inbox list, or `c11r inbox pull --unseen` for every unseen message not pulled yet.');
|
|
1195
|
-
const labels = ops.loadPolicy().mail_orders.labels;
|
|
1196
|
-
return withMailbox(async (mb) => {
|
|
1197
|
-
const cursor = readInboxCursor();
|
|
1198
|
-
let ids = id ? [id] : [];
|
|
1199
|
-
if (all)
|
|
1200
|
-
ids = (await mb.list({ unseen: true, limit: INBOX_LIMIT(values.limit) })).filter((m) => !cursor.seen.includes(seenKey(m))).map((m) => m.id);
|
|
1201
|
-
const pulled = [];
|
|
1202
|
-
for (const one of ids) {
|
|
1203
|
-
const got = await mb.fetch(one);
|
|
1204
|
-
if (!got)
|
|
1205
|
-
fail('not_found', `There is no message ${one} in ${mb.folder}.`, 'Run `c11r inbox list` for the ids.');
|
|
1206
|
-
const p = writeMessage(one, await parseMessage(got.raw), got.labels);
|
|
1207
|
-
pulled.push(p);
|
|
1208
|
-
const key = seenKey({ id: one, message_id: p.message_id });
|
|
1209
|
-
cursor.seen = [key, ...cursor.seen.filter((k) => k !== key)];
|
|
1210
|
-
}
|
|
1211
|
-
writeInboxCursor(cursor);
|
|
1212
|
-
const first = pulled[0];
|
|
1213
|
-
return {
|
|
1214
|
-
count: pulled.length,
|
|
1215
|
-
pulled,
|
|
1216
|
-
next: !first
|
|
1217
|
-
? 'Nothing to pull: every unseen message was pulled before.'
|
|
1218
|
-
: `Read ${pulled.length === 1 ? first.body : 'each body.txt'} and the files in attachments (PDFs, sheets, documents: open them yourself). If it is an order, write the request (customer, ship_to, items by sku or by the words used) to a file and run c11r orders create -f <file> --source email:<message_id>; then c11r inbox tag <id> --label ${labels.ordered} --unlabel ${labels.pending} --seen. If something is missing or unclear, ask the human; if it is not an order, leave the email for the human.`,
|
|
1219
|
-
};
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
export async function inboxTag(id, lists, values, flags) {
|
|
1223
|
-
if (!id)
|
|
1224
|
-
fail('missing_argument', 'Which message?', 'Use `c11r inbox tag <id> --label <name> [--unlabel <name>] [--move <folder>] [--seen]`.');
|
|
1225
|
-
const label = lists.label ?? [];
|
|
1226
|
-
const unlabel = lists.unlabel ?? [];
|
|
1227
|
-
for (const l of [...label, ...unlabel]) {
|
|
1228
|
-
if (!l.trim() || l.length > 50)
|
|
1229
|
-
fail('invalid_argument', `"${l}" is not a label name.`, 'A label is a name of 1 to 50 characters.');
|
|
1230
|
-
if (l.startsWith('\\'))
|
|
1231
|
-
fail('invalid_argument', `${l} is an IMAP flag, not a label.`, 'Use --seen to mark the email read; labels are plain names (ordered, pending).');
|
|
1232
|
-
}
|
|
1233
|
-
if (values.move !== undefined && !values.move.trim())
|
|
1234
|
-
fail('invalid_argument', '--move needs a folder.', 'Use --move <folder>, an existing folder of the mailbox.');
|
|
1235
|
-
if (!label.length && !unlabel.length && values.move === undefined && !flags.has('--seen'))
|
|
1236
|
-
fail('missing_argument', 'Nothing to do to the email.', 'Use --label <name>, --unlabel <name>, --move <folder> or --seen (any of them together).');
|
|
1237
|
-
return withMailbox(async (mb) => {
|
|
1238
|
-
const r = await mb.tag(id, { label, unlabel, move: values.move, seen: flags.has('--seen') });
|
|
1239
|
-
return { status: 'tagged', ...r };
|
|
1240
|
-
});
|
|
1241
|
-
}
|