hookwright 1.6.0 → 1.8.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 +96 -18
  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.6.0",
27
+ version: "1.8.0",
28
28
  description: "Build real, signed e-commerce webhooks from a live Shopify catalogue \u2014 interactive terminal UI, no backend",
29
29
  keywords: [
30
30
  "webhook",
@@ -163,6 +163,9 @@ var DEFAULT_CONFIG = {
163
163
  shopify: {
164
164
  domain: "",
165
165
  accessToken: "",
166
+ // Storefront API token. Creating a checkout needs one: the admin checkout API
167
+ // was removed, so a fresh checkout can only be minted through the storefront.
168
+ storefrontAccessToken: "",
166
169
  apiVersion: "2024-10",
167
170
  // cached from the last successful connection, for display only
168
171
  shop: null
@@ -911,7 +914,7 @@ function editableFieldsFor(eventName) {
911
914
  }
912
915
  var editableFields3 = editableFieldsFor(DEFAULT_EVENT);
913
916
  function buildPayload3(ctx) {
914
- const { config, items, phone, live } = ctx;
917
+ const { config, items, phone, live, delta } = ctx;
915
918
  const eventName = ctx.eventName ?? DEFAULT_EVENT;
916
919
  const now = ctx.now ?? /* @__PURE__ */ new Date();
917
920
  const currency = ctx.currency || config.defaults.currency;
@@ -1022,16 +1025,18 @@ function buildPayload3(ctx) {
1022
1025
  case "addtocart":
1023
1026
  case "removefromcart": {
1024
1027
  const added = eventName === "addtocart";
1025
- const removed = added ? [] : items.slice(-1);
1026
- const remaining = added ? items : items.slice(0, -1);
1028
+ const fallback = added ? items : items.slice(-1);
1029
+ const moved = delta?.length ? items.filter((item) => delta.includes(item)) : fallback;
1030
+ const untouched = items.filter((item) => !moved.includes(item));
1031
+ const cart = added ? items : untouched;
1027
1032
  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,
1033
+ line_items: cart.map(cartLine),
1034
+ cart_value: valueOf(cart),
1035
+ recent_product_image: added ? moved.map(cartLine)[0]?.image_url ?? null : null,
1031
1036
  init_cart: false,
1032
- empty_cart: !added && remaining.length === 0,
1033
- items_added: added ? lineItems : [],
1034
- items_removed: removed.map(removedLine),
1037
+ empty_cart: !added && cart.length === 0,
1038
+ items_added: added ? moved.map(cartLine) : [],
1039
+ items_removed: added ? [] : moved.map(removedLine),
1035
1040
  customer,
1036
1041
  bik_customer_id: null
1037
1042
  };
@@ -2491,6 +2496,33 @@ var ShopifyClient = class {
2491
2496
  const { json } = await this.request("/checkouts.json", { query: { limit } });
2492
2497
  return json?.checkouts ?? [];
2493
2498
  }
2499
+ /**
2500
+ * Creates a real checkout through the storefront API and returns its recovery
2501
+ * link. The admin checkout API was removed, so this is the only way to mint a
2502
+ * new one; it needs a storefront token, which is separate from the admin one.
2503
+ */
2504
+ async createCheckout({ storefrontAccessToken, lineItems, email }) {
2505
+ if (!storefrontAccessToken) throw new ShopifyError("Storefront access token missing", { hint: "run `hookwright configure` and add it, or the checkout event reuses an existing checkout" });
2506
+ const mutation = `mutation checkoutCreate($input: CheckoutCreateInput!) {
2507
+ checkoutCreate(input: $input) {
2508
+ checkout { id webUrl totalPrice { amount currencyCode } }
2509
+ checkoutUserErrors { field message }
2510
+ }
2511
+ }`;
2512
+ const input = { lineItems: lineItems.map(({ variantId, quantity }) => ({ variantId: `gid://shopify/ProductVariant/${variantId}`, quantity })) };
2513
+ if (email) input.email = email;
2514
+ const res = await fetch(`https://${this.domain}/api/2024-01/graphql.json`, {
2515
+ method: "POST",
2516
+ headers: { "Content-Type": "application/json", "X-Shopify-Storefront-Access-Token": storefrontAccessToken },
2517
+ body: JSON.stringify({ query: mutation, variables: { input } })
2518
+ });
2519
+ const body = await res.json();
2520
+ const errors = body.errors ?? body.data?.checkoutCreate?.checkoutUserErrors;
2521
+ if (errors?.length) throw new ShopifyError(`Shopify rejected the checkout: ${errors.map((e) => e.message).join("; ")}`, { hint: "the storefront token needs unauthenticated_write_checkouts" });
2522
+ const checkout = body.data?.checkoutCreate?.checkout;
2523
+ if (!checkout) throw new ShopifyError("Shopify returned no checkout", { hint: "check the storefront token and api version" });
2524
+ return checkout;
2525
+ }
2494
2526
  /** Orders, newest first. */
2495
2527
  async orders({ limit = 10, status = "any" } = {}) {
2496
2528
  const { json } = await this.request("/orders.json", { query: { limit, status } });
@@ -2908,7 +2940,7 @@ function loadPayload(file) {
2908
2940
  }
2909
2941
 
2910
2942
  // src/core/build.mjs
2911
- function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName, collection, envelope, live }) {
2943
+ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, phone, eventName, collection, envelope, live, delta }) {
2912
2944
  const provider = getProvider(providerId);
2913
2945
  const event = eventName ?? provider.defaultEvent;
2914
2946
  const resolvedPhone = phone ?? cfg.customer.phone;
@@ -2921,6 +2953,7 @@ function draftPayload({ cfg, providerId = "cashfree-occ", items, discount = 0, p
2921
2953
  collection,
2922
2954
  envelope,
2923
2955
  live,
2956
+ delta,
2924
2957
  phone: parsed.ok ? parsed.value : String(resolvedPhone ?? "")
2925
2958
  });
2926
2959
  const editable = provider.editableFieldsFor ? provider.editableFieldsFor(event) : provider.editableFields;
@@ -2936,7 +2969,7 @@ function applyEdits(payload, fields, edits) {
2936
2969
  }
2937
2970
  return payload;
2938
2971
  }
2939
- async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName, collection, envelope, live }) {
2972
+ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], discount = 0, phone, checkImageUrls = true, payload: prebuilt, eventName, collection, envelope, live, delta }) {
2940
2973
  const provider0 = getProvider(providerId);
2941
2974
  const needsItems = !provider0.selectionFor || ["cart", "product"].includes(provider0.selectionFor(eventName ?? provider0.defaultEvent));
2942
2975
  if (needsItems && !items.length) throw new ConfigError("no products selected");
@@ -2945,7 +2978,7 @@ async function buildPayload8({ cfg, providerId = "cashfree-occ", items = [], dis
2945
2978
  const parsed = toE164(resolvedPhone, cfg.customer.countryCode);
2946
2979
  if (!parsed.ok) throw new ConfigError(`phone is unusable: ${parsed.reason}`);
2947
2980
  const event = eventName ?? provider.defaultEvent;
2948
- const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event, collection, envelope, live });
2981
+ const payload = prebuilt ?? provider.buildPayload({ config: cfg, items, discount, phone: parsed.value, eventName: event, collection, envelope, live, delta });
2949
2982
  const body = JSON.stringify(payload);
2950
2983
  const report = await runAll({ payload, body, provider, config: cfg, checkImageUrls, eventName: event });
2951
2984
  const summary = provider.summarize ? provider.summarize(payload) : {};
@@ -2988,6 +3021,19 @@ function clientFor(config) {
2988
3021
  });
2989
3022
  }
2990
3023
  async function liveCheckout({ config, items = [], client = clientFor(config) }) {
3024
+ const storefrontAccessToken = config.shopify?.storefrontAccessToken;
3025
+ const lineItems = items.map(({ variant, product, quantity }) => ({ variantId: Number(variant?.id ?? product?.id), quantity: Number(quantity) || 1 })).filter((l) => Number.isFinite(l.variantId) && l.variantId > 0);
3026
+ if (storefrontAccessToken && lineItems.length) {
3027
+ const checkout2 = await client.createCheckout({ storefrontAccessToken, lineItems, email: config.customer?.email || void 0 });
3028
+ return {
3029
+ kind: "checkout",
3030
+ created: true,
3031
+ url: checkout2.webUrl,
3032
+ cartValue: money2(checkout2.totalPrice?.amount ?? 0).toFixed(2),
3033
+ cartToken: /\/checkouts\/([^/?]+)/.exec(checkout2.webUrl ?? "")?.[1],
3034
+ lineItems: []
3035
+ };
3036
+ }
2991
3037
  const checkouts = await client.abandonedCheckouts({ limit: 20 });
2992
3038
  if (checkouts.length === 0) return null;
2993
3039
  const wanted = new Set(items.map(({ variant }) => Number(variant?.id)).filter(Boolean));
@@ -2995,6 +3041,7 @@ async function liveCheckout({ config, items = [], client = clientFor(config) })
2995
3041
  const checkout = matching ?? checkouts[0];
2996
3042
  return {
2997
3043
  kind: "checkout",
3044
+ created: false,
2998
3045
  url: checkout.abandoned_checkout_url,
2999
3046
  cartValue: money2(checkout.total_price ?? 0).toFixed(2),
3000
3047
  cartToken: checkout.cart_token,
@@ -3224,6 +3271,7 @@ var STEP = {
3224
3271
  PICK: "pick",
3225
3272
  VARIANT: "variant",
3226
3273
  QUANTITY: "quantity",
3274
+ DELTA: "delta",
3227
3275
  DISCOUNT: "discount",
3228
3276
  LIVE: "live",
3229
3277
  MODE: "mode",
@@ -3248,6 +3296,8 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3248
3296
  const [cursor, setCursor] = useState(0);
3249
3297
  const [items, setItems] = useState([]);
3250
3298
  const [pendingVariant, setPendingVariant] = useState(null);
3299
+ const [delta, setDelta] = useState([]);
3300
+ const isDeltaEvent = providerId === "nitro" && (eventName === "addtocart" || eventName === "removefromcart");
3251
3301
  const [draft, setDraft] = useState(null);
3252
3302
  const [fieldIndex, setFieldIndex] = useState(0);
3253
3303
  const [edits, setEdits] = useState({});
@@ -3281,6 +3331,8 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3281
3331
  setItems(items.slice(0, -1));
3282
3332
  return stepInto(last);
3283
3333
  }
3334
+ case STEP.DELTA:
3335
+ return setStep(STEP.PICK);
3284
3336
  case STEP.LIVE:
3285
3337
  case STEP.MODE:
3286
3338
  return setStep(STEP.DISCOUNT);
@@ -3369,7 +3421,8 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3369
3421
  const advance = (nextItems, nextCursor) => {
3370
3422
  if (nextCursor >= chosen.length) {
3371
3423
  setItems(nextItems);
3372
- if (selection === "cart") setStep(STEP.DISCOUNT);
3424
+ if (isDeltaEvent) setStep(STEP.DELTA);
3425
+ else if (selection === "cart") setStep(STEP.DISCOUNT);
3373
3426
  else startReview(0, collection, nextItems);
3374
3427
  return;
3375
3428
  }
@@ -3425,7 +3478,7 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3425
3478
  const nextItems = [...items, { product, variant: pendingVariant, quantity: qty }];
3426
3479
  advance(nextItems, cursor + 1);
3427
3480
  };
3428
- const startReview = async (discount, chosenCollection = collection, chosenItems = items) => {
3481
+ const startReview = async (discount, chosenCollection = collection, chosenItems = items, chosenDelta = delta) => {
3429
3482
  try {
3430
3483
  let live = null;
3431
3484
  if (needsLive(providerId, eventName)) {
@@ -3439,19 +3492,26 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3439
3492
  discount,
3440
3493
  eventName,
3441
3494
  collection: chosenCollection,
3442
- live
3495
+ live,
3496
+ delta: chosenDelta
3443
3497
  });
3444
- setDraft({ ...next, discount, live });
3498
+ setDraft({ ...next, discount, live, delta: chosenDelta });
3445
3499
  setStep(STEP.MODE);
3446
3500
  } catch (err) {
3447
3501
  setError({ message: err.message, hint: err.hint, failures: err.failures });
3448
3502
  setStep(STEP.ERROR);
3449
3503
  }
3450
3504
  };
3505
+ const onDelta = (ids) => {
3506
+ const picked = items.filter((i) => ids.includes(String(i.variant?.id ?? i.product?.id)));
3507
+ if (!picked.length) return;
3508
+ setDelta(picked);
3509
+ startReview(0, collection, items, picked);
3510
+ };
3451
3511
  const onDiscount = (raw) => startReview(Math.max(0, Number.parseFloat(raw) || 0));
3452
3512
  const finalise = (payload) => {
3453
3513
  setStep(STEP.BUILDING);
3454
- buildPayload8({ cfg: config, providerId, items, discount: draft.discount, payload, eventName, collection, live: draft.live }).then(async (result) => {
3514
+ buildPayload8({ cfg: config, providerId, items, discount: draft.discount, payload, eventName, collection, live: draft.live, delta: draft.delta }).then(async (result) => {
3455
3515
  setBuilt(result);
3456
3516
  let req = null;
3457
3517
  let command = "";
@@ -3571,6 +3631,24 @@ function Build({ config, providerId = "cashfree-occ", onDone }) {
3571
3631
  }
3572
3632
  ) })
3573
3633
  ] }),
3634
+ step === STEP.DELTA && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3635
+ /* @__PURE__ */ jsxs8(Text8, { children: [
3636
+ eventName === "addtocart" ? "Which of those lines is the shopper adding now? " : "Which of those lines is the shopper removing? ",
3637
+ /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: "(space toggles \xB7 enter confirms)" })
3638
+ ] }),
3639
+ /* @__PURE__ */ jsx8(Text8, { color: palette.dim, children: eventName === "addtocart" ? "The rest were already in the cart." : "The rest stay in the cart." }),
3640
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(
3641
+ MultiSelect,
3642
+ {
3643
+ visibleOptionCount: 10,
3644
+ options: items.map((i) => ({
3645
+ label: `${truncate(i.product.title, 40).padEnd(41)} x${String(i.quantity).padEnd(3)} ${money(i.variant?.price, currency)}`,
3646
+ value: String(i.variant?.id ?? i.product?.id)
3647
+ })),
3648
+ onSubmit: onDelta
3649
+ }
3650
+ ) })
3651
+ ] }),
3574
3652
  step === STEP.VARIANT && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
3575
3653
  /* @__PURE__ */ jsxs8(Text8, { children: [
3576
3654
  `Variant for `,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookwright",
3
- "version": "1.6.0",
3
+ "version": "1.8.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",