hookwright 1.4.0 → 1.6.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 +196 -19
  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.4.0",
27
+ version: "1.6.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",
@@ -911,7 +911,7 @@ function editableFieldsFor(eventName) {
911
911
  }
912
912
  var editableFields3 = editableFieldsFor(DEFAULT_EVENT);
913
913
  function buildPayload3(ctx) {
914
- const { config, items, phone } = ctx;
914
+ const { config, items, phone, live } = ctx;
915
915
  const eventName = ctx.eventName ?? DEFAULT_EVENT;
916
916
  const now = ctx.now ?? /* @__PURE__ */ new Date();
917
917
  const currency = ctx.currency || config.defaults.currency;
@@ -919,15 +919,55 @@ function buildPayload3(ctx) {
919
919
  const cust = config.customer;
920
920
  const session = crypto3.randomBytes(8).toString("hex");
921
921
  const first = items[0];
922
- const lineItems = items.map(({ product, variant, quantity }) => ({
922
+ const cartLine = ({ product, variant, quantity }) => ({
923
923
  quantity,
924
924
  title: product.title,
925
925
  line_price: money2((variant?.price ?? 0) * quantity).toFixed(2),
926
926
  id: Number(variant?.id ?? product.id),
927
927
  product_id: Number(product.id),
928
928
  image_url: imageForVariant(product, variant)
929
- }));
930
- const cartValue = money2(items.reduce((sum, i) => sum + money2(i.variant?.price ?? 0) * i.quantity, 0));
929
+ });
930
+ const removedLine = ({ product, variant, quantity }) => {
931
+ const unit = money2(variant?.price ?? 0).toFixed(2);
932
+ const line = money2((variant?.price ?? 0) * quantity).toFixed(2);
933
+ const priceSet = (amount) => {
934
+ const value = Number(amount).toFixed(1);
935
+ return {
936
+ shop_money: { amount: value, currency_code: currency },
937
+ presentment_money: { amount: value, currency_code: currency }
938
+ };
939
+ };
940
+ return {
941
+ id: Number(variant?.id ?? product.id),
942
+ properties: null,
943
+ quantity,
944
+ variant_id: Number(variant?.id ?? product.id),
945
+ key: `${variant?.id ?? product.id}:${crypto3.randomBytes(16).toString("hex")}`,
946
+ discounted_price: unit,
947
+ discounts: [],
948
+ gift_card: false,
949
+ grams: 0,
950
+ line_price: line,
951
+ original_line_price: line,
952
+ original_price: unit,
953
+ price: unit,
954
+ product_id: Number(product.id),
955
+ sku: variant?.sku || "",
956
+ taxable: true,
957
+ title: product.title,
958
+ total_discount: "0.00",
959
+ vendor: config.shopify.shop?.name ?? config.shopify.domain,
960
+ discounted_price_set: priceSet(unit),
961
+ line_price_set: priceSet(line),
962
+ original_line_price_set: priceSet(line),
963
+ price_set: priceSet(unit),
964
+ total_discount_set: priceSet(0),
965
+ parent_relationship: null
966
+ };
967
+ };
968
+ const lineItems = items.map(cartLine);
969
+ const valueOf = (list) => money2(list.reduce((sum, i) => sum + money2(i.variant?.price ?? 0) * i.quantity, 0));
970
+ const cartValue = valueOf(items);
931
971
  const customer = {
932
972
  email: cust.email,
933
973
  phone,
@@ -982,14 +1022,16 @@ function buildPayload3(ctx) {
982
1022
  case "addtocart":
983
1023
  case "removefromcart": {
984
1024
  const added = eventName === "addtocart";
1025
+ const removed = added ? [] : items.slice(-1);
1026
+ const remaining = added ? items : items.slice(0, -1);
985
1027
  eventVal = {
986
- line_items: added ? lineItems : [],
987
- cart_value: added ? cartValue : 0,
1028
+ line_items: added ? lineItems : remaining.map(cartLine),
1029
+ cart_value: added ? cartValue : valueOf(remaining),
988
1030
  recent_product_image: added ? lineItems[0]?.image_url ?? null : null,
989
1031
  init_cart: false,
990
- empty_cart: !added,
1032
+ empty_cart: !added && remaining.length === 0,
991
1033
  items_added: added ? lineItems : [],
992
- items_removed: added ? [] : lineItems,
1034
+ items_removed: removed.map(removedLine),
993
1035
  customer,
994
1036
  bik_customer_id: null
995
1037
  };
@@ -997,8 +1039,8 @@ function buildPayload3(ctx) {
997
1039
  }
998
1040
  case "checkout":
999
1041
  eventVal = {
1000
- checkout: cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity }))),
1001
- cart_value: cartValue.toFixed(2),
1042
+ checkout: live?.kind === "checkout" ? live.url : cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity }))),
1043
+ cart_value: live?.kind === "checkout" ? live.cartValue : cartValue.toFixed(2),
1002
1044
  customer,
1003
1045
  bik_customer_id: null
1004
1046
  };
@@ -1006,7 +1048,17 @@ function buildPayload3(ctx) {
1006
1048
  case "orders/create":
1007
1049
  case "orders/updated": {
1008
1050
  const orderNumber = 1e3 + Math.floor(Math.random() * 9e3);
1009
- eventVal = {
1051
+ eventVal = live?.kind === "order" ? {
1052
+ url: live.statusUrl,
1053
+ order_id: live.id,
1054
+ order_number: live.number,
1055
+ order_name: live.name,
1056
+ price: live.price,
1057
+ currency: live.currency ?? currency,
1058
+ order_created_at: live.createdAt ?? now.toISOString(),
1059
+ customer,
1060
+ bik_customer_id: null
1061
+ } : {
1010
1062
  url: `https://${domain}/${crypto3.randomBytes(5).toString("hex")}/orders/${crypto3.randomBytes(16).toString("hex")}/authenticate?key=${crypto3.randomBytes(16).toString("hex")}`,
1011
1063
  order_id: Number(`${Date.now()}`.slice(-13)),
1012
1064
  order_number: orderNumber,
@@ -2430,6 +2482,38 @@ var ShopifyClient = class {
2430
2482
  const { json } = await this.request("/products/count.json");
2431
2483
  return json?.count ?? 0;
2432
2484
  }
2485
+ /**
2486
+ * Abandoned checkouts, newest first. Nitro's checkout webhook carries a real
2487
+ * recovery link, and the cart token inside it is what the receiving side looks
2488
+ * the checkout up by — so it has to come from a checkout that actually exists.
2489
+ */
2490
+ async abandonedCheckouts({ limit = 10 } = {}) {
2491
+ const { json } = await this.request("/checkouts.json", { query: { limit } });
2492
+ return json?.checkouts ?? [];
2493
+ }
2494
+ /** Orders, newest first. */
2495
+ async orders({ limit = 10, status = "any" } = {}) {
2496
+ const { json } = await this.request("/orders.json", { query: { limit, status } });
2497
+ return json?.orders ?? [];
2498
+ }
2499
+ /**
2500
+ * Creates a real order. Receipts and inventory are suppressed: this exists to
2501
+ * give an order webhook something true to point at, not to trade.
2502
+ */
2503
+ async createOrder(order) {
2504
+ const { json } = await this.request("/orders.json", {
2505
+ method: "POST",
2506
+ body: {
2507
+ order: {
2508
+ ...order,
2509
+ send_receipt: false,
2510
+ send_fulfillment_receipt: false,
2511
+ inventory_behaviour: "bypass"
2512
+ }
2513
+ }
2514
+ });
2515
+ return json?.order ?? null;
2516
+ }
2433
2517
  /**
2434
2518
  * Collections, from both endpoints Shopify splits them across: custom
2435
2519
  * (manually curated) and smart (rule based). Either can back a category page.
@@ -2824,7 +2908,7 @@ function loadPayload(file) {
2824
2908
  }
2825
2909
 
2826
2910
  // src/core/build.mjs
2827
- function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName, collection, envelope }) {
2911
+ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName, collection, envelope, live }) {
2828
2912
  const provider = getProvider(providerId);
2829
2913
  const event = eventName ?? provider.defaultEvent;
2830
2914
  const resolvedPhone = phone ?? cfg.customer.phone;
@@ -2836,6 +2920,7 @@ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, p
2836
2920
  eventName: event,
2837
2921
  collection,
2838
2922
  envelope,
2923
+ live,
2839
2924
  phone: parsed.ok ? parsed.value : String(resolvedPhone ?? "")
2840
2925
  });
2841
2926
  const editable = provider.editableFieldsFor ? provider.editableFieldsFor(event) : provider.editableFields;
@@ -2851,7 +2936,7 @@ function applyEdits(payload, fields, edits) {
2851
2936
  }
2852
2937
  return payload;
2853
2938
  }
2854
- async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName, collection, envelope }) {
2939
+ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName, collection, envelope, live }) {
2855
2940
  const provider0 = getProvider(providerId);
2856
2941
  const needsItems = !provider0.selectionFor || ["cart", "product"].includes(provider0.selectionFor(eventName ?? provider0.defaultEvent));
2857
2942
  if (needsItems && !items.length) throw new ConfigError("no products selected");
@@ -2860,7 +2945,7 @@ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], dis
2860
2945
  const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
2861
2946
  if (!parsed.ok) throw new ConfigError(`phone is unusable: ${parsed.reason}`);
2862
2947
  const event = eventName ?? provider.defaultEvent;
2863
- const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event, collection, envelope });
2948
+ const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event, collection, envelope, live });
2864
2949
  const body = JSON.stringify(payload);
2865
2950
  const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls, eventName: event });
2866
2951
  const summary = provider.summarize ? provider.summarize(payload) : {};
@@ -2888,6 +2973,80 @@ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], dis
2888
2973
  return { provider, payload, body, report, file, meta, summary, record };
2889
2974
  }
2890
2975
 
2976
+ // src/shopify/live.mjs
2977
+ var LIVE_EVENTS = {
2978
+ nitro: /* @__PURE__ */ new Set(["checkout", "orders/create", "orders/updated"])
2979
+ };
2980
+ function needsLive(providerId, eventName) {
2981
+ return LIVE_EVENTS[providerId]?.has(eventName) ?? false;
2982
+ }
2983
+ function clientFor(config) {
2984
+ return new ShopifyClient({
2985
+ domain: config.shopify.domain,
2986
+ accessToken: config.shopify.accessToken,
2987
+ apiVersion: config.shopify.apiVersion
2988
+ });
2989
+ }
2990
+ async function liveCheckout({ config, items = [], client = clientFor(config) }) {
2991
+ const checkouts = await client.abandonedCheckouts({ limit: 20 });
2992
+ if (checkouts.length === 0) return null;
2993
+ const wanted = new Set(items.map(({ variant }) => Number(variant?.id)).filter(Boolean));
2994
+ const matching = checkouts.find((c2) => (c2.line_items ?? []).some((li) => wanted.has(Number(li.variant_id))));
2995
+ const checkout = matching ?? checkouts[0];
2996
+ return {
2997
+ kind: "checkout",
2998
+ url: checkout.abandoned_checkout_url,
2999
+ cartValue: money2(checkout.total_price ?? 0).toFixed(2),
3000
+ cartToken: checkout.cart_token,
3001
+ lineItems: checkout.line_items ?? []
3002
+ };
3003
+ }
3004
+ async function liveOrder({ config, items = [], phone, client = clientFor(config) }) {
3005
+ const cust = config.customer ?? {};
3006
+ const lineItems = items.map(({ variant, product, quantity }) => ({
3007
+ variant_id: Number(variant?.id ?? product?.id),
3008
+ quantity: Number(quantity) || 1
3009
+ })).filter((li) => Number.isFinite(li.variant_id) && li.variant_id > 0);
3010
+ if (lineItems.length === 0) return null;
3011
+ const order = await client.createOrder({
3012
+ line_items: lineItems,
3013
+ financial_status: "paid",
3014
+ email: cust.email || void 0,
3015
+ phone: phone || cust.phone || void 0,
3016
+ customer: cust.email || cust.firstName ? { first_name: cust.firstName, last_name: cust.lastName, email: cust.email } : void 0,
3017
+ shipping_address: cust.address1 ? {
3018
+ first_name: cust.firstName,
3019
+ last_name: cust.lastName,
3020
+ address1: cust.address1,
3021
+ address2: cust.address2 || void 0,
3022
+ city: cust.city,
3023
+ province: cust.province,
3024
+ country_code: cust.countryCode,
3025
+ zip: cust.zip,
3026
+ phone: phone || cust.phone || void 0
3027
+ } : void 0,
3028
+ tags: "hookwright",
3029
+ note: "created by hookwright for webhook testing"
3030
+ });
3031
+ if (!order) return null;
3032
+ return {
3033
+ kind: "order",
3034
+ id: Number(order.id),
3035
+ number: Number(order.order_number),
3036
+ name: order.name,
3037
+ price: String(order.total_price ?? "0.00"),
3038
+ currency: order.currency,
3039
+ createdAt: order.created_at,
3040
+ statusUrl: order.order_status_url
3041
+ };
3042
+ }
3043
+ async function resolveLive({ config, providerId, eventName, items, phone, client }) {
3044
+ if (!needsLive(providerId, eventName)) return null;
3045
+ if (!config?.shopify?.domain || !config?.shopify?.accessToken) return null;
3046
+ if (eventName === "checkout") return await liveCheckout({ config, items, ...client ? { client } : {} });
3047
+ return await liveOrder({ config, items, phone, ...client ? { client } : {} });
3048
+ }
3049
+
2891
3050
  // src/core/send.mjs
2892
3051
  import fs4 from "node:fs";
2893
3052
  import path4 from "node:path";
@@ -3066,6 +3225,7 @@ var STEP = {
3066
3225
  VARIANT: "variant",
3067
3226
  QUANTITY: "quantity",
3068
3227
  DISCOUNT: "discount",
3228
+ LIVE: "live",
3069
3229
  MODE: "mode",
3070
3230
  FIELDS: "fields",
3071
3231
  BUILDING: "building",
@@ -3121,6 +3281,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3121
3281
  setItems(items.slice(0, -1));
3122
3282
  return stepInto(last);
3123
3283
  }
3284
+ case STEP.LIVE:
3124
3285
  case STEP.MODE:
3125
3286
  return setStep(STEP.DISCOUNT);
3126
3287
  case STEP.FIELDS:
@@ -3264,17 +3425,23 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3264
3425
  const nextItems = [...items, { product, variant: pendingVariant, quantity: qty }];
3265
3426
  advance(nextItems, cursor + 1);
3266
3427
  };
3267
- const startReview = (discount, chosenCollection = collection, chosenItems = items) => {
3428
+ const startReview = async (discount, chosenCollection = collection, chosenItems = items) => {
3268
3429
  try {
3430
+ let live = null;
3431
+ if (needsLive(providerId, eventName)) {
3432
+ setStep(STEP.LIVE);
3433
+ live = await resolveLive({ config, providerId, eventName, items: chosenItems, phone: config.customer.phone });
3434
+ }
3269
3435
  const next = draftPayload({
3270
3436
  cfg: config,
3271
3437
  providerId,
3272
3438
  items: chosenItems,
3273
3439
  discount,
3274
3440
  eventName,
3275
- collection: chosenCollection
3441
+ collection: chosenCollection,
3442
+ live
3276
3443
  });
3277
- setDraft({ ...next, discount });
3444
+ setDraft({ ...next, discount, live });
3278
3445
  setStep(STEP.MODE);
3279
3446
  } catch (err) {
3280
3447
  setError({ message: err.message, hint: err.hint, failures: err.failures });
@@ -3284,7 +3451,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3284
3451
  const onDiscount = (raw) => startReview(Math.max(0, Number.parseFloat(raw) || 0));
3285
3452
  const finalise = (payload) => {
3286
3453
  setStep(STEP.BUILDING);
3287
- buildPayload8({ cfg: config, providerId, items, discount: draft.discount, payload, eventName, collection }).then(async (result) => {
3454
+ buildPayload8({ cfg: config, providerId, items, discount: draft.discount, payload, eventName, collection, live: draft.live }).then(async (result) => {
3288
3455
  setBuilt(result);
3289
3456
  let req = null;
3290
3457
  let command = "";
@@ -3358,6 +3525,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3358
3525
  ) })
3359
3526
  ] }),
3360
3527
  step === STEP.LOADING && /* @__PURE__ */ jsx8(Spinner, { label: `fetching live products from ${store}\u2026` }),
3528
+ step === STEP.LIVE && /* @__PURE__ */ jsx8(Spinner, { label: eventName === "checkout" ? `finding a real abandoned checkout in ${store}\u2026` : `creating a real order in ${store}\u2026` }),
3361
3529
  step === STEP.COLLECTION && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3362
3530
  /* @__PURE__ */ jsx8(Text8, { children: "Which collection was viewed?" }),
3363
3531
  /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
@@ -4528,10 +4696,19 @@ async function cmdBuild(args, config) {
4528
4696
  } else {
4529
4697
  log.ok(`${eventName} needs no product or collection`);
4530
4698
  }
4699
+ let live = null;
4700
+ if (needsLive(providerId, eventName)) {
4701
+ log.step(eventName === "checkout" ? `finding a real abandoned checkout in ${config.shopify.domain}\u2026` : `creating a real order in ${config.shopify.domain}\u2026`);
4702
+ live = await resolveLive({ config, providerId, eventName, items, phone: typeof args.flags.phone === "string" ? args.flags.phone : void 0 });
4703
+ if (live?.kind === "checkout") log.ok(`checkout cart token ${live.cartToken}`);
4704
+ else if (live?.kind === "order") log.ok(`order ${live.name} (${live.id})`);
4705
+ else log.warn("no live record available \u2014 falling back to a generated one");
4706
+ }
4531
4707
  const built = await buildPayload8({
4532
4708
  cfg: config,
4533
4709
  providerId,
4534
4710
  eventName,
4711
+ live,
4535
4712
  collection,
4536
4713
  envelope: typeof args.flags.envelope === "string" ? args.flags.envelope : void 0,
4537
4714
  items,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookwright",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",