UNPKG

@swell/cli

Version:

Swell's command line interface/utility

691 lines (690 loc) 25.8 kB
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; }