@swell/cli
Version:
Swell's command line interface/utility
691 lines (690 loc) • 25.8 kB
JavaScript
import isEqual from 'lodash/isEqual.js';
const APP_SLUG_KEY = /^app\.([^.]+)\.(.+)$/;
const BARE_NAME = /^[\w-]+$/i;
/**
* Parse a `swell inspect extensions` identifier.
*
* Accepts:
* - `app.<slug>.<extId>` → kind 'slug'
* - bare `<extId>` → kind 'name' (requires --app= scope at the call site)
*
* 24-char hex is intentionally NOT a valid form — the synthesized resource has
* no canonical 24-char id. Callers should reject hex shapes upstream so the
* error message can be clear about the cause.
*/
export function parseExtensionKey(input) {
const slugMatch = input.match(APP_SLUG_KEY);
if (slugMatch) {
return { kind: 'slug', appPart: slugMatch[1], extId: slugMatch[2] };
}
if (BARE_NAME.test(input)) {
return { kind: 'name', name: input };
}
return { kind: 'invalid', input };
}
/** Build the column-1 paste-back key for a row. */
export function formatExtensionKey(slug, extId) {
return `app.${slug}.${extId}`;
}
/**
* Resolve a manifest entry to the CLI's type taxonomy. Payment splits on
* `method`: when `method === 'card'` the extension provides a card gateway;
* otherwise it's an alt method. The platform formula at
* schema-api-server/api/admin/models/apps.json:457-458 defaults `method` to
* `id` when unset, so a payment extension whose `id` happens to be `card`
* resolves to `card` even with no explicit `method`.
*/
export function resolveExtensionType(entry) {
if (entry.type === 'shipping')
return 'shipping';
if (entry.type === 'tax')
return 'tax';
const method = entry.method ?? entry.id;
return method === 'card' ? 'card' : 'alt';
}
/** Apply the platform's `if(method, method, id)` formula. */
export function paymentMethodIdFor(entry) {
return entry.method ?? entry.id;
}
/** Shipping records use `app_<appId>_<extId>` as both the row id and the carrier id. */
export function shippingBindId(appId, extId) {
return `app_${appId}_${extId}`;
}
/** Card-gateway records use the same id form as shipping rows. */
export function gatewayBindId(appId, extId) {
return `app_${appId}_${extId}`;
}
/**
* Required events per extension type, in the `<model>/<event>` form that
* function records actually store.
*
* Verified against:
* schema-api-server/server/vault.js:1818,1894 (payment.create_intent)
* schema-api-server/api/com/features/payments/index.js:701-757
* (payment.charge, payment.refund, dispatcher gate)
* schema-api-server/server/vault.js:2304-2322 (card-gateway intent path)
* schema-api-server/api/com/features/orders/shipping.js:411-417,424-431,452-458
* (shipping dispatch + enabled gate)
* schema-api-server/api/com/features/orders/taxes.js:123-176
* (tax dispatch)
* schema-api-server/api/com/features/orders/extensions.test.js:85,95,179,271,290,303,671,704
* (event-name format fixtures)
*/
export function requiredEventsFor(type) {
switch (type) {
case 'card':
case 'alt': {
return [
'payments/payment.create_intent',
'payments/payment.charge',
'payments/payment.refund',
];
}
case 'shipping': {
return ['orders/order.shipping'];
}
case 'tax': {
return ['orders/order.taxes'];
}
}
}
/**
* Strip `before:` / `after:` hook-type prefix from an event identifier.
*
* Both prefixes are platform-supported on function records; bare events
* default to `after`. For required-event coverage we treat any prefix as
* equivalent so that a function declaring `before:payment.charge` covers a
* required `payments/payment.charge`. Cite:
* schema-api-server/api/com/features/orders/extensions.test.js:95.
*/
export function stripHookPrefix(event) {
return event.replace(/^(?:before|after):/, '');
}
/**
* Compute the set of bare event identifiers covered by a function record's
* `model.events` list. The function event format is `<model>/<event>` with
* an optional `before:`/`after:` prefix on the bare event part. Returns the
* full `<model>/<event>` string with hook prefixes stripped from the event.
*/
export function eventsCoveredByFunctions(functions) {
const covered = new Set();
for (const fn of functions) {
for (const ev of fn.model?.events ?? []) {
const slash = ev.indexOf('/');
if (slash < 0) {
covered.add(stripHookPrefix(ev));
continue;
}
const model = ev.slice(0, slash);
const eventName = stripHookPrefix(ev.slice(slash + 1));
covered.add(`${model}/${eventName}`);
}
}
return covered;
}
/** Required events not covered by any of the bound functions. */
export function missingRequiredEvents(type, functions) {
const covered = eventsCoveredByFunctions(functions);
return requiredEventsFor(type).filter((ev) => !covered.has(ev));
}
/** Lift a component record into the synthesized `BoundComponent` shape. */
export function liftBoundComponent(record) {
return {
id: record.id,
name: record.name,
extension: record.values?.extension,
file_path: record.file_path,
};
}
/** Lift a function record into the synthesized `BoundFunction` shape. */
export function liftBoundFunction(record) {
return {
id: record.id,
name: record.name,
extension: record.extension,
enabled: record.enabled,
model: record.model,
};
}
/**
* Compare a local manifest entry against the deployed entry. Uses deep
* equality for change detection and reports differing top-level keys. When
* the entries are equal, returns `null`.
*/
export function diffManifestEntries(local, deployed) {
const keys = new Set([...Object.keys(local), ...Object.keys(deployed)]);
const changed = [];
for (const key of keys) {
if (!isEqual(local[key], deployed[key])) {
changed.push(key);
}
}
if (changed.length === 0)
return null;
return { changed_fields: changed, local, deployed };
}
/**
* Find a binding among the native settings collections.
* Returns null when the entry isn't present at all.
*/
function findPaymentMethod(payments, methodId) {
return payments?.methods?.find((m) => m.id === methodId) ?? null;
}
function findPaymentGateway(payments, gatewayId) {
return payments?.gateways?.find((g) => g.id === gatewayId) ?? null;
}
function findShippingCarrier(shipments, carrierId) {
return shipments?.carriers?.find((c) => c.id === carrierId) ?? null;
}
/**
* Payment-alt activation. The dispatcher gates only on `extension_app_id`
* being set on the method record (verified at
* schema-api-server/api/com/features/payments/index.js:701-721).
*
* The `enabled` boolean on the method record is checkout-visibility only,
* NOT a dispatch gate — a method with `enabled: false` and `extension_app_id`
* set will still dispatch `before:payment.charge` to the extension. Likewise,
* `activated: true` is set during the merchant activation flow but is not a
* dispatch gate. Do NOT require `enabled === true` or `activated === true`
* to compute `activated` status here. This asymmetry with shipping (which DOES
* gate on `enabled`) is intentional.
*/
function deriveAltDispatch(ctx) {
const methodId = paymentMethodIdFor(ctx.manifest);
const expectedGatewayId = gatewayBindId(ctx.appId, ctx.extId);
const method = findPaymentMethod(ctx.payments, methodId);
const gateway = findPaymentGateway(ctx.payments, expectedGatewayId);
const methodChecks = {
extension_app_id: method?.extension_app_id === ctx.appId,
extension_config_id: method?.extension_config_id === ctx.extId,
gateway: method?.gateway === expectedGatewayId,
};
const gatewayChecks = {
extension_app_id: gateway?.extension_app_id === ctx.appId,
extension_config_id: gateway?.extension_config_id === ctx.extId,
};
const native_bindings = [
{
path: `/settings/payments/methods/${methodId}`,
record: method,
field_checks: methodChecks,
},
{
path: `/settings/payments/gateways/${expectedGatewayId}`,
record: gateway,
field_checks: gatewayChecks,
},
];
// Order matters: stop at the first failed check.
if (!method || !method.extension_app_id) {
return { status: 'not activated', native_bindings };
}
if (method.extension_app_id !== ctx.appId) {
return { status: 'app id mismatch', native_bindings };
}
if (method.extension_config_id !== ctx.extId) {
return { status: 'id mismatch', native_bindings };
}
if (method.gateway !== expectedGatewayId ||
!gateway ||
gateway.extension_app_id !== ctx.appId ||
gateway.extension_config_id !== ctx.extId) {
return { status: 'gateway missing', native_bindings };
}
return { status: 'activated', native_bindings };
}
/**
* Card-gateway activation. The merchant selects a gateway under the `card`
* row of `methods[]`; the gateway record must exist with matching ids. The
* dispatcher's intent path goes through `triggerPaymentGatewayExtension`
* (schema-api-server/server/vault.js:2304-2322), and charge/refund go through
* the same `eventHooks.triggerExtension` as alt (index.js:751-757).
*/
function deriveCardDispatch(ctx) {
const expectedGatewayId = gatewayBindId(ctx.appId, ctx.extId);
const cardMethod = findPaymentMethod(ctx.payments, 'card');
const gateway = findPaymentGateway(ctx.payments, expectedGatewayId);
const methodChecks = {
gateway: cardMethod?.gateway === expectedGatewayId,
};
const gatewayChecks = {
extension_app_id: gateway?.extension_app_id === ctx.appId,
extension_config_id: gateway?.extension_config_id === ctx.extId,
};
const native_bindings = [
{
path: `/settings/payments/methods/card`,
record: cardMethod,
field_checks: methodChecks,
},
{
path: `/settings/payments/gateways/${expectedGatewayId}`,
record: gateway,
field_checks: gatewayChecks,
},
];
if (!gateway || !gateway.extension_app_id) {
return { status: 'not activated', native_bindings };
}
if (gateway.extension_app_id !== ctx.appId) {
return { status: 'app id mismatch', native_bindings };
}
if (gateway.extension_config_id !== ctx.extId) {
return { status: 'id mismatch', native_bindings };
}
if (cardMethod?.gateway !== expectedGatewayId) {
return { status: 'not activated', native_bindings };
}
return { status: 'activated', native_bindings };
}
/**
* Shipping activation. Dispatch fires when the carrier row at
* `/settings/shipments/carriers/app_<appId>_<extId>` has both
* `enabled === true` AND `extension_app_id` set. Cite:
* schema-api-server/api/com/features/orders/shipping.js:411-417.
*
* Shipping is the ONLY type where `enabled` is a dispatch gate.
*/
function deriveShippingDispatch(ctx) {
const carrierId = shippingBindId(ctx.appId, ctx.extId);
const carrier = findShippingCarrier(ctx.shipments, carrierId);
const checks = {
extension_app_id: carrier?.extension_app_id === ctx.appId,
extension_config_id: carrier?.extension_config_id === ctx.extId,
enabled: carrier?.enabled === true,
};
const native_bindings = [
{
path: `/settings/shipments/carriers/${carrierId}`,
record: carrier,
field_checks: checks,
},
];
if (!carrier || !carrier.extension_app_id) {
return { status: 'not activated', native_bindings };
}
if (carrier.extension_app_id !== ctx.appId) {
return { status: 'app id mismatch', native_bindings };
}
if (carrier.extension_config_id !== ctx.extId) {
return { status: 'id mismatch', native_bindings };
}
if (carrier.enabled !== true) {
return { status: 'not enabled', native_bindings };
}
return { status: 'activated', native_bindings };
}
/**
* Tax activation. The top-level `/settings/taxes` record carries
* `extension_app_id` and `extension_config_id`; the IDs themselves ARE the
* selection — there is no separate `selected`/`activated` field. Cite:
* schema-api-server/api/com/features/orders/taxes.js:123-144 (gate);
* schema-api-server/api/com/settings/taxes.json:15-19 (schema confirms no
* separate selector).
*
* `not selected` distinguishes "another app owns this binding" from the
* empty `not activated` case.
*/
function deriveTaxDispatch(ctx) {
const taxes = ctx.taxes ?? {};
const checks = {
extension_app_id: taxes.extension_app_id === ctx.appId,
extension_config_id: taxes.extension_config_id === ctx.extId,
};
const native_bindings = [
{
path: `/settings/taxes`,
record: taxes,
field_checks: checks,
},
];
if (!taxes.extension_app_id) {
return { status: 'not activated', native_bindings };
}
if (taxes.extension_app_id !== ctx.appId) {
return { status: 'not selected', native_bindings };
}
if (taxes.extension_config_id !== ctx.extId) {
return { status: 'id mismatch', native_bindings };
}
return { status: 'activated', native_bindings };
}
function deriveDispatch(type, ctx) {
switch (type) {
case 'alt': {
return deriveAltDispatch(ctx);
}
case 'card': {
return deriveCardDispatch(ctx);
}
case 'shipping': {
return deriveShippingDispatch(ctx);
}
case 'tax': {
return deriveTaxDispatch(ctx);
}
}
}
/** Build the synthesized envelope for a regular (non-orphan) extension row. */
export function buildExtensionDetail(input) {
if (input.notDeployed) {
if (!input.manifest) {
throw new Error('buildExtensionDetail: notDeployed=true requires a local manifest entry');
}
return {
status: 'not deployed',
action_owner: 'dev',
action: 'swell app push',
type: resolveExtensionType(input.manifest),
manifest: input.manifest,
native_bindings: [],
bound: {
functions: input.functions.map((f) => liftBoundFunction(f)),
components: input.components.map((c) => liftBoundComponent(c)),
},
required_events: requiredEventsFor(resolveExtensionType(input.manifest)),
missing_required_events: missingRequiredEvents(resolveExtensionType(input.manifest), input.functions),
local_diff: input.localDiff ?? null,
};
}
if (!input.manifest) {
throw new Error('buildExtensionDetail: missing manifest for a deployed extension');
}
const type = resolveExtensionType(input.manifest);
const dispatch = deriveDispatch(type, {
appId: input.appId,
extId: input.extId,
manifest: input.manifest,
payments: input.payments,
shipments: input.shipments,
taxes: input.taxes,
functions: input.functions,
components: input.components,
});
// Status answers "can dispatch happen at all?" — partial coverage stays
// `activated` because dispatch IS happening for the events that have
// handlers; `missing_required_events` carries the completeness signal as a
// separate, parseable field. Only `no handler` (zero matching functions for
// the binding) escalates to a status.
const { native_bindings } = dispatch;
let { status } = dispatch;
if (status === 'activated' && input.functions.length === 0) {
status = 'no handler';
}
const required = requiredEventsFor(type);
const missing = missingRequiredEvents(type, input.functions);
const { action, action_owner } = describeAction(status, {
type,
appId: input.appId,
extId: input.extId,
manifest: input.manifest,
});
return {
status,
action_owner,
action,
type,
manifest: input.manifest,
native_bindings,
bound: {
functions: input.functions.map((f) => liftBoundFunction(f)),
components: input.components.map((c) => liftBoundComponent(c)),
},
required_events: required,
missing_required_events: missing,
local_diff: input.localDiff ?? null,
};
}
/** Build the synthesized envelope for an orphan row. */
export function buildOrphanDetail(input) {
return {
status: 'handler mismatch',
action_owner: 'dev',
action: 'Fix `config.extension` in source and `swell app push`.',
type: null,
manifest: null,
native_bindings: [],
bound: {
functions: input.functions.map((f) => liftBoundFunction(f)),
components: input.components.map((c) => liftBoundComponent(c)),
},
required_events: [],
missing_required_events: [],
local_diff: null,
};
}
function settingsSection(type) {
switch (type) {
case 'card':
case 'alt': {
return 'Payments';
}
case 'shipping': {
return 'Shipping';
}
case 'tax': {
return 'Taxes';
}
}
}
function extensionLabel(manifest) {
return manifest.name ?? manifest.id;
}
function describeAction(status, ctx) {
const section = settingsSection(ctx.type);
const label = extensionLabel(ctx.manifest);
switch (status) {
case 'not deployed': {
return { action: 'swell app push', action_owner: 'dev' };
}
case 'not activated': {
return {
action: `Open Settings → ${section} → ${label} → Save`,
action_owner: 'merchant',
};
}
case 'app id mismatch': {
return {
action: 'Another app owns this binding; activate this one if intended',
action_owner: 'merchant',
};
}
case 'id mismatch': {
return {
action: 'Re-save activation dialog after redeploy',
action_owner: 'merchant',
};
}
case 'not selected': {
return {
action: `Settings → Taxes → select ${label} → Save`,
action_owner: 'merchant',
};
}
case 'gateway missing': {
return {
action: 'Re-save activation dialog',
action_owner: 'merchant',
};
}
case 'not enabled': {
return {
action: `Settings → Shipping → ${label} → toggle Enabled → Save`,
action_owner: 'merchant',
};
}
case 'no handler': {
return {
action: `swell create function --extension ${ctx.extId} --event ${requiredEventsFor(ctx.type)[0]}`,
action_owner: 'dev',
};
}
case 'handler mismatch': {
return {
action: 'Fix `config.extension` in source and `swell app push`.',
action_owner: 'dev',
};
}
case 'activated': {
return { action: null, action_owner: null };
}
}
}
/**
* List-mode meta string for one row. Returns `undefined` when the row has
* nothing surface-worthy beyond the type tag (which is always present).
*/
export function listMetaFor(detail) {
const parts = [];
if (detail.type)
parts.push(detail.type);
if (detail.status !== 'activated') {
parts.push(detail.status);
}
const fnCount = detail.bound.functions.length;
const compCount = detail.bound.components.length;
// "not deployed", "no handler" and orphan rows shouldn't show "0 fns".
const handlerCounts = [];
if (fnCount > 0) {
handlerCounts.push(`${fnCount} fn${fnCount === 1 ? '' : 's'}`);
}
if (compCount > 0) {
handlerCounts.push(`${compCount} comp${compCount === 1 ? '' : 's'}`);
}
if (handlerCounts.length > 0) {
parts.push(handlerCounts.join(' / '));
}
// Only surface missing-events count on activated rows. On `no handler`,
// every required event is missing by definition; the status already says so.
if (detail.status === 'activated' &&
detail.missing_required_events.length > 0) {
const n = detail.missing_required_events.length;
parts.push(`missing ${n} event${n === 1 ? '' : 's'}`);
}
return parts.join(' · ');
}
/**
* List-mode meta string for an orphan row. The type tag is replaced with
* the status word since there's no manifest to derive type from.
*/
export function orphanListMeta(fnCount, compCount) {
const parts = ['handler mismatch'];
const handlerCounts = [];
if (fnCount > 0) {
handlerCounts.push(`${fnCount} fn${fnCount === 1 ? '' : 's'}`);
}
if (compCount > 0) {
handlerCounts.push(`${compCount} comp${compCount === 1 ? '' : 's'}`);
}
if (handlerCounts.length > 0) {
parts.push(handlerCounts.join(' / '));
}
return parts.join(' · ');
}
/**
* `Next steps:` lines per status. Runnable shell commands come first,
* merchant-UI lines come second prefixed with `(merchant)` so an agent can
* filter. Empty array when there are no actionable next steps.
*/
export function nextStepLines(detail) {
const lines = [];
switch (detail.status) {
case 'not deployed': {
lines.push('swell app push');
break;
}
case 'not activated': {
const path = detail.native_bindings[0]?.path;
if (path)
lines.push(`swell api get '${path}'`);
if (detail.manifest && detail.type) {
const section = settingsSection(detail.type);
const label = extensionLabel(detail.manifest);
lines.push(`(merchant) Open Settings → ${section} → ${label} → Save`);
}
break;
}
case 'app id mismatch': {
const path = detail.native_bindings[0]?.path;
if (path)
lines.push(`swell api get '${path}'`);
lines.push('(merchant) Re-save activation under the correct app');
break;
}
case 'id mismatch': {
lines.push('swell app push', '(merchant) Re-save activation dialog after redeploy');
break;
}
case 'not selected': {
lines.push(`swell api get '/settings/taxes'`);
if (detail.manifest) {
const label = extensionLabel(detail.manifest);
lines.push(`(merchant) Settings → Taxes → select ${label} → Save`);
}
break;
}
case 'gateway missing': {
const gatewayPath = detail.native_bindings.find((b) => b.path.startsWith('/settings/payments/gateways/'))?.path;
if (gatewayPath)
lines.push(`swell api get '${gatewayPath}'`);
lines.push('(merchant) Re-save activation dialog');
break;
}
case 'not enabled': {
if (detail.manifest) {
const label = extensionLabel(detail.manifest);
lines.push(`(merchant) Settings → Shipping → ${label} → toggle Enabled → Save`);
}
break;
}
case 'no handler': {
const firstMissing = detail.missing_required_events[0];
if (firstMissing) {
lines.push(`swell create function --extension ${detail.manifest?.id ?? ''} --event ${firstMissing}`);
}
// No "swell logs" hint here — `no handler` means zero functions exist.
break;
}
case 'handler mismatch': {
for (const fn of detail.bound.functions) {
if (!fn.name)
continue;
lines.push(`swell inspect functions ${fn.name}`);
}
lines.push('Fix `config.extension` in source and `swell app push`.');
break;
}
case 'activated': {
if (detail.missing_required_events.length === 0)
break;
const firstMissing = detail.missing_required_events[0];
if (firstMissing && detail.manifest) {
lines.push(`swell create function --extension ${detail.manifest.id} --event ${firstMissing}`);
}
for (const fn of detail.bound.functions) {
if (!fn.name)
continue;
lines.push(`swell logs --type function -s '${fn.name}'`);
}
break;
}
}
return lines;
}
/**
* Section ordering for the list view. Apps render alphabetically; orphan
* rows land in a single trailing `<orphans>` group.
*/
export function listGroupForApp(slug) {
return { slug, order: 1 };
}
export function listGroupForOrphans() {
return { slug: '<orphans>', label: '<orphans>', order: 2 };
}
/** True when a function/component "belongs to" an extension by direct match. */
export function functionMatchesExtension(fn, extId) {
return fn.extension === extId;
}
export function componentMatchesExtension(comp, extId) {
return comp.values?.extension === extId;
}