hookwright 1.0.0 → 1.1.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 (3) hide show
  1. package/README.md +30 -8
  2. package/dist/cli.js +765 -78
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -38,7 +38,7 @@ hookwright # interactive UI — starts in the setup wizard on first
38
38
  | Setup | asks for |
39
39
  |---|---|
40
40
  | **Shopify** | store domain + Admin API access token (needs `read_products`). Verifies immediately and picks up currency, country, province and contact details from the store record |
41
- | **Webhook** | just the destination URL, plus the signing secret |
41
+ | **Webhook** | one destination URL + signing secret **per integration** — Cashfree, Razorpay Magic and Nitro each get their own |
42
42
  | **Customer** | the shopper the abandoned cart belongs to |
43
43
  | **Defaults** | currency, platform |
44
44
 
@@ -50,14 +50,27 @@ Then **Integrations → Cashfree One Click Checkout** builds the payload:
50
50
  2. Variant and quantity per product
51
51
  3. Cart discount
52
52
  4. Either accept the suggested values, or **review every field** — each one is pre-filled with the generated suggestion, enter accepts it, typing replaces it
53
- 5. Pre-flight report, then send
53
+ 5. Pre-flight report — every check, the full field coverage table, the signed request and the complete payload
54
+ 6. A **ready-to-paste `curl` is copied to your clipboard** automatically, then send
55
+
56
+ ### The curl is built for pasting
57
+
58
+ The command is emitted in the exact shape Postman itself exports — `curl --location --request POST` with unindented `--header` / `--data` lines — and the body is inlined, never `@file`. Drop it straight into **Postman → Import → Raw text**, a colleague's terminal, or a bug report.
59
+
60
+ Single quotes inside product titles (`Levi's 501`) are escaped, and the signature travels with the exact body it signs, so a pasted command verifies precisely as the original request would.
61
+
62
+ Two things keep this honest: a test that executes the generated command verbatim against a receiver doing real HMAC verification, and a test that runs it through **`curl-to-postmanv2`** — the same parser Postman uses for Raw-text import — asserting the URL, method, every header and a byte-identical body all survive.
63
+
64
+ In the report screen: `c` re-copies the curl · `j` copies the raw payload JSON · `p` toggles the full payload view.
54
65
 
55
66
  ## Headless usage
56
67
 
57
68
  Every command works without a TTY, for CI or scripting:
58
69
 
59
70
  ```bash
60
- hookwright build --items 2 # build from the 2 first products
71
+ hookwright build --items 2 # cashfree-occ by default
72
+ hookwright build --provider razorpay-magic --items 2
73
+ hookwright build --provider nitro --event orders/create
61
74
  hookwright build --search "cold brew" # only products matching a title
62
75
  hookwright build --items 3 --discount 250 --send
63
76
  hookwright send --dry-run # print the request + curl, send nothing
@@ -78,6 +91,7 @@ A generated payload is useless if the receiver silently drops it, so every build
78
91
  - **Phone** — parsed to E.164 using the shipping `country_code`, the same way the consumer does.
79
92
  - **Phone allow-list** — refuses to build for a number that isn't an approved test handset, so a live shopper can never be messaged by a test run.
80
93
  - **Product images** — every `image_url` is `HEAD`-checked; unreachable images fail the build.
94
+ - **Endpoint exists** — before a signed payload is handed over, the destination is verified: the hostname must resolve, something must accept a TCP connection on the port, and the path must not 404. A typo'd host is caught here instead of looking like a silent delivery failure. The probe is `OPTIONS`, falling back to `HEAD` — **never a POST and never a body**, so it cannot create data on the receiving system. Timeouts and servers that reject `OPTIONS` warn rather than block. Skip with `--no-endpoint-check`, override with `--force`.
81
95
  - **Field coverage** — a table of every field the consumer reads, what it maps to, and its value. Missing required fields are flagged in red.
82
96
 
83
97
  Sending is also blocked when the target looks like production, unless `--force`.
@@ -94,18 +108,26 @@ Override both locations with `HOOKWRIGHT_HOME`, or point at one config with `--c
94
108
 
95
109
  ## Providers
96
110
 
97
- | provider | signature | headers |
98
- |---|---|---|
99
- | `cashfree-occ` | base64 HMAC-SHA256 over `timestamp + body` | `x-webhook-signature`, `x-webhook-timestamp` |
111
+ | provider | signature | headers | events |
112
+ |---|---|---|---|
113
+ | `cashfree-occ` | base64 HMAC-SHA256 over `timestamp + body` | `x-webhook-signature`, `x-webhook-timestamp` | abandoned checkout |
114
+ | `razorpay-magic` | hex HMAC-SHA256 over the body | `x-razorpay-signature` | abandoned checkout |
115
+ | `nitro` | static bearer token, no body signature | `authorization` | 8 — page view, category view, product view, add to cart, remove from cart, checkout, order created, order updated |
116
+
117
+ Each provider owns its gate, signature scheme, field map, payload shape and summary, so nothing is assumed across them:
118
+
119
+ - **Cashfree OCC** nests everything under `data`; currency is read from `line_items[0]`, not the root.
120
+ - **Razorpay Magic** is root-level, uses capitalised shipping keys (`Shipping_address.Address1`) and sends `line_items_total` as a **string in paise** — matching [Razorpay's abandoned cart webhook docs](https://razorpay.com/docs/payments/magic-checkout/abandoned-cart/). Its gate is "non-empty `abandoned_checkout_url`" rather than an exact value.
121
+ - **Nitro** keys off `eventName` and requires `eventVal.customer.phone` on *every* event, parsed against root `country`. Choose the event with `--event`, or from the menu in the UI.
100
122
 
101
- Adding one is a single file in `src/providers/` exporting `buildPayload`, `sign`, `webhookUrl`, a `gate` and a `fieldMap` — see [docs/cashfree-occ.md](docs/cashfree-occ.md).
123
+ Adding one is a single file in `src/providers/` exporting `buildPayload`, `sign`, `webhookUrl`, `summarize`, a `gate` and a `fieldMap` — see [docs/cashfree-occ.md](docs/cashfree-occ.md).
102
124
 
103
125
  ## Development
104
126
 
105
127
  ```bash
106
128
  npm install
107
129
  npm run dev # esbuild watch
108
- npm test # 54 tests, no network required
130
+ npm test # 138 tests, no network required
109
131
  npm start # build + run
110
132
  ```
111
133
 
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.0.0",
27
+ version: "1.1.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",
@@ -63,6 +63,7 @@ var init_package = __esm({
63
63
  react: "^19.2.8"
64
64
  },
65
65
  devDependencies: {
66
+ "curl-to-postmanv2": "^1.8.7",
66
67
  esbuild: "^0.28.2"
67
68
  }
68
69
  };
@@ -551,6 +552,20 @@ var editableFields = [
551
552
  { group: "UTM", path: "data.utm_parameters.utm_content", label: "UTM content", optional: true },
552
553
  { group: "UTM", path: "data.utm_parameters.fbclid", label: "Facebook click id", optional: true }
553
554
  ];
555
+ function summarize(payload) {
556
+ const d = payload?.data ?? {};
557
+ const items = Array.isArray(d.line_items) ? d.line_items : [];
558
+ return {
559
+ event: "abandoned checkout",
560
+ store: d.store_url,
561
+ customer: `${d.customer?.shipping_address?.customer_name ?? ""} \xB7 ${d.phone ?? ""}`.trim(),
562
+ items: items.map((i) => `${i.name} x${i.quantity}`).join(", "),
563
+ total: d.total_price,
564
+ currency: items[0]?.currency,
565
+ extra: `was ${d.original_total_price}, discount ${d.total_discount}`,
566
+ link: d.abandoned_checkout_url
567
+ };
568
+ }
554
569
  var cashfree_occ_default = {
555
570
  id: "cashfree-occ",
556
571
  urlKey: "cashfree_occ",
@@ -564,7 +579,461 @@ var cashfree_occ_default = {
564
579
  buildPayload,
565
580
  sign,
566
581
  webhookUrl,
567
- webhookSecret
582
+ webhookSecret,
583
+ summarize
584
+ };
585
+
586
+ // src/providers/razorpay-magic.mjs
587
+ import crypto2 from "node:crypto";
588
+ var GATE_PATH2 = "abandoned_checkout_url";
589
+ var fieldMap2 = [
590
+ { source: "abandoned_checkout_url", target: "\xABgate\xBB + abandonedCheckoutUrl", required: true, note: "must be non-empty or the event is dropped" },
591
+ { source: "created_at", target: "eventTime" },
592
+ { source: "shop_id", target: "storeUrl", required: true, note: "shopify_domain / store_url also accepted" },
593
+ { source: "email", target: "email" },
594
+ { source: "phone", target: "phone", required: true, note: "falls back to Shipping_address.Phone" },
595
+ { source: "currency", target: "pricing.currency", required: true, note: "read from the ROOT, unlike Cashfree" },
596
+ { source: "line_items_total", target: "pricing.totalPrice", required: true, note: "documented as a STRING in paise \u2014 the consumer parseFloats it and divides by 100" },
597
+ { source: "platform", target: "\u2014", note: "documented root field" },
598
+ { source: "utm_parameters", target: "\u2014", note: "documented root field" },
599
+ { source: "customer.first_name", target: "customer.firstName" },
600
+ { source: "customer.last_name", target: "customer.lastName" },
601
+ { source: "customer.email", target: "customer.email" },
602
+ { source: "customer.Shipping_address.Name", target: "customer.name" },
603
+ { source: "customer.Shipping_address.First_name", target: "customer.firstName fallback" },
604
+ { source: "customer.Shipping_address.Last_name", target: "customer.lastName fallback" },
605
+ { source: "customer.Shipping_address.Phone", target: "phone fallback" },
606
+ { source: "customer.Shipping_address.Address1", target: "customer.address1" },
607
+ { source: "customer.Shipping_address.Address2", target: "customer.address2" },
608
+ { source: "customer.Shipping_address.City", target: "customer.city" },
609
+ { source: "customer.Shipping_address.Province", target: "customer.province" },
610
+ { source: "customer.Shipping_address.Country_name", target: "customer.country" },
611
+ { source: "customer.Shipping_address.Country_code", target: "customer.countryCode", required: true, note: "region used to parse the phone" },
612
+ { source: "customer.Shipping_address.Zip", target: "customer.zip" },
613
+ { source: "line_items[0].name", target: "cart.productName", required: true },
614
+ { source: "line_items[0].image_url", target: "cart.image", required: true },
615
+ { source: "line_items[0].quantity", target: "cart.quantity" }
616
+ ];
617
+ var editableFields2 = [
618
+ { group: "Checkout", path: "abandoned_checkout_url", label: "Abandoned checkout URL", hint: "must be non-empty \u2014 this is the schema gate" },
619
+ { group: "Checkout", path: "created_at", label: "Created at" },
620
+ { group: "Checkout", path: "shop_id", label: "Shop domain" },
621
+ { group: "Checkout", path: "cart_token", label: "Cart token" },
622
+ { group: "Pricing", path: "line_items_total", label: "Line items total (paise, string)", hint: "documented as a string; the consumer divides by 100" },
623
+ { group: "Pricing", path: "currency", label: "Currency" },
624
+ { group: "Customer", path: "customer.first_name", label: "First name" },
625
+ { group: "Customer", path: "customer.last_name", label: "Last name" },
626
+ { group: "Customer", path: "email", label: "Email" },
627
+ { group: "Customer", path: "phone", label: "Phone" },
628
+ { group: "Shipping", path: "customer.Shipping_address.Name", label: "Ship-to name" },
629
+ { group: "Shipping", path: "customer.Shipping_address.Address1", label: "Address line 1" },
630
+ { group: "Shipping", path: "customer.Shipping_address.Address2", label: "Address line 2", optional: true },
631
+ { group: "Shipping", path: "customer.Shipping_address.City", label: "City" },
632
+ { group: "Shipping", path: "customer.Shipping_address.Province", label: "Province / state" },
633
+ { group: "Shipping", path: "customer.Shipping_address.Province_code", label: "Province code", optional: true },
634
+ { group: "Shipping", path: "customer.Shipping_address.Country_name", label: "Country" },
635
+ { group: "Shipping", path: "customer.Shipping_address.Country_code", label: "Country code", hint: "region used to parse the phone" },
636
+ { group: "Shipping", path: "customer.Shipping_address.Zip", label: "Zip / postcode" }
637
+ ];
638
+ function buildPayload2(ctx) {
639
+ const { config, items, phone } = ctx;
640
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
641
+ const currency = ctx.currency || config.defaults.currency;
642
+ const domain = config.shopify.domain;
643
+ const cust = config.customer;
644
+ const lineItems = items.map(({ product, variant, quantity }) => {
645
+ const price = money2(variant?.price ?? 0);
646
+ return {
647
+ image_url: imageForVariant(product, variant),
648
+ name: product.title,
649
+ title: product.title,
650
+ price,
651
+ variant_price: price,
652
+ product_id: String(product.id),
653
+ variant_id: variant ? String(variant.id) : null,
654
+ variant_title: variant?.title ?? null,
655
+ quantity,
656
+ sku: variant?.sku || null,
657
+ tax_amount: 0,
658
+ taxable: true,
659
+ store_name: config.shopify.shop?.name ?? domain,
660
+ gram: "0"
661
+ };
662
+ });
663
+ const total = money2(lineItems.reduce((sum, li) => sum + li.price * li.quantity, 0));
664
+ const discount = money2(Math.min(ctx.discount ?? 0, total));
665
+ const net2 = money2(total - discount);
666
+ const checkoutUrl = cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity })));
667
+ return {
668
+ shop_id: domain,
669
+ shopify_domain: domain,
670
+ platform: config.defaults.platform,
671
+ token: crypto2.randomBytes(16).toString("hex"),
672
+ cart_token: crypto2.randomBytes(16).toString("hex"),
673
+ email: cust.email,
674
+ phone,
675
+ abandoned_checkout_url: checkoutUrl,
676
+ currency,
677
+ // Documented as a string, in the smallest currency unit (paise).
678
+ // The consumer parses it with parseFloat and divides by 100.
679
+ line_items_total: String(Math.round(net2 * 100)),
680
+ line_items: lineItems,
681
+ tax_details: {},
682
+ promotions: [],
683
+ utm_parameters: { ...config.defaults.utm },
684
+ // Not in the documented field list, but the consumer reads it for eventTime.
685
+ created_at: now.toISOString(),
686
+ customer: {
687
+ email: cust.email,
688
+ first_name: cust.firstName,
689
+ last_name: cust.lastName,
690
+ created_at: now.toISOString(),
691
+ Shipping_address: {
692
+ Name: `${cust.firstName} ${cust.lastName}`.trim(),
693
+ First_name: cust.firstName,
694
+ Last_name: cust.lastName,
695
+ Phone: phone,
696
+ Address1: cust.address1,
697
+ Address2: cust.address2,
698
+ City: cust.city,
699
+ Province: cust.province,
700
+ Province_code: cust.provinceCode,
701
+ Country: cust.country,
702
+ Country_name: cust.country,
703
+ Country_code: cust.countryCode,
704
+ Zip: cust.zip
705
+ }
706
+ }
707
+ };
708
+ }
709
+ function sign2({ body, secret }) {
710
+ const signature = crypto2.createHmac("sha256", String(secret)).update(body).digest("hex");
711
+ return {
712
+ signature,
713
+ timestamp: null,
714
+ headers: {
715
+ "content-type": "application/json",
716
+ "x-razorpay-signature": signature
717
+ }
718
+ };
719
+ }
720
+ function webhookUrl2(config) {
721
+ return String(targetFor(config, "razorpay-magic").webhookUrl ?? "").trim();
722
+ }
723
+ function webhookSecret2(config) {
724
+ return String(targetFor(config, "razorpay-magic").webhookSecret ?? "");
725
+ }
726
+ function summarize2(payload) {
727
+ const items = Array.isArray(payload?.line_items) ? payload.line_items : [];
728
+ const ship = payload?.customer?.Shipping_address ?? {};
729
+ return {
730
+ event: "abandoned checkout",
731
+ store: payload?.shop_id,
732
+ customer: `${ship.Name ?? ""} \xB7 ${payload?.phone ?? ""}`.trim(),
733
+ items: items.map((i) => `${i.name} x${i.quantity}`).join(", "),
734
+ total: Number.parseFloat(payload?.line_items_total ?? "0") / 100,
735
+ currency: payload?.currency,
736
+ extra: `${payload?.line_items_total} paise`,
737
+ link: payload?.abandoned_checkout_url
738
+ };
739
+ }
740
+ var razorpay_magic_default = {
741
+ id: "razorpay-magic",
742
+ urlKey: "razorpay_magic",
743
+ label: "Razorpay Magic",
744
+ eventType: "razorpay_magic_abandoned_checkout",
745
+ gate: { path: GATE_PATH2, value: void 0, nonEmpty: true },
746
+ signatureScheme: "hex HMAC-SHA256 over the body",
747
+ signatureHeader: "x-razorpay-signature",
748
+ fieldMap: fieldMap2,
749
+ editableFields: editableFields2,
750
+ buildPayload: buildPayload2,
751
+ sign: sign2,
752
+ webhookUrl: webhookUrl2,
753
+ webhookSecret: webhookSecret2,
754
+ summarize: summarize2
755
+ };
756
+
757
+ // src/providers/nitro.mjs
758
+ import crypto3 from "node:crypto";
759
+ var EVENTS = [
760
+ { eventName: "view", type: "nitro_view", label: "Page View" },
761
+ { eventName: "category_view", type: "nitro_category_view", label: "Category View" },
762
+ { eventName: "product_view", type: "nitro_product_view", label: "Product View" },
763
+ { eventName: "addtocart", type: "nitro_addtocart", label: "Add To Cart" },
764
+ { eventName: "removefromcart", type: "nitro_removefromcart", label: "Remove From Cart" },
765
+ { eventName: "checkout", type: "nitro_checkout", label: "Checkout" },
766
+ { eventName: "orders/create", type: "nitro_orders_create", label: "Order Created" },
767
+ { eventName: "orders/updated", type: "nitro_orders_updated", label: "Order Updated" }
768
+ ];
769
+ var DEFAULT_EVENT = "addtocart";
770
+ var COMMON_FIELDS = [
771
+ { source: "eventName", target: "\xABgate\xBB", required: true, note: "must match the schema exactly" },
772
+ { source: "country", target: "phone region", required: true, note: "used to parse eventVal.customer.phone" },
773
+ { source: "eventVal.customer.phone", target: "phone", required: true, note: "missing phone is rejected on every event" }
774
+ ];
775
+ var PAGE_FIELDS = [
776
+ { source: "eventVal.page", target: "eventVal.page" },
777
+ { source: "eventVal.h", target: "eventVal.h", note: "host" },
778
+ { source: "eventVal.l", target: "eventVal.l", note: "location" },
779
+ { source: "eventVal._ss", target: "eventVal._ss", note: "session" }
780
+ ];
781
+ var FIELD_MAPS = {
782
+ view: [...COMMON_FIELDS, ...PAGE_FIELDS],
783
+ category_view: [
784
+ ...COMMON_FIELDS,
785
+ ...PAGE_FIELDS,
786
+ { source: "eventVal.resource_id", target: "eventVal.resource_id", required: true },
787
+ { source: "eventVal.resource", target: "eventVal.resource" },
788
+ { source: "eventVal.domain", target: "eventVal.domain" }
789
+ ],
790
+ product_view: [
791
+ ...COMMON_FIELDS,
792
+ ...PAGE_FIELDS,
793
+ { source: "eventVal.title", target: "eventVal.title", required: true },
794
+ { source: "eventVal.image", target: "eventVal.image", required: true },
795
+ { source: "eventVal.price", target: "eventVal.price", required: true },
796
+ { source: "eventVal.resource_id", target: "eventVal.resource_id", required: true },
797
+ { source: "eventVal.resource", target: "eventVal.resource" },
798
+ { source: "eventVal.domain", target: "eventVal.domain" }
799
+ ],
800
+ addtocart: [
801
+ ...COMMON_FIELDS,
802
+ { source: "eventVal.cart_value", target: "eventVal.cart_value", required: true },
803
+ { source: "eventVal.init_cart", target: "eventVal.init_cart" },
804
+ { source: "eventVal.empty_cart", target: "eventVal.empty_cart" },
805
+ { source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" },
806
+ { source: "eventVal.line_items[0].name", target: "cart enrichment", required: true }
807
+ ],
808
+ removefromcart: [
809
+ ...COMMON_FIELDS,
810
+ { source: "eventVal.cart_value", target: "eventVal.cart_value", required: true },
811
+ { source: "eventVal.init_cart", target: "eventVal.init_cart" },
812
+ { source: "eventVal.empty_cart", target: "eventVal.empty_cart" },
813
+ { source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" },
814
+ { source: "eventVal.line_items[0].name", target: "cart enrichment" }
815
+ ],
816
+ checkout: [
817
+ ...COMMON_FIELDS,
818
+ { source: "eventVal.checkout", target: "eventVal.checkout", required: true },
819
+ { source: "eventVal.cart_value", target: "eventVal.cart_value", required: true },
820
+ { source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" }
821
+ ],
822
+ "orders/create": [
823
+ ...COMMON_FIELDS,
824
+ { source: "eventVal.order_id", target: "eventVal.order_id", required: true },
825
+ { source: "eventVal.order_number", target: "eventVal.order_number" },
826
+ { source: "eventVal.order_name", target: "eventVal.order_name" },
827
+ { source: "eventVal.price", target: "eventVal.price", required: true },
828
+ { source: "eventVal.currency", target: "eventVal.currency", required: true },
829
+ { source: "eventVal.url", target: "eventVal.url" },
830
+ { source: "eventVal.order_created_at", target: "eventVal.order_created_at" },
831
+ { source: "eventVal.bik_customer_id", target: "eventVal.bik_customer_id" }
832
+ ]
833
+ };
834
+ FIELD_MAPS["orders/updated"] = FIELD_MAPS["orders/create"];
835
+ var fieldMap3 = FIELD_MAPS[DEFAULT_EVENT];
836
+ var EDITABLE = {
837
+ common: [
838
+ { group: "Event", path: "eventName", label: "Event name", hint: "the schema gate \u2014 must match exactly" },
839
+ { group: "Event", path: "country", label: "Country code", hint: "region used to parse the phone" },
840
+ { group: "Event", path: "timestamp", label: "Timestamp" },
841
+ { group: "Customer", path: "eventVal.customer.phone", label: "Phone", hint: "required on every Nitro event" },
842
+ { group: "Customer", path: "eventVal.customer.email", label: "Email" },
843
+ { group: "Customer", path: "eventVal.customer.name", label: "Name" }
844
+ ],
845
+ page: [
846
+ { group: "Page", path: "eventVal.page", label: "Page" },
847
+ { group: "Page", path: "eventVal.h", label: "Host" },
848
+ { group: "Page", path: "eventVal.l", label: "Location URL" },
849
+ { group: "Page", path: "eventVal._ss", label: "Session id" }
850
+ ],
851
+ cart: [
852
+ { group: "Cart", path: "eventVal.cart_value", label: "Cart value", type: "number" },
853
+ { group: "Cart", path: "eventVal.init_cart", label: "Init cart" },
854
+ { group: "Cart", path: "eventVal.empty_cart", label: "Empty cart" },
855
+ { group: "Cart", path: "eventVal.bik_customer_id", label: "Bik customer id" }
856
+ ],
857
+ order: [
858
+ { group: "Order", path: "eventVal.order_id", label: "Order id" },
859
+ { group: "Order", path: "eventVal.order_number", label: "Order number" },
860
+ { group: "Order", path: "eventVal.order_name", label: "Order name" },
861
+ { group: "Order", path: "eventVal.price", label: "Price", type: "number" },
862
+ { group: "Order", path: "eventVal.currency", label: "Currency" },
863
+ { group: "Order", path: "eventVal.url", label: "Order URL" }
864
+ ]
865
+ };
866
+ function editableFieldsFor(eventName) {
867
+ const e = EDITABLE;
868
+ if (eventName === "view") return [...e.common, ...e.page];
869
+ if (eventName === "category_view" || eventName === "product_view") return [...e.common, ...e.page];
870
+ if (eventName === "addtocart" || eventName === "removefromcart") return [...e.common, ...e.cart];
871
+ if (eventName === "checkout") return [...e.common, ...e.cart];
872
+ if (eventName.startsWith("orders/")) return [...e.common, ...e.order];
873
+ return e.common;
874
+ }
875
+ var editableFields3 = editableFieldsFor(DEFAULT_EVENT);
876
+ function buildPayload3(ctx) {
877
+ const { config, items, phone } = ctx;
878
+ const eventName = ctx.eventName ?? DEFAULT_EVENT;
879
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
880
+ const currency = ctx.currency || config.defaults.currency;
881
+ const domain = config.shopify.domain;
882
+ const cust = config.customer;
883
+ const session = crypto3.randomBytes(8).toString("hex");
884
+ const first = items[0];
885
+ const lineItems = items.map(({ product, variant, quantity }) => ({
886
+ id: variant ? String(variant.id) : null,
887
+ product_id: String(product.id),
888
+ name: product.title,
889
+ title: product.title,
890
+ quantity,
891
+ price: money2(variant?.price ?? 0),
892
+ image: imageForVariant(product, variant),
893
+ url: `https://${domain}/products/${product.handle ?? ""}`
894
+ }));
895
+ const cartValue = money2(lineItems.reduce((sum, li) => sum + li.price * li.quantity, 0));
896
+ const productUrl = first ? `https://${domain}/products/${first.product.handle ?? ""}` : `https://${domain}`;
897
+ const customer = {
898
+ phone,
899
+ email: cust.email,
900
+ name: `${cust.firstName} ${cust.lastName}`.trim(),
901
+ first_name: cust.firstName,
902
+ last_name: cust.lastName
903
+ };
904
+ const base = {
905
+ org_token: config.defaults.orgToken ?? crypto3.randomBytes(12).toString("hex"),
906
+ eventName,
907
+ userId: crypto3.randomUUID(),
908
+ timestamp: now.toISOString(),
909
+ country: cust.countryCode,
910
+ domain
911
+ };
912
+ const page = {
913
+ page: eventName === "product_view" ? "product" : eventName === "category_view" ? "collection" : "home",
914
+ h: domain,
915
+ l: productUrl,
916
+ _ss: session,
917
+ domain
918
+ };
919
+ let eventVal;
920
+ switch (eventName) {
921
+ case "view":
922
+ eventVal = { ...page, page: "home", l: `https://${domain}`, customer };
923
+ break;
924
+ case "category_view":
925
+ eventVal = {
926
+ ...page,
927
+ resource: "collection",
928
+ resource_id: String(first?.product?.id ?? ""),
929
+ l: `https://${domain}/collections/all`,
930
+ customer
931
+ };
932
+ break;
933
+ case "product_view":
934
+ eventVal = {
935
+ ...page,
936
+ resource: "product",
937
+ resource_id: String(first?.product?.id ?? ""),
938
+ title: first?.product?.title,
939
+ image: first ? imageForVariant(first.product, first.variant) : void 0,
940
+ price: money2(first?.variant?.price ?? 0),
941
+ customer
942
+ };
943
+ break;
944
+ case "addtocart":
945
+ case "removefromcart":
946
+ eventVal = {
947
+ cart_value: cartValue,
948
+ init_cart: eventName === "addtocart",
949
+ empty_cart: eventName === "removefromcart" && items.length === 0,
950
+ bik_customer_id: crypto3.randomUUID(),
951
+ line_items: lineItems,
952
+ [eventName === "addtocart" ? "items_added" : "items_removed"]: lineItems,
953
+ customer
954
+ };
955
+ break;
956
+ case "checkout":
957
+ eventVal = {
958
+ checkout: cartPermalink(domain, items.map(({ variant, quantity }) => ({ variantId: variant?.id, quantity }))),
959
+ cart_value: cartValue,
960
+ bik_customer_id: crypto3.randomUUID(),
961
+ line_items: lineItems,
962
+ customer
963
+ };
964
+ break;
965
+ case "orders/create":
966
+ case "orders/updated": {
967
+ const orderNumber = 1e3 + Math.floor(Math.random() * 9e3);
968
+ eventVal = {
969
+ url: `https://${domain}/admin/orders/${orderNumber}`,
970
+ order_id: String(Date.now()),
971
+ order_number: orderNumber,
972
+ order_name: `#${orderNumber}`,
973
+ price: cartValue,
974
+ currency,
975
+ order_created_at: now.toISOString(),
976
+ bik_customer_id: crypto3.randomUUID(),
977
+ line_items: lineItems,
978
+ customer
979
+ };
980
+ break;
981
+ }
982
+ default:
983
+ eventVal = { customer };
984
+ }
985
+ return { ...base, eventVal };
986
+ }
987
+ function sign3({ secret }) {
988
+ return {
989
+ signature: null,
990
+ timestamp: null,
991
+ headers: {
992
+ "content-type": "application/json",
993
+ authorization: `Bearer ${secret}`
994
+ }
995
+ };
996
+ }
997
+ function webhookUrl3(config) {
998
+ return String(targetFor(config, "nitro").webhookUrl ?? "").trim();
999
+ }
1000
+ function webhookSecret3(config) {
1001
+ return String(targetFor(config, "nitro").webhookSecret ?? "");
1002
+ }
1003
+ function summarize3(payload) {
1004
+ const ev = payload?.eventVal ?? {};
1005
+ const items = Array.isArray(ev.line_items) ? ev.line_items : [];
1006
+ return {
1007
+ event: payload?.eventName,
1008
+ store: payload?.domain,
1009
+ customer: `${ev.customer?.name ?? ""} \xB7 ${ev.customer?.phone ?? ""}`.trim(),
1010
+ items: items.map((i) => `${i.name} x${i.quantity}`).join(", ") || "\u2014",
1011
+ total: ev.cart_value ?? ev.price ?? void 0,
1012
+ currency: ev.currency,
1013
+ extra: ev.order_name ? `order ${ev.order_name}` : ev.checkout ? "checkout started" : "",
1014
+ link: ev.checkout ?? ev.url ?? ev.l
1015
+ };
1016
+ }
1017
+ var nitro_default = {
1018
+ id: "nitro",
1019
+ urlKey: "nitro",
1020
+ label: "Nitro",
1021
+ events: EVENTS,
1022
+ defaultEvent: DEFAULT_EVENT,
1023
+ eventType: "nitro_addtocart",
1024
+ gate: { path: "eventName", value: DEFAULT_EVENT },
1025
+ gateFor: (eventName) => ({ path: "eventName", value: eventName }),
1026
+ signatureScheme: "static bearer token (no body signature)",
1027
+ signatureHeader: "authorization",
1028
+ fieldMap: fieldMap3,
1029
+ fieldMapFor: (eventName) => FIELD_MAPS[eventName] ?? FIELD_MAPS[DEFAULT_EVENT],
1030
+ editableFields: editableFields3,
1031
+ editableFieldsFor,
1032
+ buildPayload: buildPayload3,
1033
+ sign: sign3,
1034
+ webhookUrl: webhookUrl3,
1035
+ webhookSecret: webhookSecret3,
1036
+ summarize: summarize3
568
1037
  };
569
1038
 
570
1039
  // src/providers/index.mjs
@@ -581,18 +1050,21 @@ var PROVIDER_META = [
581
1050
  label: "Razorpay Magic",
582
1051
  signature: "hex HMAC-SHA256 over the body",
583
1052
  header: "x-razorpay-signature",
584
- available: false
1053
+ available: true
585
1054
  },
586
1055
  {
587
1056
  id: "nitro",
588
1057
  label: "Nitro",
589
1058
  signature: "static bearer token",
590
1059
  header: "authorization",
591
- available: false
1060
+ available: true,
1061
+ events: nitro_default.events
592
1062
  }
593
1063
  ];
594
1064
  var providers = {
595
- [cashfree_occ_default.id]: cashfree_occ_default
1065
+ [cashfree_occ_default.id]: cashfree_occ_default,
1066
+ [razorpay_magic_default.id]: razorpay_magic_default,
1067
+ [nitro_default.id]: nitro_default
596
1068
  };
597
1069
  function getProvider(id) {
598
1070
  const provider = providers[id];
@@ -988,6 +1460,113 @@ function setPath(obj, path6, value) {
988
1460
  return obj;
989
1461
  }
990
1462
 
1463
+ // src/core/reachability.mjs
1464
+ import dns from "node:dns/promises";
1465
+ import net from "node:net";
1466
+ function parseEndpoint(url) {
1467
+ const raw = String(url ?? "").trim();
1468
+ if (!raw) return { ok: false, reason: "no webhook URL configured" };
1469
+ let parsed;
1470
+ try {
1471
+ parsed = new URL(raw);
1472
+ } catch {
1473
+ return { ok: false, reason: `"${raw}" is not a valid URL` };
1474
+ }
1475
+ if (!/^https?:$/.test(parsed.protocol)) {
1476
+ return { ok: false, reason: `unsupported protocol "${parsed.protocol}" \u2014 use http or https` };
1477
+ }
1478
+ if (!parsed.hostname) return { ok: false, reason: "URL has no host" };
1479
+ return { ok: true, url: parsed };
1480
+ }
1481
+ async function resolves(hostname) {
1482
+ if (net.isIP(hostname)) return { ok: true, address: hostname };
1483
+ try {
1484
+ const { address } = await dns.lookup(hostname);
1485
+ return { ok: true, address };
1486
+ } catch (err) {
1487
+ if (err.code === "ENOTFOUND" || err.code === "EAI_AGAIN") {
1488
+ return { ok: false, code: err.code, reason: `host "${hostname}" does not resolve` };
1489
+ }
1490
+ return { ok: false, code: err.code, reason: `DNS lookup failed: ${err.message}` };
1491
+ }
1492
+ }
1493
+ function connects(host, port, timeoutMs) {
1494
+ return new Promise((resolve) => {
1495
+ const socket = net.connect({ host, port });
1496
+ const done = (result) => {
1497
+ socket.removeAllListeners();
1498
+ socket.destroy();
1499
+ resolve(result);
1500
+ };
1501
+ socket.setTimeout(timeoutMs);
1502
+ socket.once("connect", () => done({ ok: true }));
1503
+ socket.once("timeout", () => done({ ok: false, timedOut: true }));
1504
+ socket.once("error", (err) => done({ ok: false, code: err.code, message: err.message }));
1505
+ });
1506
+ }
1507
+ async function respond(url, method, timeoutMs) {
1508
+ const controller = new AbortController();
1509
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1510
+ try {
1511
+ const res = await fetch(url, { method, signal: controller.signal, redirect: "manual" });
1512
+ clearTimeout(timer);
1513
+ return { responded: true, status: res.status };
1514
+ } catch (err) {
1515
+ clearTimeout(timer);
1516
+ return {
1517
+ responded: false,
1518
+ aborted: err.name === "AbortError",
1519
+ code: err.cause?.code ?? err.code,
1520
+ message: err.message
1521
+ };
1522
+ }
1523
+ }
1524
+ async function checkEndpointExists(url, { timeoutMs = 8e3 } = {}) {
1525
+ const name = "Endpoint exists";
1526
+ const parsed = parseEndpoint(url);
1527
+ if (!parsed.ok) return { name, ok: false, exists: false, detail: parsed.reason };
1528
+ const target = parsed.url;
1529
+ const dnsResult = await resolves(target.hostname);
1530
+ if (!dnsResult.ok) {
1531
+ return { name, ok: false, exists: false, detail: `${dnsResult.reason} \u2014 check the URL for a typo` };
1532
+ }
1533
+ const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
1534
+ const tcp = await connects(dnsResult.address, port, timeoutMs);
1535
+ if (!tcp.ok && tcp.code === "ECONNREFUSED") {
1536
+ return { name, ok: false, exists: false, detail: `${target.host} refused the connection on port ${port} \u2014 nothing is listening there` };
1537
+ }
1538
+ if (!tcp.ok && (tcp.code === "EHOSTUNREACH" || tcp.code === "ENETUNREACH")) {
1539
+ return { name, ok: false, exists: false, detail: `${target.host} is unreachable (${tcp.code})` };
1540
+ }
1541
+ if (!tcp.ok && tcp.timedOut) {
1542
+ return { name, ok: true, warn: true, exists: void 0, detail: `${target.host}:${port} did not accept a connection within ${timeoutMs}ms \u2014 sending anyway` };
1543
+ }
1544
+ let attempt = await respond(target, "OPTIONS", timeoutMs);
1545
+ if (!attempt.responded) attempt = await respond(target, "HEAD", timeoutMs);
1546
+ if (attempt.responded) {
1547
+ if (attempt.status === 404) {
1548
+ return {
1549
+ name,
1550
+ ok: false,
1551
+ exists: true,
1552
+ detail: `${target.host} is reachable but returned 404 for ${target.pathname} \u2014 the host is right, the path is not`
1553
+ };
1554
+ }
1555
+ return { name, ok: true, exists: true, detail: `${target.host} responded (HTTP ${attempt.status}) at ${target.pathname}` };
1556
+ }
1557
+ if (attempt.aborted) {
1558
+ return { name, ok: true, warn: true, exists: void 0, detail: `${target.host} did not answer within ${timeoutMs}ms \u2014 sending anyway` };
1559
+ }
1560
+ const code = attempt.code;
1561
+ if (code === "ENOTFOUND") {
1562
+ return { name, ok: false, exists: false, detail: `${target.host} does not resolve` };
1563
+ }
1564
+ if (code === "CERT_HAS_EXPIRED" || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || code === "DEPTH_ZERO_SELF_SIGNED_CERT") {
1565
+ return { name, ok: true, warn: true, exists: true, detail: `${target.host} is up but its TLS certificate is not trusted (${code})` };
1566
+ }
1567
+ return { name, ok: true, warn: true, exists: void 0, detail: `could not confirm ${target.host} (${code ?? attempt.message}) \u2014 sending anyway` };
1568
+ }
1569
+
991
1570
  // src/core/validate.mjs
992
1571
  function checkRoundTrip(body) {
993
1572
  let reparsed;
@@ -1006,20 +1585,45 @@ function checkRoundTrip(body) {
1006
1585
  detail: `re-serialized body differs at offset ${at.index}: sent ${JSON.stringify(at.a)} vs recomputed ${JSON.stringify(at.b)}`
1007
1586
  };
1008
1587
  }
1009
- function checkGate(payload, provider) {
1010
- const actual = getPath(payload, provider.gate.path);
1011
- const ok = actual === provider.gate.value;
1588
+ function checkGate(payload, provider, eventName) {
1589
+ const gate = eventName && provider.gateFor ? provider.gateFor(eventName) : provider.gate;
1590
+ const actual = getPath(payload, gate.path);
1591
+ if (gate.nonEmpty) {
1592
+ const ok2 = Boolean(actual);
1593
+ return {
1594
+ name: "Schema gate",
1595
+ ok: ok2,
1596
+ detail: ok2 ? `${gate.path} is set` : `${gate.path} is empty \u2014 the schema filter requires a non-empty value, so the event would be silently ignored`
1597
+ };
1598
+ }
1599
+ const ok = actual === gate.value;
1012
1600
  return {
1013
1601
  name: "Schema gate",
1014
1602
  ok,
1015
- detail: ok ? `${provider.gate.path} = "${actual}"` : `${provider.gate.path} is ${JSON.stringify(actual)} but the schema filter requires "${provider.gate.value}" \u2014 the event would be silently ignored`
1603
+ detail: ok ? `${gate.path} = "${actual}"` : `${gate.path} is ${JSON.stringify(actual)} but the schema filter requires "${gate.value}" \u2014 the event would be silently ignored`
1016
1604
  };
1017
1605
  }
1606
+ function locatePhone(payload) {
1607
+ const candidates = [
1608
+ // cashfree-occ
1609
+ ["data.phone", "data.customer.shipping_address.country_code"],
1610
+ ["data.customer.shipping_address.phone", "data.customer.shipping_address.country_code"],
1611
+ // razorpay-magic
1612
+ ["phone", "customer.Shipping_address.Country_code"],
1613
+ ["customer.Shipping_address.Phone", "customer.Shipping_address.Country_code"],
1614
+ // nitro
1615
+ ["eventVal.customer.phone", "country"]
1616
+ ];
1617
+ for (const [phonePath, regionPath] of candidates) {
1618
+ const phone = getPath(payload, phonePath);
1619
+ if (phone) return { phone, region: getPath(payload, regionPath) };
1620
+ }
1621
+ return { phone: void 0, region: void 0 };
1622
+ }
1018
1623
  function checkPhone(payload) {
1019
- const phone = getPath(payload, "data.phone") ?? getPath(payload, "data.customer.shipping_address.phone");
1020
- const region = getPath(payload, "data.customer.shipping_address.country_code");
1624
+ const { phone, region } = locatePhone(payload);
1021
1625
  if (!phone) {
1022
- return { name: "Phone", ok: false, detail: "no phone in data.phone or shipping_address.phone \u2014 consumer throws BadPayloadError" };
1626
+ return { name: "Phone", ok: false, detail: "no phone found in the payload \u2014 the consumer throws BadPayloadError" };
1023
1627
  }
1024
1628
  const parsed = toE164(phone, region);
1025
1629
  return {
@@ -1033,8 +1637,8 @@ function checkAllowedPhone(payload, config) {
1033
1637
  if (!allowed.length) {
1034
1638
  return { name: "Phone allow-list", ok: true, detail: "no allow-list configured (skipped)", warn: true };
1035
1639
  }
1036
- const phone = getPath(payload, "data.phone");
1037
- const parsed = toE164(phone, getPath(payload, "data.customer.shipping_address.country_code"));
1640
+ const { phone, region } = locatePhone(payload);
1641
+ const parsed = toE164(phone, region);
1038
1642
  const normalized = parsed.ok ? parsed.value : phone;
1039
1643
  const ok = allowed.some((p) => toE164(p, "IN").value === normalized || p === normalized);
1040
1644
  return {
@@ -1043,8 +1647,8 @@ function checkAllowedPhone(payload, config) {
1043
1647
  detail: ok ? `${normalized} is an approved test handset` : `${normalized} is NOT in allowedPhones \u2014 refusing to risk messaging a real shopper`
1044
1648
  };
1045
1649
  }
1046
- function coverage(payload, fieldMap2) {
1047
- return fieldMap2.map((f) => {
1650
+ function coverage(payload, fieldMap4) {
1651
+ return fieldMap4.map((f) => {
1048
1652
  const value = getPath(payload, f.source);
1049
1653
  const present = value !== void 0 && value !== null && value !== "";
1050
1654
  return {
@@ -1058,10 +1662,10 @@ function coverage(payload, fieldMap2) {
1058
1662
  });
1059
1663
  }
1060
1664
  async function checkImages(payload, { timeoutMs = 1e4 } = {}) {
1061
- const items = getPath(payload, "data.line_items") ?? [];
1062
- const urls = [...new Set(items.map((i) => i?.image_url).filter(Boolean))];
1665
+ const items = getPath(payload, "data.line_items") ?? getPath(payload, "line_items") ?? getPath(payload, "eventVal.line_items") ?? [];
1666
+ const urls = [...new Set(items.map((i) => i?.image_url ?? i?.image).filter(Boolean))];
1063
1667
  if (!urls.length) {
1064
- return [{ name: "Product images", ok: false, detail: "no line item carries an image_url \u2014 cart.image resolves to undefined" }];
1668
+ return [{ name: "Product images", ok: true, warn: true, detail: "no line item carries an image \u2014 cart.image will be undefined" }];
1065
1669
  }
1066
1670
  const results = await Promise.all(
1067
1671
  urls.map(async (url) => {
@@ -1083,10 +1687,12 @@ async function checkImages(payload, { timeoutMs = 1e4 } = {}) {
1083
1687
  detail: `${r.ok ? "HTTP " + r.status : "unreachable (" + (r.error ?? r.status) + ")"} \u2014 ${shorten(r.url)}`
1084
1688
  }));
1085
1689
  }
1086
- async function runAll({ payload, body, provider, config, checkImageUrls = true }) {
1087
- const checks = [checkGate(payload, provider), checkRoundTrip(body), checkPhone(payload), checkAllowedPhone(payload, config)];
1690
+ async function runAll({ payload, body, provider, config, checkImageUrls = true, checkEndpoint = true, eventName }) {
1691
+ const checks = [checkGate(payload, provider, eventName), checkRoundTrip(body), checkPhone(payload), checkAllowedPhone(payload, config)];
1088
1692
  if (checkImageUrls) checks.push(...await checkImages(payload));
1089
- const cov = coverage(payload, provider.fieldMap);
1693
+ if (checkEndpoint) checks.push(await checkEndpointExists(provider.webhookUrl(config)));
1694
+ const fields = eventName && provider.fieldMapFor ? provider.fieldMapFor(eventName) : provider.fieldMap;
1695
+ const cov = coverage(payload, fields);
1090
1696
  const missing = cov.filter((c2) => c2.status === "MISSING");
1091
1697
  checks.push({
1092
1698
  name: "Required field coverage",
@@ -1168,18 +1774,21 @@ function loadPayload(file) {
1168
1774
  }
1169
1775
 
1170
1776
  // src/core/build.mjs
1171
- function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone }) {
1777
+ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName }) {
1172
1778
  const provider = getProvider(providerId);
1779
+ const event = eventName ?? provider.defaultEvent;
1173
1780
  const resolvedPhone = phone ?? cfg.customer.phone;
1174
1781
  const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
1175
1782
  const payload = provider.buildPayload({
1176
1783
  config: cfg,
1177
1784
  items,
1178
1785
  discount,
1786
+ eventName: event,
1179
1787
  phone: parsed.ok ? parsed.value : String(resolvedPhone ?? "")
1180
1788
  });
1181
- const fields = (provider.editableFields ?? []).map((f) => ({ ...f, value: getPath(payload, f.path) }));
1182
- return { provider, payload, fields };
1789
+ const editable = provider.editableFieldsFor ? provider.editableFieldsFor(event) : provider.editableFields;
1790
+ const fields = (editable ?? []).map((f) => ({ ...f, value: getPath(payload, f.path) }));
1791
+ return { provider, payload, fields, eventName: event };
1183
1792
  }
1184
1793
  function applyEdits(payload, fields, edits) {
1185
1794
  for (const field of fields) {
@@ -1190,15 +1799,17 @@ function applyEdits(payload, fields, edits) {
1190
1799
  }
1191
1800
  return payload;
1192
1801
  }
1193
- async function buildPayload2({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, checkImageUrls = true, payload: prebuilt }) {
1802
+ async function buildPayload4({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName }) {
1194
1803
  if (!items?.length) throw new ConfigError("no products selected");
1195
1804
  const provider = getProvider(providerId);
1196
1805
  const resolvedPhone = phone ?? cfg.customer.phone;
1197
1806
  const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
1198
1807
  if (!parsed.ok) throw new ConfigError(`phone is unusable: ${parsed.reason}`);
1199
- const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value });
1808
+ const event = eventName ?? provider.defaultEvent;
1809
+ const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event });
1200
1810
  const body = JSON.stringify(payload);
1201
- const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls });
1811
+ const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls, eventName: event });
1812
+ const summary = provider.summarize ? provider.summarize(payload) : {};
1202
1813
  const endpoint = provider.webhookUrl(cfg);
1203
1814
  let host = "";
1204
1815
  try {
@@ -1208,17 +1819,18 @@ async function buildPayload2({ cfg, providerId = "cashfree-occ", items, discount
1208
1819
  }
1209
1820
  const meta = {
1210
1821
  provider: provider.id,
1822
+ event,
1211
1823
  endpoint,
1212
1824
  environment: host,
1213
1825
  store: cfg.shopify.domain,
1214
1826
  items: items.map((i) => ({ title: i.product.title, variant: i.variant?.title, qty: i.quantity, price: i.variant?.price })),
1215
- totalPrice: payload.data.total_price,
1216
- currency: payload.data.line_items[0]?.currency,
1827
+ totalPrice: summary.total,
1828
+ currency: summary.currency,
1217
1829
  valid: report.ok
1218
1830
  };
1219
1831
  const file = savePayload({ provider: provider.id, payload, body, meta });
1220
1832
  audit("payload.built", { provider: provider.id, valid: report.ok, file });
1221
- return { provider, payload, body, report, file, meta };
1833
+ return { provider, payload, body, report, file, meta, summary };
1222
1834
  }
1223
1835
 
1224
1836
  // src/core/send.mjs
@@ -1230,18 +1842,32 @@ function looksLikeProduction(origin) {
1230
1842
  }
1231
1843
  function buildRequest({ provider, config, body, timestamp }) {
1232
1844
  const url = provider.webhookUrl(config);
1845
+ if (!url) {
1846
+ const err = new Error(`no webhook URL configured for ${provider.label}`);
1847
+ err.hint = "set it in Setup \u2192 Webhook (or edit targets in config.json)";
1848
+ throw err;
1849
+ }
1233
1850
  const secret = provider.webhookSecret ? provider.webhookSecret(config) : "";
1234
1851
  const signed = provider.sign({ body, secret, timestamp });
1235
1852
  return { url, headers: signed.headers, signature: signed.signature, timestamp: signed.timestamp, body };
1236
1853
  }
1237
- function toCurl({ url, headers, bodyFile }) {
1238
- const lines = [`curl -X POST '${url}' \\`];
1854
+ function shellQuote(value) {
1855
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
1856
+ }
1857
+ function toCurl({ url, headers, body, method = "POST" }) {
1858
+ const lines = [`curl --location --request ${method} ${shellQuote(url)} \\`];
1239
1859
  for (const [k, v] of Object.entries(headers)) {
1240
- lines.push(` -H '${k}: ${v}' \\`);
1860
+ lines.push(`--header ${shellQuote(`${k}: ${v}`)} \\`);
1241
1861
  }
1242
- lines.push(` --data-binary @${bodyFile}`);
1862
+ lines.push(`--data ${shellQuote(body)}`);
1243
1863
  return lines.join("\n");
1244
1864
  }
1865
+ function writeCurlFile(command, name = "last-curl.sh") {
1866
+ fs4.mkdirSync(STATE_DIR, { recursive: true });
1867
+ const file = path4.join(STATE_DIR, name);
1868
+ fs4.writeFileSync(file, command + "\n");
1869
+ return file;
1870
+ }
1245
1871
  function writeBodyFile(body, name = "last-body.json") {
1246
1872
  fs4.mkdirSync(STATE_DIR, { recursive: true });
1247
1873
  const file = path4.join(STATE_DIR, name);
@@ -1279,28 +1905,42 @@ async function send({ url, headers, body, timeoutMs = 2e4 }) {
1279
1905
  }
1280
1906
 
1281
1907
  // src/core/dispatch.mjs
1282
- async function dispatch({ cfg, record, force = false, dryRun = false, revalidate = true }) {
1908
+ async function dispatch({ cfg, record, force = false, dryRun = false, revalidate = true, checkEndpoint = true }) {
1283
1909
  const rec = record ?? loadLast();
1284
1910
  if (!rec) throw new ForgeError("nothing to send", { hint: "build a payload first" });
1285
1911
  const provider = getProvider(rec.provider);
1286
1912
  let report = null;
1287
1913
  if (revalidate) {
1288
- report = await runAll({ payload: rec.payload, body: rec.body, provider, config: cfg, checkImageUrls: false });
1914
+ report = await runAll({ payload: rec.payload, body: rec.body, provider, config: cfg, checkImageUrls: false, eventName: rec.meta?.event });
1289
1915
  if (!report.ok && !force) {
1290
- throw new ForgeError("refusing to send a payload that fails pre-flight", {
1291
- hint: `blocking: ${report.blocking.map((b) => b.name).join(", ")} \u2014 pass --force to override`
1916
+ const err = new ForgeError("refusing to send a payload that fails pre-flight", {
1917
+ hint: "fix the problems below, or pass --force to send regardless"
1292
1918
  });
1919
+ err.failures = report.blocking;
1920
+ throw err;
1293
1921
  }
1294
1922
  }
1295
1923
  const request = buildRequest({ provider, config: cfg, body: rec.body });
1296
1924
  if (looksLikeProduction(request.url) && !force) {
1297
1925
  throw new ForgeError(`target looks like production: ${request.url}`, { hint: "pass --force if you really mean it" });
1298
1926
  }
1927
+ let endpoint = null;
1928
+ if (checkEndpoint && !dryRun) {
1929
+ endpoint = await checkEndpointExists(request.url);
1930
+ if (!endpoint.ok && !force) {
1931
+ const err = new ForgeError(`endpoint check failed: ${endpoint.detail}`, {
1932
+ hint: "fix the URL in Setup \u2192 Webhook, or pass --force to send regardless"
1933
+ });
1934
+ err.failures = [endpoint];
1935
+ throw err;
1936
+ }
1937
+ }
1299
1938
  const bodyFile = writeBodyFile(rec.body);
1300
- const curl = toCurl({ url: request.url, headers: request.headers, bodyFile });
1301
- if (dryRun) return { dryRun: true, request, bodyFile, curl, report, provider };
1939
+ const curl = toCurl({ url: request.url, headers: request.headers, body: rec.body });
1940
+ const curlFile = writeCurlFile(curl);
1941
+ if (dryRun) return { dryRun: true, request, bodyFile, curl, curlFile, report, provider, endpoint };
1302
1942
  const result = await send({ url: request.url, headers: request.headers, body: rec.body });
1303
- return { ...result, dryRun: false, request, bodyFile, curl, report, provider };
1943
+ return { ...result, dryRun: false, request, bodyFile, curl, curlFile, report, provider, endpoint };
1304
1944
  }
1305
1945
  function explainStatus(status) {
1306
1946
  if (status >= 200 && status < 300) return "accepted \u2014 a 2xx only means it was received; check eventData to confirm the event landed";
@@ -1364,6 +2004,7 @@ async function copy(text, { allowOsc52 = true } = {}) {
1364
2004
  // src/ui/screens/Build.jsx
1365
2005
  import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1366
2006
  var STEP = {
2007
+ EVENT: "event",
1367
2008
  LOADING: "loading",
1368
2009
  PICK: "pick",
1369
2010
  VARIANT: "variant",
@@ -1380,7 +2021,10 @@ var STEP = {
1380
2021
  };
1381
2022
  function Build({ config, providerId = "cashfree-occ", onDone }) {
1382
2023
  const { exit } = useApp();
1383
- const [step, setStep] = useState(STEP.LOADING);
2024
+ const provider = getProvider(providerId);
2025
+ const multiEvent = Array.isArray(provider.events) && provider.events.length > 1;
2026
+ const [eventName, setEventName] = useState(provider.defaultEvent);
2027
+ const [step, setStep] = useState(multiEvent ? STEP.EVENT : STEP.LOADING);
1384
2028
  const [products, setProducts] = useState([]);
1385
2029
  const [chosen, setChosen] = useState([]);
1386
2030
  const [cursor, setCursor] = useState(0);
@@ -1391,18 +2035,20 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
1391
2035
  const [edits, setEdits] = useState({});
1392
2036
  const [built, setBuilt] = useState(null);
1393
2037
  const [copied, setCopied] = useState(null);
1394
- const [showPayload, setShowPayload] = useState(true);
2038
+ const [request, setRequest] = useState(null);
2039
+ const [curl, setCurl] = useState("");
2040
+ const [showPayload, setShowPayload] = useState(false);
1395
2041
  const [sendResult, setSendResult] = useState(null);
1396
2042
  const [error, setError] = useState(null);
1397
2043
  useInput3((input, key) => {
1398
2044
  if (key.escape) onDone();
1399
2045
  if ((step === STEP.RESULT || step === STEP.ERROR) && (key.return || input === "q")) onDone();
1400
2046
  if (step === STEP.REPORT && input === "p") setShowPayload((v) => !v);
1401
- if (step === STEP.REPORT && input === "c" && built) {
1402
- copy(built.body).then(setCopied);
1403
- }
2047
+ if (step === STEP.REPORT && input === "c" && curl) copy(curl).then((r) => setCopied({ ...r, what: "curl" }));
2048
+ if (step === STEP.REPORT && input === "j" && built) copy(built.body).then((r) => setCopied({ ...r, what: "payload JSON" }));
1404
2049
  });
1405
2050
  useEffect(() => {
2051
+ if (step === STEP.EVENT) return;
1406
2052
  let alive = true;
1407
2053
  fetchCatalogue(config).then((list) => {
1408
2054
  if (!alive) return;
@@ -1421,7 +2067,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
1421
2067
  return () => {
1422
2068
  alive = false;
1423
2069
  };
1424
- }, []);
2070
+ }, [step === STEP.EVENT]);
1425
2071
  const advance = (nextItems, nextCursor) => {
1426
2072
  if (nextCursor >= chosen.length) {
1427
2073
  setItems(nextItems);
@@ -1465,7 +2111,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
1465
2111
  const onDiscount = (raw) => {
1466
2112
  const discount = Math.max(0, Number.parseFloat(raw) || 0);
1467
2113
  try {
1468
- const next = draftPayload({ cfg: config, providerId, items, discount });
2114
+ const next = draftPayload({ cfg: config, providerId, items, discount, eventName });
1469
2115
  setDraft({ ...next, discount });
1470
2116
  setStep(STEP.MODE);
1471
2117
  } catch (err) {
@@ -1475,10 +2121,20 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
1475
2121
  };
1476
2122
  const finalise = (payload) => {
1477
2123
  setStep(STEP.BUILDING);
1478
- buildPayload2({ cfg: config, providerId, items, discount: draft.discount, payload }).then(async (result) => {
2124
+ buildPayload4({ cfg: config, providerId, items, discount: draft.discount, payload, eventName }).then(async (result) => {
1479
2125
  setBuilt(result);
1480
- if (config.defaults.copyToClipboard !== false) {
1481
- setCopied(await copy(result.body));
2126
+ let req = null;
2127
+ let command = "";
2128
+ try {
2129
+ req = buildRequest({ provider: result.provider, config, body: result.body });
2130
+ command = toCurl({ url: req.url, headers: req.headers, body: result.body });
2131
+ } catch {
2132
+ req = null;
2133
+ }
2134
+ setRequest(req);
2135
+ setCurl(command);
2136
+ if (config.defaults.copyToClipboard !== false && command) {
2137
+ setCopied({ ...await copy(command), what: "curl" });
1482
2138
  }
1483
2139
  setStep(STEP.REPORT);
1484
2140
  }).catch((err) => {
@@ -1519,10 +2175,24 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
1519
2175
  Header,
1520
2176
  {
1521
2177
  title: "Build payload",
1522
- subtitle: `${metaFor(providerId).label} \xB7 abandoned checkout`,
2178
+ subtitle: `${metaFor(providerId).label} \xB7 ${multiEvent ? eventName : "abandoned checkout"}`,
1523
2179
  right: store
1524
2180
  }
1525
2181
  ),
2182
+ step === STEP.EVENT && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
2183
+ /* @__PURE__ */ jsx7(Text7, { children: "Which event do you want to send?" }),
2184
+ /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
2185
+ Select4,
2186
+ {
2187
+ visibleOptionCount: provider.events.length,
2188
+ options: provider.events.map((e) => ({ label: `${e.label.padEnd(20)} ${e.eventName}`, value: e.eventName })),
2189
+ onChange: (value) => {
2190
+ setEventName(value);
2191
+ setStep(STEP.LOADING);
2192
+ }
2193
+ }
2194
+ ) })
2195
+ ] }),
1526
2196
  step === STEP.LOADING && /* @__PURE__ */ jsx7(Spinner, { label: `fetching live products from ${store}\u2026` }),
1527
2197
  step === STEP.PICK && /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
1528
2198
  /* @__PURE__ */ jsxs7(Text7, { children: [
@@ -1611,10 +2281,11 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
1611
2281
  /* @__PURE__ */ jsx7(Summary, { built, currency }),
1612
2282
  /* @__PURE__ */ jsx7(Section, { title: "Pre-flight", children: /* @__PURE__ */ jsx7(CheckList, { checks: built.report.checks }) }),
1613
2283
  /* @__PURE__ */ jsx7(Section, { title: "Field coverage", children: /* @__PURE__ */ jsx7(FieldTable, { coverage: built.report.coverage }) }),
1614
- /* @__PURE__ */ jsx7(Section, { title: "Request", children: /* @__PURE__ */ jsx7(RequestPreview, { config, built }) }),
2284
+ /* @__PURE__ */ jsx7(Section, { title: "Request", children: /* @__PURE__ */ jsx7(RequestPreview, { request, bytes: built.body.length }) }),
2285
+ curl ? /* @__PURE__ */ jsx7(Section, { title: "curl \u2014 paste straight into Postman (Import \u2192 Raw text)", children: /* @__PURE__ */ jsx7(Box7, { borderStyle: "round", borderColor: palette.accent, paddingX: 1, flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: curl }) }) }) : null,
1615
2286
  showPayload ? /* @__PURE__ */ jsx7(Section, { title: "Payload", children: /* @__PURE__ */ jsx7(Box7, { borderStyle: "round", borderColor: palette.dim, paddingX: 1, flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: JSON.stringify(built.payload, null, 2) }) }) }) : null,
1616
- /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: copied?.ok ? /* @__PURE__ */ jsx7(Text7, { color: palette.ok, children: `\u2714 payload copied to clipboard via ${copied.via}` }) : /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: `payload saved to ${built.file} \xB7 press c to copy` }) }),
1617
- /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "p toggles the full payload \xB7 c copies it" }) }),
2287
+ /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: copied?.ok ? /* @__PURE__ */ jsx7(Text7, { color: palette.ok, children: `\u2714 ${copied.what} copied to clipboard via ${copied.via}` }) : /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: `saved to ${built.file} \xB7 press c to copy the curl` }) }),
2288
+ /* @__PURE__ */ jsx7(Box7, { children: /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: "c copies the curl \xB7 j copies the payload JSON \xB7 p toggles the full payload" }) }),
1618
2289
  /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: built.report.ok ? /* @__PURE__ */ jsx7(Alert, { variant: "success", children: "payload is valid \u2014 the event will pass the schema gate and signature check" }) : /* @__PURE__ */ jsx7(Alert, { variant: "error", children: `blocking: ${built.report.blocking.map((b) => b.name).join(", ")}` }) }),
1619
2290
  /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
1620
2291
  /* @__PURE__ */ jsx7(Text7, { children: "Send it to the endpoint now? " }),
@@ -1662,13 +2333,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
1662
2333
  ] })
1663
2334
  ] });
1664
2335
  }
1665
- function RequestPreview({ config, built }) {
1666
- let request = null;
1667
- try {
1668
- request = buildRequest({ provider: built.provider, config, body: built.body });
1669
- } catch {
1670
- request = null;
1671
- }
2336
+ function RequestPreview({ request, bytes: bytes2 }) {
1672
2337
  if (!request) return /* @__PURE__ */ jsx7(Text7, { color: palette.dim, children: " destination not configured" });
1673
2338
  return /* @__PURE__ */ jsx7(
1674
2339
  KeyValue,
@@ -1678,7 +2343,7 @@ function RequestPreview({ config, built }) {
1678
2343
  ["POST", truncate(request.url, 56)],
1679
2344
  ["x-webhook-timestamp", request.timestamp],
1680
2345
  ["x-webhook-signature", truncate(request.signature, 46)],
1681
- ["body", `${built.body.length} bytes`]
2346
+ ["body", `${bytes2} bytes`]
1682
2347
  ]
1683
2348
  }
1684
2349
  );
@@ -2582,15 +3247,16 @@ async function headless(command, args, config) {
2582
3247
  return 1;
2583
3248
  }
2584
3249
  }
2585
- function requireConfig(config) {
2586
- if (isConfigured(config)) return null;
2587
- const missing = configStatus(config).filter((r) => !r.ok).map((r) => r.key);
3250
+ function requireConfig(config, providerId = "cashfree-occ") {
3251
+ if (isConfigured(config, providerId)) return null;
3252
+ const missing = configStatus(config, providerId).filter((r) => !r.ok).map((r) => r.key);
2588
3253
  log.fail(`configuration incomplete: ${missing.join(", ")}`);
2589
3254
  log.dim(`run \`hookwright configure\` or edit ${config._path}`);
2590
3255
  return 1;
2591
3256
  }
2592
3257
  async function cmdBuild(args, config) {
2593
- const bad = requireConfig(config);
3258
+ const providerId = typeof args.flags.provider === "string" ? args.flags.provider : "cashfree-occ";
3259
+ const bad = requireConfig(config, providerId);
2594
3260
  if (bad) return bad;
2595
3261
  const count = Number.parseInt(args.flags.items ?? "1", 10) || 1;
2596
3262
  const discount = Number.parseFloat(args.flags.discount ?? "0") || 0;
@@ -2604,8 +3270,10 @@ async function cmdBuild(args, config) {
2604
3270
  }
2605
3271
  const items = autoSelect(products, count);
2606
3272
  log.ok(`${items.length} product(s): ${items.map((i) => i.product.title).join(", ")}`);
2607
- const built = await buildPayload2({
3273
+ const built = await buildPayload4({
2608
3274
  cfg: config,
3275
+ providerId,
3276
+ eventName: typeof args.flags.event === "string" ? args.flags.event : void 0,
2609
3277
  items,
2610
3278
  discount,
2611
3279
  phone: typeof args.flags.phone === "string" ? args.flags.phone : void 0,
@@ -2622,8 +3290,18 @@ async function cmdBuild(args, config) {
2622
3290
  }
2623
3291
  }
2624
3292
  if (args.flags.copy !== false && !args.flags.json) {
2625
- const result = await copy(built.body);
2626
- if (result.ok) log.ok(`payload copied to clipboard via ${result.via}`);
3293
+ let clip = built.body;
3294
+ let what = "payload JSON";
3295
+ try {
3296
+ const request = buildRequest({ provider: built.provider, config, body: built.body });
3297
+ clip = toCurl({ url: request.url, headers: request.headers, body: built.body });
3298
+ what = "curl";
3299
+ } catch (err) {
3300
+ log.warn(`${err.message} \u2014 copying the payload JSON instead`);
3301
+ if (err.hint) log.dim(err.hint);
3302
+ }
3303
+ const result = await copy(clip);
3304
+ if (result.ok) log.ok(`${what} copied to clipboard via ${result.via}`);
2627
3305
  else log.dim(`clipboard unavailable \u2014 install xclip, xsel or wl-copy to enable copying`);
2628
3306
  }
2629
3307
  if (!built.report.ok && !args.flags.force) {
@@ -2651,7 +3329,8 @@ async function cmdSend(args, config, built) {
2651
3329
  cfg: config,
2652
3330
  record,
2653
3331
  force: Boolean(args.flags.force),
2654
- dryRun: Boolean(args.flags["dry-run"])
3332
+ dryRun: Boolean(args.flags["dry-run"]),
3333
+ checkEndpoint: args.flags["endpoint-check"] !== false
2655
3334
  });
2656
3335
  if (result.dryRun) {
2657
3336
  log.blank();
@@ -2749,14 +3428,15 @@ function cmdClear(args, config) {
2749
3428
  return 0;
2750
3429
  }
2751
3430
  function printReport(built) {
2752
- const d = built.payload.data;
3431
+ const s = built.summary ?? {};
2753
3432
  log.blank();
2754
3433
  box("Payload", [
2755
- `type ${built.payload.type}`,
2756
- `store ${d.store_url}`,
2757
- `customer ${d.customer.shipping_address.customer_name} \xB7 ${d.phone}`,
2758
- `items ${d.line_items.map((i) => `${i.name} x${i.quantity}`).join(", ")}`,
2759
- `total ${d.total_price} ${d.line_items[0]?.currency} (was ${d.original_total_price}, discount ${d.total_discount})`,
3434
+ `provider ${built.provider.label}`,
3435
+ `event ${s.event ?? "\u2014"}`,
3436
+ `store ${s.store ?? "\u2014"}`,
3437
+ `customer ${s.customer ?? "\u2014"}`,
3438
+ `items ${s.items ?? "\u2014"}`,
3439
+ `total ${s.total ?? "\u2014"} ${s.currency ?? ""}${s.extra ? " (" + s.extra + ")" : ""}`,
2760
3440
  `saved ${built.file}`
2761
3441
  ], c.green);
2762
3442
  log.blank();
@@ -2796,6 +3476,9 @@ ${c.bold("COMMANDS")}
2796
3476
  help show this message
2797
3477
 
2798
3478
  ${c.bold("BUILD OPTIONS")}
3479
+ --provider <id> cashfree-occ | razorpay-magic | nitro (default cashfree-occ)
3480
+ --event <name> nitro only: view \xB7 category_view \xB7 product_view \xB7 addtocart
3481
+ removefromcart \xB7 checkout \xB7 orders/create \xB7 orders/updated
2799
3482
  --items <n> how many products to put in the cart (default 1)
2800
3483
  --search <text> only consider products matching a title
2801
3484
  --discount <n> cart discount in major currency units (default 0)
@@ -2811,6 +3494,7 @@ ${c.bold("SEND OPTIONS")}
2811
3494
  --file <path> send a specific saved payload
2812
3495
  --dry-run print the request without sending
2813
3496
  --force send even if pre-flight fails or target looks like prod
3497
+ --no-endpoint-check skip verifying the destination exists before sending
2814
3498
 
2815
3499
  ${c.bold("CLEAR OPTIONS")}
2816
3500
  --payloads delete saved payloads
@@ -2882,6 +3566,9 @@ async function main() {
2882
3566
  main().catch((err) => {
2883
3567
  console.error(c.red(`
2884
3568
  ${err.message}`));
3569
+ for (const failure of err.failures ?? []) {
3570
+ console.error(` ${c.red(sym.cross)} ${c.bold(failure.name)}: ${failure.detail}`);
3571
+ }
2885
3572
  if (err.hint) console.error(c.yellow(` ${err.hint}`));
2886
3573
  process.exitCode = 1;
2887
3574
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hookwright",
3
- "version": "1.0.0",
4
- "description": "Build real, signed e-commerce webhooks from a live Shopify catalogue \u2014 interactive terminal UI, no backend",
3
+ "version": "1.1.0",
4
+ "description": "Build real, signed e-commerce webhooks from a live Shopify catalogue interactive terminal UI, no backend",
5
5
  "keywords": [
6
6
  "webhook",
7
7
  "shopify",
@@ -39,6 +39,7 @@
39
39
  "react": "^19.2.8"
40
40
  },
41
41
  "devDependencies": {
42
+ "curl-to-postmanv2": "^1.8.7",
42
43
  "esbuild": "^0.28.2"
43
44
  }
44
45
  }