hookwright 1.7.0 → 1.9.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.
Files changed (2) hide show
  1. package/dist/cli.js +92 -25
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ var init_package = __esm({
24
24
  "package.json"() {
25
25
  package_default = {
26
26
  name: "hookwright",
27
- version: "1.7.0",
27
+ version: "1.9.0",
28
28
  description: "Build real, signed e-commerce webhooks from a live Shopify catalogue \u2014 interactive terminal UI, no backend",
29
29
  keywords: [
30
30
  "webhook",
@@ -163,6 +163,9 @@ var DEFAULT_CONFIG = {
163
163
  shopify: {
164
164
  domain: "",
165
165
  accessToken: "",
166
+ // Storefront API token. Creating a checkout needs one: the admin checkout API
167
+ // was removed, so a fresh checkout can only be minted through the storefront.
168
+ storefrontAccessToken: "",
166
169
  apiVersion: "2024-10",
167
170
  // cached from the last successful connection, for display only
168
171
  shop: null
@@ -1153,7 +1156,13 @@ function selectionFor2(eventName) {
1153
1156
  }
1154
1157
  var COMMON_FIELDS2 = [
1155
1158
  { source: "event_name / eventName", target: "\xABgate\xBB", required: true, note: "the schema matches EITHER spelling" },
1156
- { source: "customer.phone / user_data.phone", target: "phone", required: true, note: "missing phone is rejected on every event" }
1159
+ { source: "customer.phone / user_data.phone", target: "customer.phone", required: true, note: "missing phone is rejected on every event" },
1160
+ { source: "customer.first_name / user_data.firstName", target: "customer.firstName" },
1161
+ { source: "customer.last_name / user_data.lastName", target: "customer.lastName" },
1162
+ { source: "\u2014 (first + last)", target: "customer.fullName", note: "built by the consumer, not sent" },
1163
+ { source: "customer.email / user_data.email", target: "customer.email" },
1164
+ { source: "channel", target: "channel" },
1165
+ { source: "timestamp / created_at", target: "eventTime" }
1157
1166
  ];
1158
1167
  var BROWSING_FIELDS = [
1159
1168
  { source: "uiData.pageUrl / data.page_url", target: "page.url" },
@@ -1167,7 +1176,8 @@ var CART_FIELDS = [
1167
1176
  { source: "line_items[].title", target: "cart.productName", required: true, note: "the first PAID line wins; freebies rank last" },
1168
1177
  { source: "line_items[].product_image", target: "cart.image" },
1169
1178
  { source: "line_items[].price", target: "\u2014 (ranking only)", note: "a zero price marks the line a freebie" },
1170
- { source: "line_items[].quantity", target: "cart.quantity" },
1179
+ { source: "line_items[].quantity", target: "cart.quantity", note: "summed across the cart" },
1180
+ { source: "line_items[]", target: "cart.itemCount", note: "the line count, not the quantity" },
1171
1181
  { source: "total_price", target: "pricing.totalPrice" },
1172
1182
  { source: "subtotal_price", target: "pricing.subtotal" },
1173
1183
  { source: "total_discount", target: "pricing.totalDiscount" },
@@ -1183,7 +1193,9 @@ var FIELD_MAPS2 = {
1183
1193
  { source: "data.product_name", target: "product.name" },
1184
1194
  { source: "data.product_image", target: "product.image" },
1185
1195
  { source: "data.product_url", target: "product.url" },
1186
- { source: "data.product_price", target: "product.price" }
1196
+ { source: "data.product_price", target: "product.price" },
1197
+ { source: "data.product_compare_at_price", target: "product.compareAtPrice" },
1198
+ { source: "data.product_type", target: "product.type" }
1187
1199
  ],
1188
1200
  added_to_cart_ui: [...COMMON_FIELDS2, ...CART_FIELDS],
1189
1201
  checkout_clicked: [...COMMON_FIELDS2, ...CART_FIELDS],
@@ -1194,6 +1206,8 @@ var FIELD_MAPS2 = {
1194
1206
  { source: "order_id", target: "order.id" },
1195
1207
  { source: "order_name", target: "order.name" },
1196
1208
  { source: "payment_mode", target: "order.paymentMode" },
1209
+ { source: "total_shipping", target: "order.totalShipping" },
1210
+ { source: "total_tax", target: "order.totalTax" },
1197
1211
  { source: "total_payable", target: "order.totalPayable" }
1198
1212
  ]
1199
1213
  };
@@ -1241,7 +1255,7 @@ function editableFieldsFor2(eventName) {
1241
1255
  }
1242
1256
  var editableFields4 = editableFieldsFor2(DEFAULT_EVENT2);
1243
1257
  function buildPayload4(ctx) {
1244
- const { config, items, phone, collection } = ctx;
1258
+ const { config, items, phone, collection, live } = ctx;
1245
1259
  const eventName = ctx.eventName ?? DEFAULT_EVENT2;
1246
1260
  const def = eventDef(eventName);
1247
1261
  const now = ctx.now ?? /* @__PURE__ */ new Date();
@@ -1260,7 +1274,7 @@ function buildPayload4(ctx) {
1260
1274
  pageUrl: `https://${domain}/`,
1261
1275
  pageTitle: config.shopify.shop?.name ?? domain,
1262
1276
  landingPageUrl: `https://${domain}/`,
1263
- referrerPageUrl: ""
1277
+ referrerPageUrl: ctx.referrer ?? "https://www.google.com/"
1264
1278
  },
1265
1279
  clientData: { brandUrl: `https://${domain}` }
1266
1280
  },
@@ -1277,7 +1291,7 @@ function buildPayload4(ctx) {
1277
1291
  page_url: isProduct ? `https://${domain}/products/${first?.product?.handle ?? ""}` : `https://${domain}/collections/${collection?.handle ?? "all"}`,
1278
1292
  page_title: isProduct ? first?.product?.title : collection?.title,
1279
1293
  landing_page_url: `https://${domain}/`,
1280
- referrer_page_url: "",
1294
+ referrer_page_url: ctx.referrer ?? "https://www.google.com/",
1281
1295
  ...isProduct ? {
1282
1296
  product_name: first?.product?.title,
1283
1297
  product_image: imageForVariant(first?.product, first?.variant),
@@ -1302,7 +1316,7 @@ function buildPayload4(ctx) {
1302
1316
  const subtotal = money2(lineItems.reduce((sum, li) => sum + Number(li.price) * li.quantity, 0));
1303
1317
  const discount = money2(Math.min(ctx.discount ?? 0, subtotal));
1304
1318
  const total = money2(subtotal - discount);
1305
- const checkoutUrl = cartPermalink(domain, (items ?? []).map(({ variant, quantity }) => ({ variantId: variant?.id, quantity })));
1319
+ const checkoutUrl = live?.kind === "checkout" && live.url ? live.url : cartPermalink(domain, (items ?? []).map(({ variant, quantity }) => ({ variantId: variant?.id, quantity })));
1306
1320
  return {
1307
1321
  event_name: eventName,
1308
1322
  session_id: ctx.sessionId ?? `sf-${now.getTime()}`,
@@ -1324,12 +1338,13 @@ function buildPayload4(ctx) {
1324
1338
  total_discount: discount,
1325
1339
  total_price: total,
1326
1340
  ...eventName === "order_completed" ? {
1327
- order_id: ctx.orderId ?? Math.floor(now.getTime() / 1e3),
1328
- order_name: ctx.orderName ?? `#${String(now.getTime()).slice(-5)}`,
1341
+ // a real order id is what the receiver would look up
1342
+ order_id: live?.kind === "order" ? live.id : ctx.orderId ?? Math.floor(now.getTime() / 1e3),
1343
+ order_name: live?.kind === "order" ? live.name : ctx.orderName ?? `#${String(now.getTime()).slice(-5)}`,
1329
1344
  payment_mode: "prepaid",
1330
1345
  total_shipping: 0,
1331
1346
  total_tax: 0,
1332
- total_payable: total
1347
+ total_payable: live?.kind === "order" ? Number(live.price) : total
1333
1348
  } : {},
1334
1349
  timestamp: now.getTime()
1335
1350
  };
@@ -1479,12 +1494,13 @@ function nationalNumber(raw, regionCode) {
1479
1494
  // src/providers/flexype.mjs
1480
1495
  var GATE_PATH3 = "event_type";
1481
1496
  var GATE_VALUE2 = "ABANDONED_CHECKOUT";
1497
+ var DEFAULT_EVENT3 = GATE_VALUE2;
1482
1498
  var fieldMap5 = [
1483
1499
  { source: "event_type", target: "\xABgate\xBB", required: true, note: "must be ABANDONED_CHECKOUT or the event is dropped" },
1484
1500
  { source: "payload.user.phone_with_dial_code", target: "phone", required: true, note: "falls back to dial_code + phone" },
1485
1501
  { source: "payload.user.first_name", target: "customer.firstName" },
1486
1502
  { source: "payload.user.last_name", target: "customer.lastName" },
1487
- { source: "payload.user.full_name", target: "customer.name" },
1503
+ { source: "payload.user.full_name", target: "customer.fullName", note: "falls back to first + last name" },
1488
1504
  { source: "payload.user.email", target: "customer.email" },
1489
1505
  { source: "payload.checkout_url", target: "checkoutUrl", required: true },
1490
1506
  { source: "payload.session_id", target: "sessionId" },
@@ -1493,7 +1509,8 @@ var fieldMap5 = [
1493
1509
  { source: "payload.cart_items[].image", target: "cart.image" },
1494
1510
  { source: "payload.cart_items[].total_price", target: "pricing.totalPrice", note: "summed across the cart" },
1495
1511
  { source: "payload.cart_items[].total_discount", target: "pricing.totalDiscount", note: "summed across the cart" },
1496
- { source: "payload.cart_items[].quantity", target: "cart.quantity" },
1512
+ { source: "payload.cart_items[].quantity", target: "cart.quantity", note: "summed across the cart" },
1513
+ { source: "payload.cart_items[]", target: "cart.itemCount", note: "the line count, not the quantity" },
1497
1514
  { source: "payload.coupons[0].code", target: "pricing.couponCode" },
1498
1515
  { source: "payload.checkout_context.presentment_currency", target: "pricing.currency" },
1499
1516
  { source: "payload.checkout_context.presentment_country", target: "phone region" },
@@ -1501,6 +1518,9 @@ var fieldMap5 = [
1501
1518
  { source: "payload.metadata.landing_page", target: "attribution.landingPage" },
1502
1519
  { source: "store.shopify_domain", target: "storeUrl" },
1503
1520
  { source: "store.name", target: "store.name" },
1521
+ { source: "store.main_domain", target: "store.mainDomain" },
1522
+ { source: "payload.checkout_context.store_country", target: "store.country" },
1523
+ { source: "payload.metadata.orig_referer", target: "attribution.referrer" },
1504
1524
  { source: "event_id", target: "eventId" },
1505
1525
  { source: "event_time", target: "eventTime" }
1506
1526
  ];
@@ -1521,7 +1541,7 @@ var editableFields5 = [
1521
1541
  { group: "Attribution", path: "payload.metadata.landing_page", label: "Landing page", optional: true }
1522
1542
  ];
1523
1543
  function buildPayload5(ctx) {
1524
- const { config, items, phone } = ctx;
1544
+ const { config, items, phone, live } = ctx;
1525
1545
  const now = ctx.now ?? /* @__PURE__ */ new Date();
1526
1546
  const currency = ctx.currency || config.defaults.currency;
1527
1547
  const domain = config.shopify.domain;
@@ -1541,7 +1561,7 @@ function buildPayload5(ctx) {
1541
1561
  sku: variant?.sku || null
1542
1562
  };
1543
1563
  });
1544
- const checkoutUrl = cartPermalink(domain, (items ?? []).map(({ variant, quantity }) => ({ variantId: variant?.id, quantity })));
1564
+ const checkoutUrl = live?.kind === "checkout" && live.url ? live.url : cartPermalink(domain, (items ?? []).map(({ variant, quantity }) => ({ variantId: variant?.id, quantity })));
1545
1565
  const utm = Object.entries(config.defaults.utm ?? {}).map(([name, value]) => ({ name, value }));
1546
1566
  return {
1547
1567
  event_id: `fx-${now.getTime()}`,
@@ -1576,8 +1596,8 @@ function buildPayload5(ctx) {
1576
1596
  coupons: ctx.couponCode ? [{ code: ctx.couponCode }] : [],
1577
1597
  metadata: {
1578
1598
  landing_page: `https://${domain}/products/${items?.[0]?.product?.handle ?? ""}`,
1579
- orig_referer: "",
1580
- referer_host: "",
1599
+ orig_referer: ctx.referrer ?? "https://www.google.com/",
1600
+ referer_host: "www.google.com",
1581
1601
  utm
1582
1602
  }
1583
1603
  }
@@ -1618,6 +1638,7 @@ var flexype_default = {
1618
1638
  urlKey: "flexype",
1619
1639
  label: "FlexyPe",
1620
1640
  eventType: "flexype_abandoned_checkout",
1641
+ defaultEvent: DEFAULT_EVENT3,
1621
1642
  gate: { path: GATE_PATH3, value: GATE_VALUE2 },
1622
1643
  signatureScheme: "static shared secret in the header (no body signature)",
1623
1644
  signatureHeader: "x-flexype-authorization",
@@ -1644,8 +1665,8 @@ var EVENTS3 = ["return", "exchange"].flatMap(
1644
1665
  subject: requestType === "exchange" ? "the returned product and its replacement" : "the returned product"
1645
1666
  }))
1646
1667
  );
1647
- var DEFAULT_EVENT3 = "return_requested";
1648
- var eventDef2 = (eventName) => EVENTS3.find((e) => e.eventName === eventName) ?? EVENTS3.find((e) => e.eventName === DEFAULT_EVENT3);
1668
+ var DEFAULT_EVENT4 = "return_requested";
1669
+ var eventDef2 = (eventName) => EVENTS3.find((e) => e.eventName === eventName) ?? EVENTS3.find((e) => e.eventName === DEFAULT_EVENT4);
1649
1670
  function eventFromPayload3(payload) {
1650
1671
  const request = payload?.request ?? payload ?? {};
1651
1672
  if (!request.request_type || !request.status) return void 0;
@@ -1700,13 +1721,13 @@ function editableFieldsFor3(eventName) {
1700
1721
  { group: "Pricing", path: p("line_items.0.presentment_price.currency"), label: "Currency" }
1701
1722
  ];
1702
1723
  }
1703
- var editableFields6 = editableFieldsFor3(DEFAULT_EVENT3);
1724
+ var editableFields6 = editableFieldsFor3(DEFAULT_EVENT4);
1704
1725
  function statusBlock(reached, at, comment = null) {
1705
1726
  return { status: reached, comment, created_at: reached ? at : null };
1706
1727
  }
1707
1728
  function buildPayload6(ctx) {
1708
1729
  const { config, items, phone } = ctx;
1709
- const eventName = ctx.eventName ?? DEFAULT_EVENT3;
1730
+ const eventName = ctx.eventName ?? DEFAULT_EVENT4;
1710
1731
  const def = eventDef2(eventName);
1711
1732
  const now = ctx.now ?? /* @__PURE__ */ new Date();
1712
1733
  const currency = ctx.currency || config.defaults.currency;
@@ -1910,7 +1931,7 @@ var return_prime_default = {
1910
1931
  urlKey: "return_prime",
1911
1932
  label: "Return Prime",
1912
1933
  events: EVENTS3,
1913
- defaultEvent: DEFAULT_EVENT3,
1934
+ defaultEvent: DEFAULT_EVENT4,
1914
1935
  eventType: "return_prime_return_requested",
1915
1936
  gate: { path: "request.request_type", value: "return" },
1916
1937
  gateFor: (eventName) => ({ path: "request.status", value: eventDef2(eventName).status }),
@@ -2493,6 +2514,33 @@ var ShopifyClient = class {
2493
2514
  const { json } = await this.request("/checkouts.json", { query: { limit } });
2494
2515
  return json?.checkouts ?? [];
2495
2516
  }
2517
+ /**
2518
+ * Creates a real checkout through the storefront API and returns its recovery
2519
+ * link. The admin checkout API was removed, so this is the only way to mint a
2520
+ * new one; it needs a storefront token, which is separate from the admin one.
2521
+ */
2522
+ async createCheckout({ storefrontAccessToken, lineItems, email }) {
2523
+ if (!storefrontAccessToken) throw new ShopifyError("Storefront access token missing", { hint: "run `hookwright configure` and add it, or the checkout event reuses an existing checkout" });
2524
+ const mutation = `mutation checkoutCreate($input: CheckoutCreateInput!) {
2525
+ checkoutCreate(input: $input) {
2526
+ checkout { id webUrl totalPrice { amount currencyCode } }
2527
+ checkoutUserErrors { field message }
2528
+ }
2529
+ }`;
2530
+ const input = { lineItems: lineItems.map(({ variantId, quantity }) => ({ variantId: `gid://shopify/ProductVariant/${variantId}`, quantity })) };
2531
+ if (email) input.email = email;
2532
+ const res = await fetch(`https://${this.domain}/api/2024-01/graphql.json`, {
2533
+ method: "POST",
2534
+ headers: { "Content-Type": "application/json", "X-Shopify-Storefront-Access-Token": storefrontAccessToken },
2535
+ body: JSON.stringify({ query: mutation, variables: { input } })
2536
+ });
2537
+ const body = await res.json();
2538
+ const errors = body.errors ?? body.data?.checkoutCreate?.checkoutUserErrors;
2539
+ if (errors?.length) throw new ShopifyError(`Shopify rejected the checkout: ${errors.map((e) => e.message).join("; ")}`, { hint: "the storefront token needs unauthenticated_write_checkouts" });
2540
+ const checkout = body.data?.checkoutCreate?.checkout;
2541
+ if (!checkout) throw new ShopifyError("Shopify returned no checkout", { hint: "check the storefront token and api version" });
2542
+ return checkout;
2543
+ }
2496
2544
  /** Orders, newest first. */
2497
2545
  async orders({ limit = 10, status = "any" } = {}) {
2498
2546
  const { json } = await this.request("/orders.json", { query: { limit, status } });
@@ -2978,7 +3026,11 @@ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], dis
2978
3026
 
2979
3027
  // src/shopify/live.mjs
2980
3028
  var LIVE_EVENTS = {
2981
- nitro: /* @__PURE__ */ new Set(["checkout", "orders/create", "orders/updated"])
3029
+ nitro: /* @__PURE__ */ new Set(["checkout", "orders/create", "orders/updated"]),
3030
+ // shopflo's recovery link and order id are looked up the same way
3031
+ shopflo: /* @__PURE__ */ new Set(["checkout_abandoned", "checkout_clicked", "order_completed"]),
3032
+ // flexype sends one event and it is a recovery link
3033
+ flexype: /* @__PURE__ */ new Set(["ABANDONED_CHECKOUT"])
2982
3034
  };
2983
3035
  function needsLive(providerId, eventName) {
2984
3036
  return LIVE_EVENTS[providerId]?.has(eventName) ?? false;
@@ -2991,6 +3043,19 @@ function clientFor(config) {
2991
3043
  });
2992
3044
  }
2993
3045
  async function liveCheckout({ config, items = [], client = clientFor(config) }) {
3046
+ const storefrontAccessToken = config.shopify?.storefrontAccessToken;
3047
+ const lineItems = items.map(({ variant, product, quantity }) => ({ variantId: Number(variant?.id ?? product?.id), quantity: Number(quantity) || 1 })).filter((l) => Number.isFinite(l.variantId) && l.variantId > 0);
3048
+ if (storefrontAccessToken && lineItems.length) {
3049
+ const checkout2 = await client.createCheckout({ storefrontAccessToken, lineItems, email: config.customer?.email || void 0 });
3050
+ return {
3051
+ kind: "checkout",
3052
+ created: true,
3053
+ url: checkout2.webUrl,
3054
+ cartValue: money2(checkout2.totalPrice?.amount ?? 0).toFixed(2),
3055
+ cartToken: /\/checkouts\/([^/?]+)/.exec(checkout2.webUrl ?? "")?.[1],
3056
+ lineItems: []
3057
+ };
3058
+ }
2994
3059
  const checkouts = await client.abandonedCheckouts({ limit: 20 });
2995
3060
  if (checkouts.length === 0) return null;
2996
3061
  const wanted = new Set(items.map(({ variant }) => Number(variant?.id)).filter(Boolean));
@@ -2998,6 +3063,7 @@ async function liveCheckout({ config, items = [], client = clientFor(config) })
2998
3063
  const checkout = matching ?? checkouts[0];
2999
3064
  return {
3000
3065
  kind: "checkout",
3066
+ created: false,
3001
3067
  url: checkout.abandoned_checkout_url,
3002
3068
  cartValue: money2(checkout.total_price ?? 0).toFixed(2),
3003
3069
  cartToken: checkout.cart_token,
@@ -3046,8 +3112,9 @@ async function liveOrder({ config, items = [], phone, client = clientFor(config)
3046
3112
  async function resolveLive({ config, providerId, eventName, items, phone, client }) {
3047
3113
  if (!needsLive(providerId, eventName)) return null;
3048
3114
  if (!config?.shopify?.domain || !config?.shopify?.accessToken) return null;
3049
- if (eventName === "checkout") return await liveCheckout({ config, items, ...client ? { client } : {} });
3050
- return await liveOrder({ config, items, phone, ...client ? { client } : {} });
3115
+ const wantsOrder = eventName === "orders/create" || eventName === "orders/updated" || eventName === "order_completed";
3116
+ if (wantsOrder) return await liveOrder({ config, items, phone, ...client ? { client } : {} });
3117
+ return await liveCheckout({ config, items, ...client ? { client } : {} });
3051
3118
  }
3052
3119
 
3053
3120
  // src/core/send.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookwright",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Build real, signed e-commerce webhooks from a live Shopify catalogue — interactive terminal UI, no backend",
5
5
  "keywords": [
6
6
  "webhook",