UNPKG

@swell/cli

Version:

Swell's command line interface/utility

425 lines (421 loc) 19 kB
import * as fs from 'node:fs/promises'; import { FetchError } from 'node-fetch'; import { inspectResourceBaseArgs, inspectResourceBaseFlags, } from '../../inspect-resource-command.js'; import { getCurrentAppSlugId, hasAppContext } from '../../lib/apps/index.js'; import { resolveInspectScope, ScopeError, } from '../../lib/apps/inspect-scope.js'; import { HEX24, isObjectId } from '../../lib/apps/object-id.js'; import { resolveAppId } from '../../lib/apps/resolve.js'; import { default as localConfig } from '../../lib/config.js'; import { buildExtensionDetail, buildOrphanDetail, componentMatchesExtension, diffManifestEntries, formatExtensionKey, functionMatchesExtension, listGroupForApp, listGroupForOrphans, listMetaFor, nextStepLines, orphanListMeta, parseExtensionKey, } from '../../lib/inspect/extensions.js'; import { renderKeyMetaTable } from '../../lib/inspect/table.js'; import { SwellCommand } from '../../swell-command.js'; export default class Extensions extends SwellCommand { static summary = 'Platform extensions with activation chain.'; static description = `Lists declared payment, shipping, and tax extensions across deployed apps, with status (activated, not activated, gateway missing, etc.) and bound function/component counts. Pass --app=<slug> or --app=. (current swell.json) to scope. Pass an identifier to view a single extension as a synthesized JSON envelope (manifest + native_bindings + bound functions/components + required vs missing events), followed by a "Next steps" footer mixing runnable commands and merchant-UI instructions (prefixed "(merchant)"). Identifier forms: app.<app>.<extId> — full paste-back key (list column 1) <extId> — requires --app= scope 24-char hex id is not accepted: extensions are a synthesized resource with no canonical record id. `; static args = { ...inspectResourceBaseArgs }; static flags = { ...inspectResourceBaseFlags }; static examples = [ 'swell inspect extensions', 'swell inspect extensions --app=my-app', 'swell inspect extensions app.my-app.revolut', 'swell inspect extensions revolut --app=my-app', 'swell inspect extensions app.my-app.revolut --json', 'swell inspect extensions --live', ]; async run() { const { args, flags } = await this.parse(Extensions); if (flags.json && !args.identifier) { this.error('--json is only valid in detail mode. Pass an identifier to inspect a single extension, or drop --json for the list view.', { exit: 1 }); } if (!flags.live) { await this.api.setEnv('test'); } const scope = await resolveInspectScope({ resolveAppId: (slug) => resolveAppId(this.api, slug), readCurrentAppSlug: () => getCurrentAppSlugId(), }, { app: flags.app }); if (args.identifier) { await this.showDetail(args.identifier, scope, flags); return; } await this.showList(scope, flags); } async catch(error) { if (error instanceof FetchError) { const message = `Could not connect to Swell API. Please try again later: ${error.message}`; return this.error(message, { exit: 2, code: error.code }); } if (error instanceof ScopeError) { return this.error(error.message, { exit: 1 }); } return this.error(error.message, { exit: 1 }); } /** * List mode is bounded to two parallel round-trip phases regardless of app * count: phase 1 fans `/client/apps`, the three settings singletons, and a * single `/data/:functions` filter; phase 2 fans `/apps/<id>/configs?type=component` * only for apps with a non-empty `extensions[]`. * * `include` / `aggregate` are NOT used here — the CLI's `node-fetch` * transport cannot transmit GET-with-body, and URL-form transmission of * those payloads is silently dropped server-side. Verified empirically * against the live admin API. Don't reach for those primitives. */ async showList(scope, flags) { const fnQuery = { extension: { $exists: true } }; if (scope.appId) { fnQuery.app_id = scope.appId; } const [installedAppsResp, payments, shipments, taxes, functionsResp] = await Promise.all([ this.api.get({ adminPath: '/client/apps' }), this.fetchSafe('/data/settings/payments'), this.fetchSafe('/data/settings/shipments'), this.fetchSafe('/data/settings/taxes'), this.api.getAll({ adminPath: '/data/:functions' }, { query: fnQuery }), ]); const installed = (installedAppsResp?.results ?? []); const appsScoped = scope.appId ? installed.filter((a) => a.app_id === scope.appId) : installed; const appSlugById = {}; for (const a of appsScoped) { const slug = slugFromInstalled(a); if (slug) appSlugById[a.app_id] = slug; } // Phase 2: components per app with a non-empty extensions[]. const appsWithExtensions = appsScoped.filter((a) => (a.app?.extensions ?? []).length > 0); const componentResponses = await Promise.all(appsWithExtensions.map((a) => this.api .getAll({ adminPath: `/apps/${a.app_id}/configs` }, { query: { type: 'component' } }) .catch(() => ({ results: [] })))); const componentsByAppId = {}; for (const [idx, a] of appsWithExtensions.entries()) { componentsByAppId[a.app_id] = componentResponses[idx]?.results ?? []; } const allFunctions = (functionsResp?.results ?? []); // Build per-app, per-extension row state. const rowStates = []; const orphans = new Map(); for (const installedApp of appsScoped) { const appId = installedApp.app_id; const appSlug = appSlugById[appId] ?? appId; const declared = installedApp.app?.extensions ?? []; const declaredIds = new Set(declared.map((e) => e.id)); const appFunctions = allFunctions.filter((f) => f.app_id === appId); const appComponents = componentsByAppId[appId] ?? []; for (const entry of declared) { rowStates.push({ appId, appSlug, manifest: entry, functions: appFunctions.filter((f) => functionMatchesExtension(f, entry.id)), components: appComponents.filter((c) => componentMatchesExtension(c, entry.id)), }); } for (const fn of appFunctions) { if (!fn.extension) continue; if (declaredIds.has(fn.extension)) continue; const key = `${appId}::${fn.extension}`; const existing = orphans.get(key); if (existing) { existing.functions.push(fn); } else { orphans.set(key, { appId, appSlug, unresolvedId: fn.extension, functions: [fn], components: [], }); } } for (const comp of appComponents) { const ext = comp.values?.extension; if (!ext) continue; if (declaredIds.has(ext)) continue; const key = `${appId}::${ext}`; const existing = orphans.get(key); if (existing) { existing.components.push(comp); } else { orphans.set(key, { appId, appSlug, unresolvedId: ext, functions: [], components: [comp], }); } } } // --app=. surfaces local-only extensions as `not deployed` rows. if (flags.app === '.') { const local = await readLocalManifest(); const localId = local?.id; const localExtensions = local?.extensions ?? []; if (localId) { const installedForLocal = appsScoped.find((a) => slugFromInstalled(a) === localId || a.app_id === localId); const appId = installedForLocal?.app_id ?? localId; const deployedIds = new Set((installedForLocal?.app?.extensions ?? []).map((e) => e.id)); for (const entry of localExtensions) { if (deployedIds.has(entry.id)) continue; rowStates.push({ appId, appSlug: localId, manifest: entry, functions: [], components: [], notDeployed: true, }); } } } this.printPreamble(flags.live ?? false); if (rowStates.length === 0 && orphans.size === 0) { this.log(); this.log(' (no extensions found)'); this.log(); return; } rowStates.sort((a, b) => { const cmp = a.appSlug.localeCompare(b.appSlug); return cmp === 0 ? a.manifest.id.localeCompare(b.manifest.id) : cmp; }); const rows = rowStates.map((state) => { const detail = buildExtensionDetail({ appId: state.appId, appSlug: state.appSlug, extId: state.manifest.id, manifest: state.manifest, notDeployed: state.notDeployed, payments, shipments, taxes, functions: state.functions, components: state.components, }); return { key: formatExtensionKey(state.appSlug, state.manifest.id), meta: listMetaFor(detail), group: listGroupForApp(state.appSlug), }; }); const orphanRows = [...orphans.values()] .sort((a, b) => { const cmp = a.appSlug.localeCompare(b.appSlug); return cmp === 0 ? a.unresolvedId.localeCompare(b.unresolvedId) : cmp; }) .map((state) => ({ key: formatExtensionKey(state.appSlug, state.unresolvedId), meta: orphanListMeta(state.functions.length, state.components.length), group: listGroupForOrphans(), })); this.log(); for (const line of renderKeyMetaTable([...rows, ...orphanRows])) { this.log(line); } this.log(); this.log('Run "swell inspect extensions <key>" to view an extension.'); this.log(); } /** * Detail mode is bounded to one round-trip's worth of latency: the install * record (or `/apps/<id>` fallback), the relevant settings singleton(s), * `/data/:functions?where[app_id]=<id>&where[extension]=<extId>`, and * `/apps/<id>/configs?type=component` — all in parallel. */ async showDetail(identifier, scope, flags) { if (HEX24.test(identifier)) { this.error(`Extensions are a synthesized resource with no 24-char id. ` + `Pass app.<slug>.<extId> or a bare extension id with --app=.`, { exit: 1 }); } const parsed = parseExtensionKey(identifier); let appId; let appSlug; let extId; switch (parsed.kind) { case 'slug': { const isHex = isObjectId(parsed.appPart); appId = isHex ? parsed.appPart : await resolveAppId(this.api, parsed.appPart); appSlug = isHex ? parsed.appPart : parsed.appPart; extId = parsed.extId; break; } case 'name': { if (!scope.appId) { this.error(`Bare extension id '${identifier}' requires --app=<slug> or --app=. to scope. ` + `Alternatively, pass a full key (app.<slug>.<extId>).`, { exit: 1 }); } appId = scope.appId; appSlug = scope.appSlug ?? scope.appId; extId = parsed.name; break; } case 'invalid': { this.error(`Invalid extension identifier '${identifier}'. ` + `Expected bare id or app.<slug>.<extId>.`, { exit: 1 }); } } const [installedAppsResp, payments, shipments, taxes, functionsResp] = await Promise.all([ this.api.get({ adminPath: '/client/apps' }, { query: {} }), this.fetchSafe('/data/settings/payments'), this.fetchSafe('/data/settings/shipments'), this.fetchSafe('/data/settings/taxes'), this.api.getAll({ adminPath: '/data/:functions' }, { query: { app_id: appId, extension: extId } }), ]); const installed = (installedAppsResp?.results ?? []); const installedApp = installed.find((a) => a.app_id === appId); let manifest = installedApp?.app?.extensions?.find((e) => e.id === extId) ?? null; // Fallback to /apps/<id> if the install record's nested manifest is absent. if (!manifest) { const fallback = await this.api .get({ adminPath: `/apps/${appId}` }) .catch(() => null); manifest = fallback?.extensions?.find((e) => e.id === extId); manifest ??= null; } const componentsResp = await this.api .getAll({ adminPath: `/apps/${appId}/configs` }, { query: { type: 'component' } }) .catch(() => ({ results: [] })); const allFunctions = (functionsResp?.results ?? []); const allComponents = (componentsResp?.results ?? []); const declaredIds = new Set((installedApp?.app?.extensions ?? []).map((e) => e.id)); let detail; if (manifest) { const fnsForExtension = allFunctions.filter((f) => functionMatchesExtension(f, extId)); const compsForExtension = allComponents.filter((c) => componentMatchesExtension(c, extId)); let localDiff = null; if (flags.app === '.' && (await hasAppContext())) { const local = await readLocalManifest(); const localEntry = local?.extensions?.find((e) => e.id === extId); if (localEntry) { localDiff = diffManifestEntries(localEntry, manifest); } } detail = buildExtensionDetail({ appId, appSlug, extId, manifest, payments, shipments, taxes, functions: fnsForExtension, components: compsForExtension, localDiff, }); } else { // Possible orphan: function/component exists with this `extension` value // but no manifest entry declares it. Surface as the orphan envelope only // when we actually find handlers; otherwise it's a "not found" lookup. const orphanFns = allFunctions.filter((f) => f.extension === extId); const orphanComps = allComponents.filter((c) => c.values?.extension === extId); if (orphanFns.length === 0 && orphanComps.length === 0) { this.error(`No extension '${extId}' declared by app '${appSlug}', and no handlers reference it. ` + `List available: swell inspect extensions${scope.appSlug ? ` --app=${scope.appSlug}` : ''}`, { exit: 1 }); } // Local-not-deployed: under --app=. with a local manifest entry that // hasn't been deployed yet, surface `not deployed` rather than orphan. let localEntry = null; if (flags.app === '.' && (await hasAppContext())) { const local = await readLocalManifest(); localEntry = local?.extensions?.find((e) => e.id === extId) ?? null; } detail = localEntry && !declaredIds.has(extId) ? buildExtensionDetail({ appId, appSlug, extId, manifest: localEntry, notDeployed: true, payments, shipments, taxes, functions: orphanFns, components: orphanComps, }) : buildOrphanDetail({ appId, appSlug, unresolvedId: extId, functions: orphanFns, components: orphanComps, }); } this.emitDetail(detail, flags); } /** * `extensions` emits a synthesized envelope rather than a raw API record — * other inspect subcommands print one upstream record, but here the JSON * nests `manifest`, `native_bindings[].record`, `bound.functions[]`, and * `bound.components[]` under shared status fields. * * The `Next steps:` footer mixes runnable shell commands and merchant-UI * lines (prefixed `(merchant)`). The structured `action` and `action_owner` * fields above give an agent a parseable signal independent of the footer. */ emitDetail(detail, flags) { this.log(JSON.stringify(detail, null, 2)); if (flags.json) { return; } const lines = nextStepLines(detail); if (lines.length === 0) { return; } this.log(); this.log('Next steps:'); for (const line of lines) { this.log(` ${line}`); } this.log(); } async fetchSafe(adminPath) { try { const result = await this.api.get({ adminPath }); return (result ?? null); } catch { return null; } } printPreamble(live) { const store = localConfig.getDefaultStore(); const envLabel = live ? '[live]' : '[test]'; this.log(`Extensions in '${store}' ${envLabel}`); } } function slugFromInstalled(a) { return a.app_private_id?.replace(/^_/, '') || a.app_public_id; } async function readLocalManifest() { if (!(await hasAppContext())) return null; try { const raw = await fs.readFile('swell.json', 'utf8'); return JSON.parse(raw); } catch { return null; } }