@swell/cli
Version:
Swell's command line interface/utility
425 lines (421 loc) • 19 kB
JavaScript
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;
}
}