@swell/cli 2.7.0 → 2.7.1
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.
- package/dist/commands/inspect/extensions.d.ts +49 -0
- package/dist/commands/inspect/extensions.js +424 -0
- package/dist/create-app-command.js +14 -6
- package/dist/lib/inspect/extensions.d.ts +267 -0
- package/dist/lib/inspect/extensions.js +690 -0
- package/oclif.manifest.json +65 -1
- package/package.json +1 -1
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import isEqual from 'lodash/isEqual.js';
|
|
2
|
+
const APP_SLUG_KEY = /^app\.([^.]+)\.(.+)$/;
|
|
3
|
+
const BARE_NAME = /^[\w-]+$/i;
|
|
4
|
+
/**
|
|
5
|
+
* Parse a `swell inspect extensions` identifier.
|
|
6
|
+
*
|
|
7
|
+
* Accepts:
|
|
8
|
+
* - `app.<slug>.<extId>` → kind 'slug'
|
|
9
|
+
* - bare `<extId>` → kind 'name' (requires --app= scope at the call site)
|
|
10
|
+
*
|
|
11
|
+
* 24-char hex is intentionally NOT a valid form — the synthesized resource has
|
|
12
|
+
* no canonical 24-char id. Callers should reject hex shapes upstream so the
|
|
13
|
+
* error message can be clear about the cause.
|
|
14
|
+
*/
|
|
15
|
+
export function parseExtensionKey(input) {
|
|
16
|
+
const slugMatch = input.match(APP_SLUG_KEY);
|
|
17
|
+
if (slugMatch) {
|
|
18
|
+
return { kind: 'slug', appPart: slugMatch[1], extId: slugMatch[2] };
|
|
19
|
+
}
|
|
20
|
+
if (BARE_NAME.test(input)) {
|
|
21
|
+
return { kind: 'name', name: input };
|
|
22
|
+
}
|
|
23
|
+
return { kind: 'invalid', input };
|
|
24
|
+
}
|
|
25
|
+
/** Build the column-1 paste-back key for a row. */
|
|
26
|
+
export function formatExtensionKey(slug, extId) {
|
|
27
|
+
return `app.${slug}.${extId}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a manifest entry to the CLI's type taxonomy. Payment splits on
|
|
31
|
+
* `method`: when `method === 'card'` the extension provides a card gateway;
|
|
32
|
+
* otherwise it's an alt method. The platform formula at
|
|
33
|
+
* schema-api-server/api/admin/models/apps.json:457-458 defaults `method` to
|
|
34
|
+
* `id` when unset, so a payment extension whose `id` happens to be `card`
|
|
35
|
+
* resolves to `card` even with no explicit `method`.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveExtensionType(entry) {
|
|
38
|
+
if (entry.type === 'shipping')
|
|
39
|
+
return 'shipping';
|
|
40
|
+
if (entry.type === 'tax')
|
|
41
|
+
return 'tax';
|
|
42
|
+
const method = entry.method ?? entry.id;
|
|
43
|
+
return method === 'card' ? 'card' : 'alt';
|
|
44
|
+
}
|
|
45
|
+
/** Apply the platform's `if(method, method, id)` formula. */
|
|
46
|
+
export function paymentMethodIdFor(entry) {
|
|
47
|
+
return entry.method ?? entry.id;
|
|
48
|
+
}
|
|
49
|
+
/** Shipping records use `app_<appId>_<extId>` as both the row id and the carrier id. */
|
|
50
|
+
export function shippingBindId(appId, extId) {
|
|
51
|
+
return `app_${appId}_${extId}`;
|
|
52
|
+
}
|
|
53
|
+
/** Card-gateway records use the same id form as shipping rows. */
|
|
54
|
+
export function gatewayBindId(appId, extId) {
|
|
55
|
+
return `app_${appId}_${extId}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Required events per extension type, in the `<model>/<event>` form that
|
|
59
|
+
* function records actually store.
|
|
60
|
+
*
|
|
61
|
+
* Verified against:
|
|
62
|
+
* schema-api-server/server/vault.js:1818,1894 (payment.create_intent)
|
|
63
|
+
* schema-api-server/api/com/features/payments/index.js:701-757
|
|
64
|
+
* (payment.charge, payment.refund, dispatcher gate)
|
|
65
|
+
* schema-api-server/server/vault.js:2304-2322 (card-gateway intent path)
|
|
66
|
+
* schema-api-server/api/com/features/orders/shipping.js:411-417,424-431,452-458
|
|
67
|
+
* (shipping dispatch + enabled gate)
|
|
68
|
+
* schema-api-server/api/com/features/orders/taxes.js:123-176
|
|
69
|
+
* (tax dispatch)
|
|
70
|
+
* schema-api-server/api/com/features/orders/extensions.test.js:85,95,179,271,290,303,671,704
|
|
71
|
+
* (event-name format fixtures)
|
|
72
|
+
*/
|
|
73
|
+
export function requiredEventsFor(type) {
|
|
74
|
+
switch (type) {
|
|
75
|
+
case 'card':
|
|
76
|
+
case 'alt': {
|
|
77
|
+
return [
|
|
78
|
+
'payments/payment.create_intent',
|
|
79
|
+
'payments/payment.charge',
|
|
80
|
+
'payments/payment.refund',
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
case 'shipping': {
|
|
84
|
+
return ['orders/order.shipping'];
|
|
85
|
+
}
|
|
86
|
+
case 'tax': {
|
|
87
|
+
return ['orders/order.taxes'];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Strip `before:` / `after:` hook-type prefix from an event identifier.
|
|
93
|
+
*
|
|
94
|
+
* Both prefixes are platform-supported on function records; bare events
|
|
95
|
+
* default to `after`. For required-event coverage we treat any prefix as
|
|
96
|
+
* equivalent so that a function declaring `before:payment.charge` covers a
|
|
97
|
+
* required `payments/payment.charge`. Cite:
|
|
98
|
+
* schema-api-server/api/com/features/orders/extensions.test.js:95.
|
|
99
|
+
*/
|
|
100
|
+
export function stripHookPrefix(event) {
|
|
101
|
+
return event.replace(/^(?:before|after):/, '');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Compute the set of bare event identifiers covered by a function record's
|
|
105
|
+
* `model.events` list. The function event format is `<model>/<event>` with
|
|
106
|
+
* an optional `before:`/`after:` prefix on the bare event part. Returns the
|
|
107
|
+
* full `<model>/<event>` string with hook prefixes stripped from the event.
|
|
108
|
+
*/
|
|
109
|
+
export function eventsCoveredByFunctions(functions) {
|
|
110
|
+
const covered = new Set();
|
|
111
|
+
for (const fn of functions) {
|
|
112
|
+
for (const ev of fn.model?.events ?? []) {
|
|
113
|
+
const slash = ev.indexOf('/');
|
|
114
|
+
if (slash < 0) {
|
|
115
|
+
covered.add(stripHookPrefix(ev));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const model = ev.slice(0, slash);
|
|
119
|
+
const eventName = stripHookPrefix(ev.slice(slash + 1));
|
|
120
|
+
covered.add(`${model}/${eventName}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return covered;
|
|
124
|
+
}
|
|
125
|
+
/** Required events not covered by any of the bound functions. */
|
|
126
|
+
export function missingRequiredEvents(type, functions) {
|
|
127
|
+
const covered = eventsCoveredByFunctions(functions);
|
|
128
|
+
return requiredEventsFor(type).filter((ev) => !covered.has(ev));
|
|
129
|
+
}
|
|
130
|
+
/** Lift a component record into the synthesized `BoundComponent` shape. */
|
|
131
|
+
export function liftBoundComponent(record) {
|
|
132
|
+
return {
|
|
133
|
+
id: record.id,
|
|
134
|
+
name: record.name,
|
|
135
|
+
extension: record.values?.extension,
|
|
136
|
+
file_path: record.file_path,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Lift a function record into the synthesized `BoundFunction` shape. */
|
|
140
|
+
export function liftBoundFunction(record) {
|
|
141
|
+
return {
|
|
142
|
+
id: record.id,
|
|
143
|
+
name: record.name,
|
|
144
|
+
extension: record.extension,
|
|
145
|
+
enabled: record.enabled,
|
|
146
|
+
model: record.model,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Compare a local manifest entry against the deployed entry. Uses deep
|
|
151
|
+
* equality for change detection and reports differing top-level keys. When
|
|
152
|
+
* the entries are equal, returns `null`.
|
|
153
|
+
*/
|
|
154
|
+
export function diffManifestEntries(local, deployed) {
|
|
155
|
+
const keys = new Set([...Object.keys(local), ...Object.keys(deployed)]);
|
|
156
|
+
const changed = [];
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
if (!isEqual(local[key], deployed[key])) {
|
|
159
|
+
changed.push(key);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (changed.length === 0)
|
|
163
|
+
return null;
|
|
164
|
+
return { changed_fields: changed, local, deployed };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Find a binding among the native settings collections.
|
|
168
|
+
* Returns null when the entry isn't present at all.
|
|
169
|
+
*/
|
|
170
|
+
function findPaymentMethod(payments, methodId) {
|
|
171
|
+
return payments?.methods?.find((m) => m.id === methodId) ?? null;
|
|
172
|
+
}
|
|
173
|
+
function findPaymentGateway(payments, gatewayId) {
|
|
174
|
+
return payments?.gateways?.find((g) => g.id === gatewayId) ?? null;
|
|
175
|
+
}
|
|
176
|
+
function findShippingCarrier(shipments, carrierId) {
|
|
177
|
+
return shipments?.carriers?.find((c) => c.id === carrierId) ?? null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Payment-alt activation. The dispatcher gates only on `extension_app_id`
|
|
181
|
+
* being set on the method record (verified at
|
|
182
|
+
* schema-api-server/api/com/features/payments/index.js:701-721).
|
|
183
|
+
*
|
|
184
|
+
* The `enabled` boolean on the method record is checkout-visibility only,
|
|
185
|
+
* NOT a dispatch gate — a method with `enabled: false` and `extension_app_id`
|
|
186
|
+
* set will still dispatch `before:payment.charge` to the extension. Likewise,
|
|
187
|
+
* `activated: true` is set during the merchant activation flow but is not a
|
|
188
|
+
* dispatch gate. Do NOT require `enabled === true` or `activated === true`
|
|
189
|
+
* to compute `activated` status here. This asymmetry with shipping (which DOES
|
|
190
|
+
* gate on `enabled`) is intentional.
|
|
191
|
+
*/
|
|
192
|
+
function deriveAltDispatch(ctx) {
|
|
193
|
+
const methodId = paymentMethodIdFor(ctx.manifest);
|
|
194
|
+
const expectedGatewayId = gatewayBindId(ctx.appId, ctx.extId);
|
|
195
|
+
const method = findPaymentMethod(ctx.payments, methodId);
|
|
196
|
+
const gateway = findPaymentGateway(ctx.payments, expectedGatewayId);
|
|
197
|
+
const methodChecks = {
|
|
198
|
+
extension_app_id: method?.extension_app_id === ctx.appId,
|
|
199
|
+
extension_config_id: method?.extension_config_id === ctx.extId,
|
|
200
|
+
gateway: method?.gateway === expectedGatewayId,
|
|
201
|
+
};
|
|
202
|
+
const gatewayChecks = {
|
|
203
|
+
extension_app_id: gateway?.extension_app_id === ctx.appId,
|
|
204
|
+
extension_config_id: gateway?.extension_config_id === ctx.extId,
|
|
205
|
+
};
|
|
206
|
+
const native_bindings = [
|
|
207
|
+
{
|
|
208
|
+
path: `/settings/payments/methods/${methodId}`,
|
|
209
|
+
record: method,
|
|
210
|
+
field_checks: methodChecks,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
path: `/settings/payments/gateways/${expectedGatewayId}`,
|
|
214
|
+
record: gateway,
|
|
215
|
+
field_checks: gatewayChecks,
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
// Order matters: stop at the first failed check.
|
|
219
|
+
if (!method || !method.extension_app_id) {
|
|
220
|
+
return { status: 'not activated', native_bindings };
|
|
221
|
+
}
|
|
222
|
+
if (method.extension_app_id !== ctx.appId) {
|
|
223
|
+
return { status: 'app id mismatch', native_bindings };
|
|
224
|
+
}
|
|
225
|
+
if (method.extension_config_id !== ctx.extId) {
|
|
226
|
+
return { status: 'id mismatch', native_bindings };
|
|
227
|
+
}
|
|
228
|
+
if (method.gateway !== expectedGatewayId ||
|
|
229
|
+
!gateway ||
|
|
230
|
+
gateway.extension_app_id !== ctx.appId ||
|
|
231
|
+
gateway.extension_config_id !== ctx.extId) {
|
|
232
|
+
return { status: 'gateway missing', native_bindings };
|
|
233
|
+
}
|
|
234
|
+
return { status: 'activated', native_bindings };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Card-gateway activation. The merchant selects a gateway under the `card`
|
|
238
|
+
* row of `methods[]`; the gateway record must exist with matching ids. The
|
|
239
|
+
* dispatcher's intent path goes through `triggerPaymentGatewayExtension`
|
|
240
|
+
* (schema-api-server/server/vault.js:2304-2322), and charge/refund go through
|
|
241
|
+
* the same `eventHooks.triggerExtension` as alt (index.js:751-757).
|
|
242
|
+
*/
|
|
243
|
+
function deriveCardDispatch(ctx) {
|
|
244
|
+
const expectedGatewayId = gatewayBindId(ctx.appId, ctx.extId);
|
|
245
|
+
const cardMethod = findPaymentMethod(ctx.payments, 'card');
|
|
246
|
+
const gateway = findPaymentGateway(ctx.payments, expectedGatewayId);
|
|
247
|
+
const methodChecks = {
|
|
248
|
+
gateway: cardMethod?.gateway === expectedGatewayId,
|
|
249
|
+
};
|
|
250
|
+
const gatewayChecks = {
|
|
251
|
+
extension_app_id: gateway?.extension_app_id === ctx.appId,
|
|
252
|
+
extension_config_id: gateway?.extension_config_id === ctx.extId,
|
|
253
|
+
};
|
|
254
|
+
const native_bindings = [
|
|
255
|
+
{
|
|
256
|
+
path: `/settings/payments/methods/card`,
|
|
257
|
+
record: cardMethod,
|
|
258
|
+
field_checks: methodChecks,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
path: `/settings/payments/gateways/${expectedGatewayId}`,
|
|
262
|
+
record: gateway,
|
|
263
|
+
field_checks: gatewayChecks,
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
if (!gateway || !gateway.extension_app_id) {
|
|
267
|
+
return { status: 'not activated', native_bindings };
|
|
268
|
+
}
|
|
269
|
+
if (gateway.extension_app_id !== ctx.appId) {
|
|
270
|
+
return { status: 'app id mismatch', native_bindings };
|
|
271
|
+
}
|
|
272
|
+
if (gateway.extension_config_id !== ctx.extId) {
|
|
273
|
+
return { status: 'id mismatch', native_bindings };
|
|
274
|
+
}
|
|
275
|
+
if (cardMethod?.gateway !== expectedGatewayId) {
|
|
276
|
+
return { status: 'not activated', native_bindings };
|
|
277
|
+
}
|
|
278
|
+
return { status: 'activated', native_bindings };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Shipping activation. Dispatch fires when the carrier row at
|
|
282
|
+
* `/settings/shipments/carriers/app_<appId>_<extId>` has both
|
|
283
|
+
* `enabled === true` AND `extension_app_id` set. Cite:
|
|
284
|
+
* schema-api-server/api/com/features/orders/shipping.js:411-417.
|
|
285
|
+
*
|
|
286
|
+
* Shipping is the ONLY type where `enabled` is a dispatch gate.
|
|
287
|
+
*/
|
|
288
|
+
function deriveShippingDispatch(ctx) {
|
|
289
|
+
const carrierId = shippingBindId(ctx.appId, ctx.extId);
|
|
290
|
+
const carrier = findShippingCarrier(ctx.shipments, carrierId);
|
|
291
|
+
const checks = {
|
|
292
|
+
extension_app_id: carrier?.extension_app_id === ctx.appId,
|
|
293
|
+
extension_config_id: carrier?.extension_config_id === ctx.extId,
|
|
294
|
+
enabled: carrier?.enabled === true,
|
|
295
|
+
};
|
|
296
|
+
const native_bindings = [
|
|
297
|
+
{
|
|
298
|
+
path: `/settings/shipments/carriers/${carrierId}`,
|
|
299
|
+
record: carrier,
|
|
300
|
+
field_checks: checks,
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
if (!carrier || !carrier.extension_app_id) {
|
|
304
|
+
return { status: 'not activated', native_bindings };
|
|
305
|
+
}
|
|
306
|
+
if (carrier.extension_app_id !== ctx.appId) {
|
|
307
|
+
return { status: 'app id mismatch', native_bindings };
|
|
308
|
+
}
|
|
309
|
+
if (carrier.extension_config_id !== ctx.extId) {
|
|
310
|
+
return { status: 'id mismatch', native_bindings };
|
|
311
|
+
}
|
|
312
|
+
if (carrier.enabled !== true) {
|
|
313
|
+
return { status: 'not enabled', native_bindings };
|
|
314
|
+
}
|
|
315
|
+
return { status: 'activated', native_bindings };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Tax activation. The top-level `/settings/taxes` record carries
|
|
319
|
+
* `extension_app_id` and `extension_config_id`; the IDs themselves ARE the
|
|
320
|
+
* selection — there is no separate `selected`/`activated` field. Cite:
|
|
321
|
+
* schema-api-server/api/com/features/orders/taxes.js:123-144 (gate);
|
|
322
|
+
* schema-api-server/api/com/settings/taxes.json:15-19 (schema confirms no
|
|
323
|
+
* separate selector).
|
|
324
|
+
*
|
|
325
|
+
* `not selected` distinguishes "another app owns this binding" from the
|
|
326
|
+
* empty `not activated` case.
|
|
327
|
+
*/
|
|
328
|
+
function deriveTaxDispatch(ctx) {
|
|
329
|
+
const taxes = ctx.taxes ?? {};
|
|
330
|
+
const checks = {
|
|
331
|
+
extension_app_id: taxes.extension_app_id === ctx.appId,
|
|
332
|
+
extension_config_id: taxes.extension_config_id === ctx.extId,
|
|
333
|
+
};
|
|
334
|
+
const native_bindings = [
|
|
335
|
+
{
|
|
336
|
+
path: `/settings/taxes`,
|
|
337
|
+
record: taxes,
|
|
338
|
+
field_checks: checks,
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
if (!taxes.extension_app_id) {
|
|
342
|
+
return { status: 'not activated', native_bindings };
|
|
343
|
+
}
|
|
344
|
+
if (taxes.extension_app_id !== ctx.appId) {
|
|
345
|
+
return { status: 'not selected', native_bindings };
|
|
346
|
+
}
|
|
347
|
+
if (taxes.extension_config_id !== ctx.extId) {
|
|
348
|
+
return { status: 'id mismatch', native_bindings };
|
|
349
|
+
}
|
|
350
|
+
return { status: 'activated', native_bindings };
|
|
351
|
+
}
|
|
352
|
+
function deriveDispatch(type, ctx) {
|
|
353
|
+
switch (type) {
|
|
354
|
+
case 'alt': {
|
|
355
|
+
return deriveAltDispatch(ctx);
|
|
356
|
+
}
|
|
357
|
+
case 'card': {
|
|
358
|
+
return deriveCardDispatch(ctx);
|
|
359
|
+
}
|
|
360
|
+
case 'shipping': {
|
|
361
|
+
return deriveShippingDispatch(ctx);
|
|
362
|
+
}
|
|
363
|
+
case 'tax': {
|
|
364
|
+
return deriveTaxDispatch(ctx);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Build the synthesized envelope for a regular (non-orphan) extension row. */
|
|
369
|
+
export function buildExtensionDetail(input) {
|
|
370
|
+
if (input.notDeployed) {
|
|
371
|
+
if (!input.manifest) {
|
|
372
|
+
throw new Error('buildExtensionDetail: notDeployed=true requires a local manifest entry');
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
status: 'not deployed',
|
|
376
|
+
action_owner: 'dev',
|
|
377
|
+
action: 'swell app push',
|
|
378
|
+
type: resolveExtensionType(input.manifest),
|
|
379
|
+
manifest: input.manifest,
|
|
380
|
+
native_bindings: [],
|
|
381
|
+
bound: {
|
|
382
|
+
functions: input.functions.map((f) => liftBoundFunction(f)),
|
|
383
|
+
components: input.components.map((c) => liftBoundComponent(c)),
|
|
384
|
+
},
|
|
385
|
+
required_events: requiredEventsFor(resolveExtensionType(input.manifest)),
|
|
386
|
+
missing_required_events: missingRequiredEvents(resolveExtensionType(input.manifest), input.functions),
|
|
387
|
+
local_diff: input.localDiff ?? null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (!input.manifest) {
|
|
391
|
+
throw new Error('buildExtensionDetail: missing manifest for a deployed extension');
|
|
392
|
+
}
|
|
393
|
+
const type = resolveExtensionType(input.manifest);
|
|
394
|
+
const dispatch = deriveDispatch(type, {
|
|
395
|
+
appId: input.appId,
|
|
396
|
+
extId: input.extId,
|
|
397
|
+
manifest: input.manifest,
|
|
398
|
+
payments: input.payments,
|
|
399
|
+
shipments: input.shipments,
|
|
400
|
+
taxes: input.taxes,
|
|
401
|
+
functions: input.functions,
|
|
402
|
+
components: input.components,
|
|
403
|
+
});
|
|
404
|
+
// Status answers "can dispatch happen at all?" — partial coverage stays
|
|
405
|
+
// `activated` because dispatch IS happening for the events that have
|
|
406
|
+
// handlers; `missing_required_events` carries the completeness signal as a
|
|
407
|
+
// separate, parseable field. Only `no handler` (zero matching functions for
|
|
408
|
+
// the binding) escalates to a status.
|
|
409
|
+
const { native_bindings } = dispatch;
|
|
410
|
+
let { status } = dispatch;
|
|
411
|
+
if (status === 'activated' && input.functions.length === 0) {
|
|
412
|
+
status = 'no handler';
|
|
413
|
+
}
|
|
414
|
+
const required = requiredEventsFor(type);
|
|
415
|
+
const missing = missingRequiredEvents(type, input.functions);
|
|
416
|
+
const { action, action_owner } = describeAction(status, {
|
|
417
|
+
type,
|
|
418
|
+
appId: input.appId,
|
|
419
|
+
extId: input.extId,
|
|
420
|
+
manifest: input.manifest,
|
|
421
|
+
});
|
|
422
|
+
return {
|
|
423
|
+
status,
|
|
424
|
+
action_owner,
|
|
425
|
+
action,
|
|
426
|
+
type,
|
|
427
|
+
manifest: input.manifest,
|
|
428
|
+
native_bindings,
|
|
429
|
+
bound: {
|
|
430
|
+
functions: input.functions.map((f) => liftBoundFunction(f)),
|
|
431
|
+
components: input.components.map((c) => liftBoundComponent(c)),
|
|
432
|
+
},
|
|
433
|
+
required_events: required,
|
|
434
|
+
missing_required_events: missing,
|
|
435
|
+
local_diff: input.localDiff ?? null,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/** Build the synthesized envelope for an orphan row. */
|
|
439
|
+
export function buildOrphanDetail(input) {
|
|
440
|
+
return {
|
|
441
|
+
status: 'handler mismatch',
|
|
442
|
+
action_owner: 'dev',
|
|
443
|
+
action: 'Fix `config.extension` in source and `swell app push`.',
|
|
444
|
+
type: null,
|
|
445
|
+
manifest: null,
|
|
446
|
+
native_bindings: [],
|
|
447
|
+
bound: {
|
|
448
|
+
functions: input.functions.map((f) => liftBoundFunction(f)),
|
|
449
|
+
components: input.components.map((c) => liftBoundComponent(c)),
|
|
450
|
+
},
|
|
451
|
+
required_events: [],
|
|
452
|
+
missing_required_events: [],
|
|
453
|
+
local_diff: null,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function settingsSection(type) {
|
|
457
|
+
switch (type) {
|
|
458
|
+
case 'card':
|
|
459
|
+
case 'alt': {
|
|
460
|
+
return 'Payments';
|
|
461
|
+
}
|
|
462
|
+
case 'shipping': {
|
|
463
|
+
return 'Shipping';
|
|
464
|
+
}
|
|
465
|
+
case 'tax': {
|
|
466
|
+
return 'Taxes';
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function extensionLabel(manifest) {
|
|
471
|
+
return manifest.name ?? manifest.id;
|
|
472
|
+
}
|
|
473
|
+
function describeAction(status, ctx) {
|
|
474
|
+
const section = settingsSection(ctx.type);
|
|
475
|
+
const label = extensionLabel(ctx.manifest);
|
|
476
|
+
switch (status) {
|
|
477
|
+
case 'not deployed': {
|
|
478
|
+
return { action: 'swell app push', action_owner: 'dev' };
|
|
479
|
+
}
|
|
480
|
+
case 'not activated': {
|
|
481
|
+
return {
|
|
482
|
+
action: `Open Settings → ${section} → ${label} → Save`,
|
|
483
|
+
action_owner: 'merchant',
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
case 'app id mismatch': {
|
|
487
|
+
return {
|
|
488
|
+
action: 'Another app owns this binding; activate this one if intended',
|
|
489
|
+
action_owner: 'merchant',
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
case 'id mismatch': {
|
|
493
|
+
return {
|
|
494
|
+
action: 'Re-save activation dialog after redeploy',
|
|
495
|
+
action_owner: 'merchant',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
case 'not selected': {
|
|
499
|
+
return {
|
|
500
|
+
action: `Settings → Taxes → select ${label} → Save`,
|
|
501
|
+
action_owner: 'merchant',
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
case 'gateway missing': {
|
|
505
|
+
return {
|
|
506
|
+
action: 'Re-save activation dialog',
|
|
507
|
+
action_owner: 'merchant',
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
case 'not enabled': {
|
|
511
|
+
return {
|
|
512
|
+
action: `Settings → Shipping → ${label} → toggle Enabled → Save`,
|
|
513
|
+
action_owner: 'merchant',
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
case 'no handler': {
|
|
517
|
+
return {
|
|
518
|
+
action: `swell create function --extension ${ctx.extId} --event ${requiredEventsFor(ctx.type)[0]}`,
|
|
519
|
+
action_owner: 'dev',
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
case 'handler mismatch': {
|
|
523
|
+
return {
|
|
524
|
+
action: 'Fix `config.extension` in source and `swell app push`.',
|
|
525
|
+
action_owner: 'dev',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
case 'activated': {
|
|
529
|
+
return { action: null, action_owner: null };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* List-mode meta string for one row. Returns `undefined` when the row has
|
|
535
|
+
* nothing surface-worthy beyond the type tag (which is always present).
|
|
536
|
+
*/
|
|
537
|
+
export function listMetaFor(detail) {
|
|
538
|
+
const parts = [];
|
|
539
|
+
if (detail.type)
|
|
540
|
+
parts.push(detail.type);
|
|
541
|
+
if (detail.status !== 'activated') {
|
|
542
|
+
parts.push(detail.status);
|
|
543
|
+
}
|
|
544
|
+
const fnCount = detail.bound.functions.length;
|
|
545
|
+
const compCount = detail.bound.components.length;
|
|
546
|
+
// "not deployed", "no handler" and orphan rows shouldn't show "0 fns".
|
|
547
|
+
const handlerCounts = [];
|
|
548
|
+
if (fnCount > 0) {
|
|
549
|
+
handlerCounts.push(`${fnCount} fn${fnCount === 1 ? '' : 's'}`);
|
|
550
|
+
}
|
|
551
|
+
if (compCount > 0) {
|
|
552
|
+
handlerCounts.push(`${compCount} comp${compCount === 1 ? '' : 's'}`);
|
|
553
|
+
}
|
|
554
|
+
if (handlerCounts.length > 0) {
|
|
555
|
+
parts.push(handlerCounts.join(' / '));
|
|
556
|
+
}
|
|
557
|
+
// Only surface missing-events count on activated rows. On `no handler`,
|
|
558
|
+
// every required event is missing by definition; the status already says so.
|
|
559
|
+
if (detail.status === 'activated' &&
|
|
560
|
+
detail.missing_required_events.length > 0) {
|
|
561
|
+
const n = detail.missing_required_events.length;
|
|
562
|
+
parts.push(`missing ${n} event${n === 1 ? '' : 's'}`);
|
|
563
|
+
}
|
|
564
|
+
return parts.join(' · ');
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* List-mode meta string for an orphan row. The type tag is replaced with
|
|
568
|
+
* the status word since there's no manifest to derive type from.
|
|
569
|
+
*/
|
|
570
|
+
export function orphanListMeta(fnCount, compCount) {
|
|
571
|
+
const parts = ['handler mismatch'];
|
|
572
|
+
const handlerCounts = [];
|
|
573
|
+
if (fnCount > 0) {
|
|
574
|
+
handlerCounts.push(`${fnCount} fn${fnCount === 1 ? '' : 's'}`);
|
|
575
|
+
}
|
|
576
|
+
if (compCount > 0) {
|
|
577
|
+
handlerCounts.push(`${compCount} comp${compCount === 1 ? '' : 's'}`);
|
|
578
|
+
}
|
|
579
|
+
if (handlerCounts.length > 0) {
|
|
580
|
+
parts.push(handlerCounts.join(' / '));
|
|
581
|
+
}
|
|
582
|
+
return parts.join(' · ');
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* `Next steps:` lines per status. Runnable shell commands come first,
|
|
586
|
+
* merchant-UI lines come second prefixed with `(merchant)` so an agent can
|
|
587
|
+
* filter. Empty array when there are no actionable next steps.
|
|
588
|
+
*/
|
|
589
|
+
export function nextStepLines(detail) {
|
|
590
|
+
const lines = [];
|
|
591
|
+
switch (detail.status) {
|
|
592
|
+
case 'not deployed': {
|
|
593
|
+
lines.push('swell app push');
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case 'not activated': {
|
|
597
|
+
const path = detail.native_bindings[0]?.path;
|
|
598
|
+
if (path)
|
|
599
|
+
lines.push(`swell api get '${path}'`);
|
|
600
|
+
if (detail.manifest && detail.type) {
|
|
601
|
+
const section = settingsSection(detail.type);
|
|
602
|
+
const label = extensionLabel(detail.manifest);
|
|
603
|
+
lines.push(`(merchant) Open Settings → ${section} → ${label} → Save`);
|
|
604
|
+
}
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
case 'app id mismatch': {
|
|
608
|
+
const path = detail.native_bindings[0]?.path;
|
|
609
|
+
if (path)
|
|
610
|
+
lines.push(`swell api get '${path}'`);
|
|
611
|
+
lines.push('(merchant) Re-save activation under the correct app');
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
case 'id mismatch': {
|
|
615
|
+
lines.push('swell app push', '(merchant) Re-save activation dialog after redeploy');
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
case 'not selected': {
|
|
619
|
+
lines.push(`swell api get '/settings/taxes'`);
|
|
620
|
+
if (detail.manifest) {
|
|
621
|
+
const label = extensionLabel(detail.manifest);
|
|
622
|
+
lines.push(`(merchant) Settings → Taxes → select ${label} → Save`);
|
|
623
|
+
}
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
case 'gateway missing': {
|
|
627
|
+
const gatewayPath = detail.native_bindings.find((b) => b.path.startsWith('/settings/payments/gateways/'))?.path;
|
|
628
|
+
if (gatewayPath)
|
|
629
|
+
lines.push(`swell api get '${gatewayPath}'`);
|
|
630
|
+
lines.push('(merchant) Re-save activation dialog');
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
case 'not enabled': {
|
|
634
|
+
if (detail.manifest) {
|
|
635
|
+
const label = extensionLabel(detail.manifest);
|
|
636
|
+
lines.push(`(merchant) Settings → Shipping → ${label} → toggle Enabled → Save`);
|
|
637
|
+
}
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
case 'no handler': {
|
|
641
|
+
const firstMissing = detail.missing_required_events[0];
|
|
642
|
+
if (firstMissing) {
|
|
643
|
+
lines.push(`swell create function --extension ${detail.manifest?.id ?? ''} --event ${firstMissing}`);
|
|
644
|
+
}
|
|
645
|
+
// No "swell logs" hint here — `no handler` means zero functions exist.
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
case 'handler mismatch': {
|
|
649
|
+
for (const fn of detail.bound.functions) {
|
|
650
|
+
if (!fn.name)
|
|
651
|
+
continue;
|
|
652
|
+
lines.push(`swell inspect functions ${fn.name}`);
|
|
653
|
+
}
|
|
654
|
+
lines.push('Fix `config.extension` in source and `swell app push`.');
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case 'activated': {
|
|
658
|
+
if (detail.missing_required_events.length === 0)
|
|
659
|
+
break;
|
|
660
|
+
const firstMissing = detail.missing_required_events[0];
|
|
661
|
+
if (firstMissing && detail.manifest) {
|
|
662
|
+
lines.push(`swell create function --extension ${detail.manifest.id} --event ${firstMissing}`);
|
|
663
|
+
}
|
|
664
|
+
for (const fn of detail.bound.functions) {
|
|
665
|
+
if (!fn.name)
|
|
666
|
+
continue;
|
|
667
|
+
lines.push(`swell logs --type function -s '${fn.name}'`);
|
|
668
|
+
}
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return lines;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Section ordering for the list view. Apps render alphabetically; orphan
|
|
676
|
+
* rows land in a single trailing `<orphans>` group.
|
|
677
|
+
*/
|
|
678
|
+
export function listGroupForApp(slug) {
|
|
679
|
+
return { slug, order: 1 };
|
|
680
|
+
}
|
|
681
|
+
export function listGroupForOrphans() {
|
|
682
|
+
return { slug: '<orphans>', label: '<orphans>', order: 2 };
|
|
683
|
+
}
|
|
684
|
+
/** True when a function/component "belongs to" an extension by direct match. */
|
|
685
|
+
export function functionMatchesExtension(fn, extId) {
|
|
686
|
+
return fn.extension === extId;
|
|
687
|
+
}
|
|
688
|
+
export function componentMatchesExtension(comp, extId) {
|
|
689
|
+
return comp.values?.extension === extId;
|
|
690
|
+
}
|