UNPKG

@swell/cli

Version:

Swell's command line interface/utility

241 lines (240 loc) 9.42 kB
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}`); } }