@swell/cli
Version:
Swell's command line interface/utility
241 lines (240 loc) • 9.42 kB
JavaScript
import { Args, Flags } from '@oclif/core';
import { FetchError } from 'node-fetch';
import { getCurrentAppSlugId } from './lib/apps/index.js';
import { classifyIdentifier, resolveInspectScope, ScopeError, } from './lib/apps/inspect-scope.js';
import { HEX24 } from './lib/apps/object-id.js';
import { buildAppSlugMap, resolveAppId } from './lib/apps/resolve.js';
import { default as localConfig } from './lib/config.js';
import { renderKeyMetaTable, } from './lib/inspect/table.js';
import { SwellCommand } from './swell-command.js';
export const inspectResourceBaseArgs = {
identifier: Args.string({
description: 'Resource identifier — see "Identifier forms" below.',
required: false,
}),
};
export const inspectResourceBaseFlags = {
app: Flags.string({
description: 'Filter by app slug, or "." for current swell.json.',
}),
live: Flags.boolean({
description: 'Use live environment (default: test).',
default: false,
}),
json: Flags.boolean({
description: 'Emit pure JSON without the "Next steps" footer. Detail mode only.',
default: false,
}),
yes: Flags.boolean({
char: 'y',
description: 'No-op; accepted for agent compatibility.',
default: false,
hidden: true,
}),
};
export class InspectResourceCommand extends SwellCommand {
/**
* Record field used to look up a bare-name identifier. Defaults to 'name';
* override for resources that identify records by a different key
* (e.g. webhooks use 'alias').
*/
nameField = 'name';
/**
* Return the strict paste-back identifier for a record. Used as column 1
* content so every row is individually pasteable back into a detail query.
*
* Default implementation:
* - no `app_id` → `record.id` (store-level records have no app-slug form)
* - no name/alias → `record.id` (degenerate, no meaningful slug)
* - otherwise → `app.<slug>.<name>`, using the raw hex when the slug map
* has no entry (still pasteable via the hex appPart branch)
*/
keyFor(record, appSlugById) {
if (!record.app_id) {
return record.id ?? '-';
}
const name = record[this.nameField];
if (!name) {
return record.id ?? '-';
}
const slug = appSlugById[record.app_id] ?? record.app_id;
return `app.${slug}.${name}`;
}
/**
* Optional compact meta rendered after the key. Only non-default state
* should surface — enabled rows with no failures return undefined so the
* listing stays to one key per line.
*/
metaFor(_record, _appSlugById) {
return undefined;
}
/**
* Post-process raw list results before rendering. Default is identity;
* override to drop noise that the API returns unconditionally (e.g.
* deprecated system records that have no server-side filter equivalent).
* Detail-mode lookups bypass this hook.
*/
filterListResults(results) {
return results;
}
/**
* Return the section grouping for a list record in global mode. Override
* to change labels (e.g. content treats no-app_id rows as `<custom>`).
*/
groupForRecord(record, appSlugById) {
if (!record.app_id) {
return { slug: '<store>', order: 0 };
}
const resolved = appSlugById[record.app_id];
if (!resolved) {
return { slug: '<not resolved>', order: 2 };
}
return { slug: resolved };
}
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 });
}
async runInspect(parsed) {
const { args, flags } = parsed;
if (flags.json && !args.identifier) {
this.error('--json is only valid in detail mode. Pass an identifier to inspect a single record, 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 showList(scope, flags) {
const results = await this.getListResults(scope, flags);
const filtered = this.filterListResults(results);
const appSlugById = await this.getAppSlugById(scope);
this.printPreamble(flags.live ?? false);
if (filtered.length === 0) {
this.log();
this.log(` (no ${this.resourceLabel.toLowerCase()} found)`);
this.log();
return;
}
const rows = filtered.map((r) => ({
key: this.keyFor(r, appSlugById),
meta: this.metaFor(r, appSlugById),
group: scope.appId ? undefined : this.groupForRecord(r, appSlugById),
}));
this.log();
for (const line of renderKeyMetaTable(rows)) {
this.log(line);
}
this.afterList(scope, flags);
this.log();
this.log(`Run "swell inspect ${this.commandName} <key>" to view a ${this.resourceLabelSingular}.`);
this.log();
}
async getListResults(scope, _flags) {
const { results } = await this.api.getAll({ adminPath: this.adminPath }, { query: scope.query });
return results ?? [];
}
async getAppSlugById(scope) {
if (scope.appId) {
return scope.appSlug ? { [scope.appId]: scope.appSlug } : {};
}
return buildAppSlugMap(this.api);
}
afterList(_scope, _flags) { }
async showDetail(identifier, scope, flags) {
const kind = classifyIdentifier(identifier);
let record = null;
switch (kind.kind) {
case 'id': {
record = await this.api.get({
adminPath: `${this.adminPath}/${kind.id}`,
});
break;
}
case 'slug': {
const isHex = HEX24.test(kind.appPart);
const appId = isHex
? kind.appPart
: await resolveAppId(this.api, kind.appPart);
record = await this.lookupByAppAndName(appId, kind.name);
break;
}
case 'name': {
if (!scope.appId) {
this.error(`Bare ${this.resourceLabelSingular} name '${identifier}' requires --app=<slug> or --app=. to scope. ` +
`Alternatively, pass a full slug (app.<app>.<name>) or 24-char id.`, { exit: 1 });
}
record = await this.lookupByAppAndName(scope.appId, identifier);
break;
}
case 'invalid': {
this.error(`Invalid ${this.resourceLabelSingular} identifier '${identifier}'. ` +
`Expected bare name, full slug (app.<app>.<name>), or 24-char id.`, { exit: 1 });
}
}
if (!record) {
this.throwNotFound(identifier);
}
this.emitDetail(record, flags);
}
/**
* Per-subclass detail-mode hints. Return runnable commands pointing to
* runtime/sibling addresses for the inspected record. Interpolated values
* must derive from the record's own fields, not constants — that keeps the
* hint self-consistent with column 1 and absorbs platform-side renames.
* Default is no hints.
*/
hints(_record) {
return [];
}
/**
* Emit a fetched detail record. Prints JSON unconditionally; in non-`--json`
* mode follows with a `Next steps:` block of `hints(record)` lines.
*
* `--json` is the machine-readable contract — hints belong in the same
* stream as the human-facing output and are simply omitted there.
*/
emitDetail(record, flags) {
this.log(JSON.stringify(record, null, 2));
if (flags.json) {
return;
}
const lines = this.hints(record);
if (lines.length === 0) {
return;
}
this.log();
this.log('Next steps:');
for (const line of lines) {
this.log(` ${line}`);
}
this.log();
}
async lookupByAppAndName(appId, name) {
const response = await this.api.get({ adminPath: this.adminPath }, { query: { app_id: appId, [this.nameField]: name, limit: 1 } });
return response?.results?.[0] ?? null;
}
printPreamble(live) {
const store = localConfig.getDefaultStore();
const envLabel = live ? '[live]' : '[test]';
this.log(`${this.resourceLabel} in '${store}' ${envLabel}`);
}
throwNotFound(identifier) {
throw new Error(`No ${this.resourceLabelSingular} found for '${identifier}'.\n` +
`List available: swell inspect ${this.commandName}`);
}
}