Skip to content

Commit 9996eba

Browse files
authored
fix: hook scaffold issues (#247) (#264)
- Merge SCAPI/OCAPI hook types into single type, remove unverified SFRA type - Split hook sources into scapi-ocapi-hook-points and system-hook-points - Validate all hook points against Script API docs and commerce-cloud-docs - Fix json-merge: wrap new files in jsonPath structure, handle bare arrays - Support appending new hook functions to existing hook script files - Fix display paths using outputDir instead of process.cwd() - Fix conditional parameter resolution for interactive prompting - Generate correct SCAPI/OCAPI function signatures from docs reference - Show extension point value in CLI choice labels - Allow custom hook points via search prompt
1 parent 4cf7249 commit 9996eba

12 files changed

Lines changed: 476 additions & 134 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@salesforce/b2c-tooling-sdk': patch
3+
'@salesforce/b2c-cli': patch
4+
---
5+
6+
Fix multiple issues with the hook scaffold (#247):
7+
8+
- Merge SCAPI and OCAPI hook types into a single "SCAPI/OCAPI Hook" type
9+
- Fix hook extension points list to match verified B2C Commerce documentation
10+
- Fix hooks.json not updating when adding hooks to existing files (json-merge bug)
11+
- Support appending new hook functions to existing hook script files
12+
- Fix display paths missing leading slash in VS Code extension context
13+
- Filter hook extension points by selected hook type (SCAPI/OCAPI vs System)
14+
- Allow typing custom hook points not in the list
15+
- Generate correct function signatures matching commerce-cloud-docs reference
16+
- Show extension point value alongside label in CLI and VS Code prompts

packages/b2c-cli/src/lib/scaffold/generate-helper.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,18 @@ export async function executeScaffoldGenerate(
187187

188188
// Display results
189189
const created = result.files.filter((f) => f.action === 'created' || f.action === 'overwritten');
190-
const skipped = result.files.filter((f) => f.action === 'skipped');
190+
const merged = result.files.filter((f) => f.action === 'merged');
191+
// Don't show "skipped" for files that were subsequently merged by a modification
192+
const mergedPaths = new Set(merged.map((f) => f.path));
193+
const skipped = result.files.filter((f) => f.action === 'skipped' && !mergedPaths.has(f.path));
191194

192-
if (created.length > 0) {
195+
const generated = [...created, ...merged];
196+
if (generated.length > 0) {
193197
ctx.log('');
194-
ctx.log(`Successfully generated ${created.length} file(s):`);
195-
for (const file of created) {
196-
// file.path is already relative to cwd from the executor
197-
ctx.log(` ${file.action === 'overwritten' ? '(overwritten)' : '+'} ${file.path}`);
198+
ctx.log(`Successfully generated ${generated.length} file(s):`);
199+
for (const file of generated) {
200+
const prefix = file.action === 'overwritten' ? '(overwritten)' : file.action === 'merged' ? '(updated)' : '+';
201+
ctx.log(` ${prefix} ${file.path}`);
198202
}
199203
}
200204

@@ -249,7 +253,7 @@ async function promptForParameter(
249253

250254
return select({
251255
message: param.prompt,
252-
choices: choices.map((c) => ({name: c.label, value: c.value})),
256+
choices: choices.map((c) => ({name: formatChoiceName(c), value: c.value})),
253257
default: param.default as string | undefined,
254258
});
255259
}
@@ -285,7 +289,7 @@ async function promptForParameter(
285289
}
286290
return select({
287291
message: param.prompt,
288-
choices: choices.map((c) => ({name: c.label, value: c.value})),
292+
choices: choices.map((c) => ({name: formatChoiceName(c), value: c.value})),
289293
default: param.default as string | undefined,
290294
});
291295
}
@@ -324,18 +328,32 @@ async function promptTextInput(param: ScaffoldParameter): Promise<string | undef
324328
return value || undefined;
325329
}
326330

331+
/**
332+
* Format a choice for display: shows "label (value)" when they differ, just "label" otherwise.
333+
*/
334+
function formatChoiceName(c: ScaffoldChoice): string {
335+
return c.label === c.value ? c.label : `${c.label} (${c.value})`;
336+
}
337+
327338
/**
328339
* Create a search source function for inquirer search prompt.
329340
*/
330341
function createSearchSource(choices: ScaffoldChoice[]) {
331342
return async (term: string | undefined) => {
332343
if (!term) {
333-
return choices.map((c) => ({name: c.label, value: c.value}));
344+
return choices.map((c) => ({name: formatChoiceName(c), value: c.value}));
334345
}
335346
const lowerTerm = term.toLowerCase();
336-
return choices
347+
const filtered = choices
337348
.filter((c) => c.label.toLowerCase().includes(lowerTerm) || c.value.toLowerCase().includes(lowerTerm))
338-
.map((c) => ({name: c.label, value: c.value}));
349+
.map((c) => ({name: formatChoiceName(c), value: c.value}));
350+
351+
// Allow entering a custom value not in the list
352+
if (term.length > 0 && !choices.some((c) => c.value === term)) {
353+
filtered.push({name: `Custom: ${term}`, value: term});
354+
}
355+
356+
return filtered;
339357
};
340358
}
341359

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
<%
2+
var hookPoint = locals.scapiOcapiHookPoint || locals.systemHookPoint;
3+
var hookFunctionName = hookPoint.split('.').pop();
4+
5+
// SCAPI/OCAPI hook signatures from commerce-api hook-method-details reference.
6+
// Format: { params: 'paramList', jsdoc: [{name, type, desc}] }
7+
var HOOK_SIGNATURES = {
8+
// Auth
9+
'dw.ocapi.shop.auth.beforePOST': {
10+
params: 'authorizationHeader, authRequestType',
11+
jsdoc: [{name: 'authorizationHeader', type: 'String', desc: 'the authorization header'}, {name: 'authRequestType', type: 'dw.value.EnumValue', desc: 'guest, login, or refresh'}]
12+
},
13+
'dw.ocapi.shop.auth.afterPOST': {
14+
params: 'customer, authRequestType',
15+
jsdoc: [{name: 'customer', type: 'dw.customer.Customer', desc: 'the authenticated customer'}, {name: 'authRequestType', type: 'dw.value.EnumValue', desc: 'guest, login, or refresh'}]
16+
},
17+
'dw.ocapi.shop.auth.modifyPOSTResponse': {
18+
params: 'customer, customerResponse, authRequestType',
19+
jsdoc: [{name: 'customer', type: 'dw.customer.Customer', desc: 'the authenticated customer'}, {name: 'customerResponse', type: 'Object', desc: 'the API response document'}, {name: 'authRequestType', type: 'dw.value.EnumValue', desc: 'guest, login, or refresh'}]
20+
},
21+
// Basket
22+
'dw.ocapi.shop.basket.beforePOST_v2': {
23+
params: 'basketRequest',
24+
jsdoc: [{name: 'basketRequest', type: 'Object', desc: 'the basket request'}]
25+
},
26+
'dw.ocapi.shop.basket.afterPOST': {
27+
params: 'basket',
28+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the created basket'}]
29+
},
30+
'dw.ocapi.shop.basket.modifyPOSTResponse': {
31+
params: 'basket, basketResponse',
32+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'basketResponse', type: 'Object', desc: 'the API response document'}]
33+
},
34+
'dw.ocapi.shop.basket.beforePATCH': {
35+
params: 'basket, basketInput',
36+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket to update'}, {name: 'basketInput', type: 'Object', desc: 'the basket update document'}]
37+
},
38+
'dw.ocapi.shop.basket.afterPATCH': {
39+
params: 'basket, basketInput',
40+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the updated basket'}, {name: 'basketInput', type: 'Object', desc: 'the basket update document'}]
41+
},
42+
'dw.ocapi.shop.basket.modifyPATCHResponse': {
43+
params: 'basket, basketResponse',
44+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'basketResponse', type: 'Object', desc: 'the API response document'}]
45+
},
46+
'dw.ocapi.shop.basket.modifyGETResponse': {
47+
params: 'basket, basketResponse',
48+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'basketResponse', type: 'Object', desc: 'the API response document'}]
49+
},
50+
'dw.ocapi.shop.basket.validateBasket': {
51+
params: 'basketResponse, duringSubmit',
52+
jsdoc: [{name: 'basketResponse', type: 'Object', desc: 'the basket response document'}, {name: 'duringSubmit', type: 'boolean', desc: 'true if called during order submit'}]
53+
},
54+
// Basket billing address
55+
'dw.ocapi.shop.basket.billing_address.beforePUT': {
56+
params: 'basket, billingAddress',
57+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'billingAddress', type: 'Object', desc: 'the billing address document'}]
58+
},
59+
'dw.ocapi.shop.basket.billing_address.afterPUT': {
60+
params: 'basket, billingAddress',
61+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'billingAddress', type: 'Object', desc: 'the billing address document'}]
62+
},
63+
// Basket items
64+
'dw.ocapi.shop.basket.items.beforePOST': {
65+
params: 'basket, items',
66+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'items', type: 'Object', desc: 'the items document'}]
67+
},
68+
'dw.ocapi.shop.basket.items.afterPOST': {
69+
params: 'basket, items',
70+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'items', type: 'Object', desc: 'the items document'}]
71+
},
72+
// Basket item
73+
'dw.ocapi.shop.basket.item.beforePATCH': {
74+
params: 'basket, item',
75+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'item', type: 'Object', desc: 'the item document'}]
76+
},
77+
'dw.ocapi.shop.basket.item.afterPATCH': {
78+
params: 'basket, item',
79+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'item', type: 'Object', desc: 'the item document'}]
80+
},
81+
'dw.ocapi.shop.basket.item.beforeDELETE': {
82+
params: 'basket, productItemId',
83+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'productItemId', type: 'String', desc: 'the product item ID'}]
84+
},
85+
'dw.ocapi.shop.basket.item.afterDELETE': {
86+
params: 'basket, productItemId',
87+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'productItemId', type: 'String', desc: 'the product item ID'}]
88+
},
89+
// Basket coupon
90+
'dw.ocapi.shop.basket.coupon.beforePOST': {
91+
params: 'basket, couponItem',
92+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'couponItem', type: 'Object', desc: 'the coupon item document'}]
93+
},
94+
'dw.ocapi.shop.basket.coupon.afterPOST': {
95+
params: 'basket, couponItem',
96+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'couponItem', type: 'Object', desc: 'the coupon item document'}]
97+
},
98+
// Basket payment instrument
99+
'dw.ocapi.shop.basket.payment_instrument.beforePOST': {
100+
params: 'basket, paymentInstrument',
101+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'paymentInstrument', type: 'Object', desc: 'the payment instrument document'}]
102+
},
103+
'dw.ocapi.shop.basket.payment_instrument.afterPOST': {
104+
params: 'basket, paymentInstrument',
105+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'paymentInstrument', type: 'Object', desc: 'the payment instrument document'}]
106+
},
107+
// Basket shipment
108+
'dw.ocapi.shop.basket.shipment.beforePATCH': {
109+
params: 'basket, shipment, shipmentInfo',
110+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'shipment', type: 'dw.order.Shipment', desc: 'the shipment'}, {name: 'shipmentInfo', type: 'Object', desc: 'the shipment update document'}]
111+
},
112+
'dw.ocapi.shop.basket.shipment.afterPATCH': {
113+
params: 'basket, shipment, shipmentInfo',
114+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'shipment', type: 'dw.order.Shipment', desc: 'the shipment'}, {name: 'shipmentInfo', type: 'Object', desc: 'the shipment update document'}]
115+
},
116+
// Basket shipment shipping address
117+
'dw.ocapi.shop.basket.shipment.shipping_address.beforePUT': {
118+
params: 'basket, shipment, shippingAddress',
119+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'shipment', type: 'dw.order.Shipment', desc: 'the shipment'}, {name: 'shippingAddress', type: 'Object', desc: 'the shipping address document'}]
120+
},
121+
'dw.ocapi.shop.basket.shipment.shipping_address.afterPUT': {
122+
params: 'basket, shipment, shippingAddress',
123+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'shipment', type: 'dw.order.Shipment', desc: 'the shipment'}, {name: 'shippingAddress', type: 'Object', desc: 'the shipping address document'}]
124+
},
125+
// Basket shipment shipping method
126+
'dw.ocapi.shop.basket.shipment.shipping_method.beforePUT': {
127+
params: 'basket, shipment, shippingMethod',
128+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'shipment', type: 'dw.order.Shipment', desc: 'the shipment'}, {name: 'shippingMethod', type: 'Object', desc: 'the shipping method document'}]
129+
},
130+
'dw.ocapi.shop.basket.shipment.shipping_method.afterPUT': {
131+
params: 'basket, shipment, shippingMethod',
132+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket'}, {name: 'shipment', type: 'dw.order.Shipment', desc: 'the shipment'}, {name: 'shippingMethod', type: 'Object', desc: 'the shipping method document'}]
133+
},
134+
// Order
135+
'dw.ocapi.shop.order.beforePOST': {
136+
params: 'basket',
137+
jsdoc: [{name: 'basket', type: 'dw.order.Basket', desc: 'the basket to create the order from'}]
138+
},
139+
'dw.ocapi.shop.order.afterPOST': {
140+
params: 'order',
141+
jsdoc: [{name: 'order', type: 'dw.order.Order', desc: 'the created order'}]
142+
},
143+
'dw.ocapi.shop.order.modifyPOSTResponse': {
144+
params: 'order, orderResponse',
145+
jsdoc: [{name: 'order', type: 'dw.order.Order', desc: 'the order'}, {name: 'orderResponse', type: 'Object', desc: 'the API response document'}]
146+
},
147+
'dw.ocapi.shop.order.modifyGETResponse': {
148+
params: 'order, orderResponse',
149+
jsdoc: [{name: 'order', type: 'dw.order.Order', desc: 'the order'}, {name: 'orderResponse', type: 'Object', desc: 'the API response document'}]
150+
},
151+
'dw.ocapi.shop.order.validateOrder': {
152+
params: 'order',
153+
jsdoc: [{name: 'order', type: 'Object', desc: 'the order response document'}]
154+
},
155+
// Order payment instrument
156+
'dw.ocapi.shop.order.payment_instrument.beforePOST': {
157+
params: 'order, paymentInstrument',
158+
jsdoc: [{name: 'order', type: 'dw.order.Order', desc: 'the order'}, {name: 'paymentInstrument', type: 'Object', desc: 'the payment instrument document'}]
159+
},
160+
'dw.ocapi.shop.order.payment_instrument.afterPOST': {
161+
params: 'order, paymentInstrument, successfullyAuthorized',
162+
jsdoc: [{name: 'order', type: 'dw.order.Order', desc: 'the order'}, {name: 'paymentInstrument', type: 'Object', desc: 'the payment instrument document'}, {name: 'successfullyAuthorized', type: 'boolean', desc: 'true if the payment was authorized'}]
163+
},
164+
// Customer
165+
'dw.ocapi.shop.customer.beforePOST': {
166+
params: 'registration',
167+
jsdoc: [{name: 'registration', type: 'Object', desc: 'the customer registration document'}]
168+
},
169+
'dw.ocapi.shop.customer.afterPOST': {
170+
params: 'customer, registration',
171+
jsdoc: [{name: 'customer', type: 'dw.customer.Customer', desc: 'the created customer'}, {name: 'registration', type: 'Object', desc: 'the customer registration document'}]
172+
},
173+
'dw.ocapi.shop.customer.modifyGETResponse': {
174+
params: 'customer, customerResponse',
175+
jsdoc: [{name: 'customer', type: 'dw.customer.Customer', desc: 'the customer'}, {name: 'customerResponse', type: 'Object', desc: 'the API response document'}]
176+
},
177+
'dw.ocapi.shop.customer.modifyPOSTResponse': {
178+
params: 'customer, customerResponse',
179+
jsdoc: [{name: 'customer', type: 'dw.customer.Customer', desc: 'the customer'}, {name: 'customerResponse', type: 'Object', desc: 'the API response document'}]
180+
},
181+
// Product
182+
'dw.ocapi.shop.product.modifyGETResponse': {
183+
params: 'product, productResponse',
184+
jsdoc: [{name: 'product', type: 'dw.catalog.Product', desc: 'the product'}, {name: 'productResponse', type: 'Object', desc: 'the API response document'}]
185+
},
186+
// Product search
187+
'dw.ocapi.shop.product_search.modifyGETResponse': {
188+
params: 'searchResponse',
189+
jsdoc: [{name: 'searchResponse', type: 'Object', desc: 'the API response document'}]
190+
}
191+
};
192+
193+
// Look up signature or generate a generic one
194+
var sig = HOOK_SIGNATURES[hookPoint];
195+
if (!sig) {
196+
// Fallback: generic signature based on method pattern
197+
if (hookFunctionName.indexOf('modifyGET') === 0 || hookFunctionName.indexOf('modifyPOST') === 0 || hookFunctionName.indexOf('modifyPATCH') === 0 || hookFunctionName.indexOf('modifyPUT') === 0 || hookFunctionName.indexOf('modifyDELETE') === 0) {
198+
sig = {params: 'object, response', jsdoc: [{name: 'object', type: 'Object', desc: 'the object being processed'}, {name: 'response', type: 'Object', desc: 'the API response document'}]};
199+
} else if (hookFunctionName.indexOf('validate') === 0) {
200+
sig = {params: 'response', jsdoc: [{name: 'response', type: 'Object', desc: 'the response document'}]};
201+
} else if (hookFunctionName.indexOf('before') === 0 || hookFunctionName.indexOf('after') === 0) {
202+
sig = {params: 'object', jsdoc: [{name: 'object', type: 'Object', desc: 'the object being processed'}]};
203+
} else {
204+
sig = {params: 'object', jsdoc: [{name: 'object', type: 'Object', desc: 'the object being processed'}]};
205+
}
206+
}
207+
-%>
208+
209+
<% if (hookType === 'scapi-ocapi') { %>
210+
/**
211+
* SCAPI/OCAPI Hook - <%= hookPoint %>
212+
*
213+
<% sig.jsdoc.forEach(function(p) { -%>
214+
* @param {<%= p.type %>} <%= p.name %> - <%= p.desc %>
215+
<% }); -%>
216+
*/
217+
exports.<%= hookFunctionName %> = function (<%= sig.params %>) {
218+
var log = Logger.getLogger('<%= hookName %>', '<%= cartridgeName %>');
219+
log.info('<%= hookFunctionName %> hook called');
220+
221+
try {
222+
// TODO: Implement hook logic
223+
224+
} catch (e) {
225+
log.error('<%= hookFunctionName %> hook error: ' + e.message);
226+
return new Status(
227+
Status.ERROR,
228+
'<%= hookFunctionName.toUpperCase() %>_ERROR',
229+
e.message
230+
);
231+
}
232+
233+
// WARNING: Returning Status.OK will skip the system implementation
234+
// and all subsequent hooks for this extension point.
235+
// Only uncomment if you intentionally want to override system behavior.
236+
// return new Status(Status.OK);
237+
};
238+
<% } else { %>
239+
/**
240+
* System Hook Implementation - <%= hookPoint %>
241+
*
242+
* @param {dw.order.Basket|dw.order.Order|Object} object - The object being processed
243+
* @returns {dw.system.Status} - Status object
244+
*/
245+
exports.<%= hookFunctionName %> = function (object) {
246+
var log = Logger.getLogger('<%= hookName %>', '<%= cartridgeName %>');
247+
log.info('<%= hookFunctionName %> system hook called');
248+
249+
try {
250+
// TODO: Implement hook logic
251+
252+
} catch (e) {
253+
log.error('<%= hookFunctionName %> hook error: ' + e.message);
254+
return new Status(
255+
Status.ERROR,
256+
'<%= hookFunctionName.toUpperCase() %>_ERROR',
257+
e.message
258+
);
259+
}
260+
261+
return new Status(Status.OK);
262+
};
263+
<% } %>

0 commit comments

Comments
 (0)