hookwright 1.5.0 → 1.7.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 +190 -21
  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.5.0",
27
+ version: "1.7.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, delta } = 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;
@@ -1022,16 +1022,18 @@ function buildPayload3(ctx) {
1022
1022
  case "addtocart":
1023
1023
  case "removefromcart": {
1024
1024
  const added = eventName === "addtocart";
1025
- const removed = added ? [] : items.slice(-1);
1026
- const remaining = added ? items : items.slice(0, -1);
1025
+ const fallback = added ? items : items.slice(-1);
1026
+ const moved = delta?.length ? items.filter((item) => delta.includes(item)) : fallback;
1027
+ const untouched = items.filter((item) => !moved.includes(item));
1028
+ const cart = added ? items : untouched;
1027
1029
  eventVal = {
1028
- line_items: added ? lineItems : remaining.map(cartLine),
1029
- cart_value: added ? cartValue : valueOf(remaining),
1030
- recent_product_image: added ? lineItems[0]?.image_url ?? null : null,
1030
+ line_items: cart.map(cartLine),
1031
+ cart_value: valueOf(cart),
1032
+ recent_product_image: added ? moved.map(cartLine)[0]?.image_url ?? null : null,
1031
1033
  init_cart: false,
1032
- empty_cart: !added && remaining.length === 0,
1033
- items_added: added ? lineItems : [],
1034
- items_removed: removed.map(removedLine),
1034
+ empty_cart: !added && cart.length === 0,
1035
+ items_added: added ? moved.map(cartLine) : [],
1036
+ items_removed: added ? [] : moved.map(removedLine),
1035
1037
  customer,
1036
1038
  bik_customer_id: null
1037
1039
  };
@@ -1039,8 +1041,8 @@ function buildPayload3(ctx) {
1039
1041
  }
1040
1042
  case "checkout":
1041
1043
  eventVal = {
1042
- checkout: cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity }))),
1043
- cart_value: cartValue.toFixed(2),
1044
+ checkout: live?.kind === "checkout" ? live.url : cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity }))),
1045
+ cart_value: live?.kind === "checkout" ? live.cartValue : cartValue.toFixed(2),
1044
1046
  customer,
1045
1047
  bik_customer_id: null
1046
1048
  };
@@ -1048,7 +1050,17 @@ function buildPayload3(ctx) {
1048
1050
  case "orders/create":
1049
1051
  case "orders/updated": {
1050
1052
  const orderNumber = 1e3 + Math.floor(Math.random() * 9e3);
1051
- eventVal = {
1053
+ eventVal = live?.kind === "order" ? {
1054
+ url: live.statusUrl,
1055
+ order_id: live.id,
1056
+ order_number: live.number,
1057
+ order_name: live.name,
1058
+ price: live.price,
1059
+ currency: live.currency ?? currency,
1060
+ order_created_at: live.createdAt ?? now.toISOString(),
1061
+ customer,
1062
+ bik_customer_id: null
1063
+ } : {
1052
1064
  url: `https://${domain}/${crypto3.randomBytes(5).toString("hex")}/orders/${crypto3.randomBytes(16).toString("hex")}/authenticate?key=${crypto3.randomBytes(16).toString("hex")}`,
1053
1065
  order_id: Number(`${Date.now()}`.slice(-13)),
1054
1066
  order_number: orderNumber,
@@ -2472,6 +2484,38 @@ var ShopifyClient = class {
2472
2484
  const { json } = await this.request("/products/count.json");
2473
2485
  return json?.count ?? 0;
2474
2486
  }
2487
+ /**
2488
+ * Abandoned checkouts, newest first. Nitro's checkout webhook carries a real
2489
+ * recovery link, and the cart token inside it is what the receiving side looks
2490
+ * the checkout up by — so it has to come from a checkout that actually exists.
2491
+ */
2492
+ async abandonedCheckouts({ limit = 10 } = {}) {
2493
+ const { json } = await this.request("/checkouts.json", { query: { limit } });
2494
+ return json?.checkouts ?? [];
2495
+ }
2496
+ /** Orders, newest first. */
2497
+ async orders({ limit = 10, status = "any" } = {}) {
2498
+ const { json } = await this.request("/orders.json", { query: { limit, status } });
2499
+ return json?.orders ?? [];
2500
+ }
2501
+ /**
2502
+ * Creates a real order. Receipts and inventory are suppressed: this exists to
2503
+ * give an order webhook something true to point at, not to trade.
2504
+ */
2505
+ async createOrder(order) {
2506
+ const { json } = await this.request("/orders.json", {
2507
+ method: "POST",
2508
+ body: {
2509
+ order: {
2510
+ ...order,
2511
+ send_receipt: false,
2512
+ send_fulfillment_receipt: false,
2513
+ inventory_behaviour: "bypass"
2514
+ }
2515
+ }
2516
+ });
2517
+ return json?.order ?? null;
2518
+ }
2475
2519
  /**
2476
2520
  * Collections, from both endpoints Shopify splits them across: custom
2477
2521
  * (manually curated) and smart (rule based). Either can back a category page.
@@ -2866,7 +2910,7 @@ function loadPayload(file) {
2866
2910
  }
2867
2911
 
2868
2912
  // src/core/build.mjs
2869
- function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName, collection, envelope }) {
2913
+ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName, collection, envelope, live, delta }) {
2870
2914
  const provider = getProvider(providerId);
2871
2915
  const event = eventName ?? provider.defaultEvent;
2872
2916
  const resolvedPhone = phone ?? cfg.customer.phone;
@@ -2878,6 +2922,8 @@ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, p
2878
2922
  eventName: event,
2879
2923
  collection,
2880
2924
  envelope,
2925
+ live,
2926
+ delta,
2881
2927
  phone: parsed.ok ? parsed.value : String(resolvedPhone ?? "")
2882
2928
  });
2883
2929
  const editable = provider.editableFieldsFor ? provider.editableFieldsFor(event) : provider.editableFields;
@@ -2893,7 +2939,7 @@ function applyEdits(payload, fields, edits) {
2893
2939
  }
2894
2940
  return payload;
2895
2941
  }
2896
- async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName, collection, envelope }) {
2942
+ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName, collection, envelope, live, delta }) {
2897
2943
  const provider0 = getProvider(providerId);
2898
2944
  const needsItems = !provider0.selectionFor || ["cart", "product"].includes(provider0.selectionFor(eventName ?? provider0.defaultEvent));
2899
2945
  if (needsItems && !items.length) throw new ConfigError("no products selected");
@@ -2902,7 +2948,7 @@ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], dis
2902
2948
  const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
2903
2949
  if (!parsed.ok) throw new ConfigError(`phone is unusable: ${parsed.reason}`);
2904
2950
  const event = eventName ?? provider.defaultEvent;
2905
- const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event, collection, envelope });
2951
+ const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event, collection, envelope, live, delta });
2906
2952
  const body = JSON.stringify(payload);
2907
2953
  const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls, eventName: event });
2908
2954
  const summary = provider.summarize ? provider.summarize(payload) : {};
@@ -2930,6 +2976,80 @@ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], dis
2930
2976
  return { provider, payload, body, report, file, meta, summary, record };
2931
2977
  }
2932
2978
 
2979
+ // src/shopify/live.mjs
2980
+ var LIVE_EVENTS = {
2981
+ nitro: /* @__PURE__ */ new Set(["checkout", "orders/create", "orders/updated"])
2982
+ };
2983
+ function needsLive(providerId, eventName) {
2984
+ return LIVE_EVENTS[providerId]?.has(eventName) ?? false;
2985
+ }
2986
+ function clientFor(config) {
2987
+ return new ShopifyClient({
2988
+ domain: config.shopify.domain,
2989
+ accessToken: config.shopify.accessToken,
2990
+ apiVersion: config.shopify.apiVersion
2991
+ });
2992
+ }
2993
+ async function liveCheckout({ config, items = [], client = clientFor(config) }) {
2994
+ const checkouts = await client.abandonedCheckouts({ limit: 20 });
2995
+ if (checkouts.length === 0) return null;
2996
+ const wanted = new Set(items.map(({ variant }) => Number(variant?.id)).filter(Boolean));
2997
+ const matching = checkouts.find((c2) => (c2.line_items ?? []).some((li) => wanted.has(Number(li.variant_id))));
2998
+ const checkout = matching ?? checkouts[0];
2999
+ return {
3000
+ kind: "checkout",
3001
+ url: checkout.abandoned_checkout_url,
3002
+ cartValue: money2(checkout.total_price ?? 0).toFixed(2),
3003
+ cartToken: checkout.cart_token,
3004
+ lineItems: checkout.line_items ?? []
3005
+ };
3006
+ }
3007
+ async function liveOrder({ config, items = [], phone, client = clientFor(config) }) {
3008
+ const cust = config.customer ?? {};
3009
+ const lineItems = items.map(({ variant, product, quantity }) => ({
3010
+ variant_id: Number(variant?.id ?? product?.id),
3011
+ quantity: Number(quantity) || 1
3012
+ })).filter((li) => Number.isFinite(li.variant_id) && li.variant_id > 0);
3013
+ if (lineItems.length === 0) return null;
3014
+ const order = await client.createOrder({
3015
+ line_items: lineItems,
3016
+ financial_status: "paid",
3017
+ email: cust.email || void 0,
3018
+ phone: phone || cust.phone || void 0,
3019
+ customer: cust.email || cust.firstName ? { first_name: cust.firstName, last_name: cust.lastName, email: cust.email } : void 0,
3020
+ shipping_address: cust.address1 ? {
3021
+ first_name: cust.firstName,
3022
+ last_name: cust.lastName,
3023
+ address1: cust.address1,
3024
+ address2: cust.address2 || void 0,
3025
+ city: cust.city,
3026
+ province: cust.province,
3027
+ country_code: cust.countryCode,
3028
+ zip: cust.zip,
3029
+ phone: phone || cust.phone || void 0
3030
+ } : void 0,
3031
+ tags: "hookwright",
3032
+ note: "created by hookwright for webhook testing"
3033
+ });
3034
+ if (!order) return null;
3035
+ return {
3036
+ kind: "order",
3037
+ id: Number(order.id),
3038
+ number: Number(order.order_number),
3039
+ name: order.name,
3040
+ price: String(order.total_price ?? "0.00"),
3041
+ currency: order.currency,
3042
+ createdAt: order.created_at,
3043
+ statusUrl: order.order_status_url
3044
+ };
3045
+ }
3046
+ async function resolveLive({ config, providerId, eventName, items, phone, client }) {
3047
+ if (!needsLive(providerId, eventName)) return null;
3048
+ 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 } : {} });
3051
+ }
3052
+
2933
3053
  // src/core/send.mjs
2934
3054
  import fs4 from "node:fs";
2935
3055
  import path4 from "node:path";
@@ -3107,7 +3227,9 @@ var STEP = {
3107
3227
  PICK: "pick",
3108
3228
  VARIANT: "variant",
3109
3229
  QUANTITY: "quantity",
3230
+ DELTA: "delta",
3110
3231
  DISCOUNT: "discount",
3232
+ LIVE: "live",
3111
3233
  MODE: "mode",
3112
3234
  FIELDS: "fields",
3113
3235
  BUILDING: "building",
@@ -3130,6 +3252,8 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3130
3252
  const [cursor, setCursor] = useState(0);
3131
3253
  const [items, setItems] = useState([]);
3132
3254
  const [pendingVariant, setPendingVariant] = useState(null);
3255
+ const [delta, setDelta] = useState([]);
3256
+ const isDeltaEvent = providerId === "nitro" && (eventName === "addtocart" || eventName === "removefromcart");
3133
3257
  const [draft, setDraft] = useState(null);
3134
3258
  const [fieldIndex, setFieldIndex] = useState(0);
3135
3259
  const [edits, setEdits] = useState({});
@@ -3163,6 +3287,9 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3163
3287
  setItems(items.slice(0, -1));
3164
3288
  return stepInto(last);
3165
3289
  }
3290
+ case STEP.DELTA:
3291
+ return setStep(STEP.PICK);
3292
+ case STEP.LIVE:
3166
3293
  case STEP.MODE:
3167
3294
  return setStep(STEP.DISCOUNT);
3168
3295
  case STEP.FIELDS:
@@ -3250,7 +3377,8 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3250
3377
  const advance = (nextItems, nextCursor) => {
3251
3378
  if (nextCursor >= chosen.length) {
3252
3379
  setItems(nextItems);
3253
- if (selection === "cart") setStep(STEP.DISCOUNT);
3380
+ if (isDeltaEvent) setStep(STEP.DELTA);
3381
+ else if (selection === "cart") setStep(STEP.DISCOUNT);
3254
3382
  else startReview(0, collection, nextItems);
3255
3383
  return;
3256
3384
  }
@@ -3306,27 +3434,40 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3306
3434
  const nextItems = [...items, { product, variant: pendingVariant, quantity: qty }];
3307
3435
  advance(nextItems, cursor + 1);
3308
3436
  };
3309
- const startReview = (discount, chosenCollection = collection, chosenItems = items) => {
3437
+ const startReview = async (discount, chosenCollection = collection, chosenItems = items, chosenDelta = delta) => {
3310
3438
  try {
3439
+ let live = null;
3440
+ if (needsLive(providerId, eventName)) {
3441
+ setStep(STEP.LIVE);
3442
+ live = await resolveLive({ config, providerId, eventName, items: chosenItems, phone: config.customer.phone });
3443
+ }
3311
3444
  const next = draftPayload({
3312
3445
  cfg: config,
3313
3446
  providerId,
3314
3447
  items: chosenItems,
3315
3448
  discount,
3316
3449
  eventName,
3317
- collection: chosenCollection
3450
+ collection: chosenCollection,
3451
+ live,
3452
+ delta: chosenDelta
3318
3453
  });
3319
- setDraft({ ...next, discount });
3454
+ setDraft({ ...next, discount, live, delta: chosenDelta });
3320
3455
  setStep(STEP.MODE);
3321
3456
  } catch (err) {
3322
3457
  setError({ message: err.message, hint: err.hint, failures: err.failures });
3323
3458
  setStep(STEP.ERROR);
3324
3459
  }
3325
3460
  };
3461
+ const onDelta = (ids) => {
3462
+ const picked = items.filter((i) => ids.includes(String(i.variant?.id ?? i.product?.id)));
3463
+ if (!picked.length) return;
3464
+ setDelta(picked);
3465
+ startReview(0, collection, items, picked);
3466
+ };
3326
3467
  const onDiscount = (raw) => startReview(Math.max(0, Number.parseFloat(raw) || 0));
3327
3468
  const finalise = (payload) => {
3328
3469
  setStep(STEP.BUILDING);
3329
- buildPayload8({ cfg: config, providerId, items, discount: draft.discount, payload, eventName, collection }).then(async (result) => {
3470
+ buildPayload8({ cfg: config, providerId, items, discount: draft.discount, payload, eventName, collection, live: draft.live, delta: draft.delta }).then(async (result) => {
3330
3471
  setBuilt(result);
3331
3472
  let req = null;
3332
3473
  let command = "";
@@ -3400,6 +3541,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3400
3541
  ) })
3401
3542
  ] }),
3402
3543
  step === STEP.LOADING && /* @__PURE__ */ jsx8(Spinner, { label: `fetching live products from ${store}\u2026` }),
3544
+ step === STEP.LIVE && /* @__PURE__ */ jsx8(Spinner, { label: eventName === "checkout" ? `finding a real abandoned checkout in ${store}\u2026` : `creating a real order in ${store}\u2026` }),
3403
3545
  step === STEP.COLLECTION && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3404
3546
  /* @__PURE__ */ jsx8(Text8, { children: "Which collection was viewed?" }),
3405
3547
  /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
@@ -3445,6 +3587,24 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3445
3587
  }
3446
3588
  ) })
3447
3589
  ] }),
3590
+ step === STEP.DELTA && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3591
+ /* @__PURE__ */ jsxs8(Text8, { children: [
3592
+ eventName === "addtocart" ? "Which of those lines is the shopper adding now? " : "Which of those lines is the shopper removing? ",
3593
+ /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: "(space toggles \xB7 enter confirms)" })
3594
+ ] }),
3595
+ /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: eventName === "addtocart" ? "The rest were already in the cart." : "The rest stay in the cart." }),
3596
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
3597
+ MultiSelect,
3598
+ {
3599
+ visibleOptionCount: 10,
3600
+ options: items.map((i) => ({
3601
+ label: `${truncate(i.product.title, 40).padEnd(41)} x${String(i.quantity).padEnd(3)} ${money(i.variant?.price, currency)}`,
3602
+ value: String(i.variant?.id ?? i.product?.id)
3603
+ })),
3604
+ onSubmit: onDelta
3605
+ }
3606
+ ) })
3607
+ ] }),
3448
3608
  step === STEP.VARIANT && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3449
3609
  /* @__PURE__ */ jsxs8(Text8, { children: [
3450
3610
  `Variant for `,
@@ -4570,10 +4730,19 @@ async function cmdBuild(args, config) {
4570
4730
  } else {
4571
4731
  log.ok(`${eventName} needs no product or collection`);
4572
4732
  }
4733
+ let live = null;
4734
+ if (needsLive(providerId, eventName)) {
4735
+ log.step(eventName === "checkout" ? `finding a real abandoned checkout in ${config.shopify.domain}\u2026` : `creating a real order in ${config.shopify.domain}\u2026`);
4736
+ live = await resolveLive({ config, providerId, eventName, items, phone: typeof args.flags.phone === "string" ? args.flags.phone : void 0 });
4737
+ if (live?.kind === "checkout") log.ok(`checkout cart token ${live.cartToken}`);
4738
+ else if (live?.kind === "order") log.ok(`order ${live.name} (${live.id})`);
4739
+ else log.warn("no live record available \u2014 falling back to a generated one");
4740
+ }
4573
4741
  const built = await buildPayload8({
4574
4742
  cfg: config,
4575
4743
  providerId,
4576
4744
  eventName,
4745
+ live,
4577
4746
  collection,
4578
4747
  envelope: typeof args.flags.envelope === "string" ? args.flags.envelope : void 0,
4579
4748
  items,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookwright",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",