UNPKG

@swell/cli

Version:

Swell's command line interface/utility

291 lines (289 loc) 11.4 kB
import { Args, Flags } from '@oclif/core'; import { FetchError } from 'node-fetch'; import { getCurrentAppSlugId } from '../../lib/apps/index.js'; import { resolveInspectScope, } from '../../lib/apps/inspect-scope.js'; import { resolveAppId } from '../../lib/apps/resolve.js'; import { slugFromApp } from '../../lib/apps/slug.js'; import { default as localConfig } from '../../lib/config.js'; import { SwellCommand } from '../../swell-command.js'; export default class Models extends SwellCommand { static summary = 'Collection models and fields (hierarchical).'; static description = `Lists all collections grouped by type. Pass --app=<slug> or --app=. (current swell.json) to filter to one app's models. Pass a collection path to view a single model as JSON. Paths are fully qualified, so --app is ignored in detail mode. `; static args = { 'collection-path': Args.string({ description: "Optional collection path (must start with '/'). Example: /products, /apps/myapp/orders", required: false, }), }; static flags = { 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, }), }; static examples = [ 'swell inspect models', 'swell inspect models --app=my-app', 'swell inspect models --live', 'swell inspect models /products', 'swell inspect models /content/blogs', 'swell inspect models /apps/myapp/orders', 'swell inspect models /products --json', ]; async run() { const { args, flags } = await this.parse(Models); const { 'collection-path': path } = args; const { app, json, live } = flags; if (json && !path) { this.error('--json is only valid in detail mode. Pass a collection path to inspect a single model, or drop --json for the list view.', { exit: 1 }); } if (!live) { await this.api.setEnv('test'); } // Detail mode: show schema for a specific model. Paths are fully // qualified, so --app is ignored here and we skip scope resolution. if (path) { if (!path.startsWith('/')) { throw new Error(`Collection path must start with '/'. Did you mean '/${path}'?`); } await this.showModelDetail(path, json); return; } const scope = await resolveInspectScope({ resolveAppId: (slug) => resolveAppId(this.api, slug), readCurrentAppSlug: () => getCurrentAppSlugId(), }, { app }); const store = localConfig.getDefaultStore(); await this.listModels(store, live, scope); } 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 }); } return this.error(error.message, { exit: 1 }); } async listModels(store, live, scope) { const { results: apps } = await this.api.get({ adminPath: '/apps', }); const allAppsById = Object.fromEntries(apps.map((app) => [app.id, app])); const appsById = scope.appId ? allAppsById[scope.appId] ? { [scope.appId]: allAppsById[scope.appId] } : {} : allAppsById; const baseModelsQuery = { deprecated: { $ne: true }, development: { $ne: true }, abstract: { $ne: true }, reserved: { $ne: true }, }; const modelsQuery = scope.appId ? { ...baseModelsQuery, app_id: scope.appId } : { ...baseModelsQuery, $or: [ { app_id: { $exists: false } }, { app_id: { $in: Object.keys(allAppsById) } }, ], }; const { results: models } = await this.api.getAll({ adminPath: '/data/:models' }, { query: modelsQuery }); const standardModels = []; const modelsByAppId = {}; for (const model of models) { if (model.app_id) { modelsByAppId[model.app_id] ||= []; modelsByAppId[model.app_id].push(model); } else { standardModels.push(model); } } this.log(`Collections in '${store}' ${live ? '[live]' : '[test]'}`); if (!scope.appId) { this.log(); this.showStandardModels(standardModels); } await this.showAppModels(appsById, modelsByAppId); this.log(); this.log('Run "swell inspect models <path>" to view a collection model.'); this.log(); } showStandardModels(models) { this.showModels(models, '── standard'); } async showAppModels(appsById, modelsByAppId) { const currentAppSlugId = await getCurrentAppSlugId(); for (const [appId, app] of Object.entries(appsById)) { const models = modelsByAppId[appId]; if (models) { const appSlugId = slugFromApp(app); const pathPrefix = `/apps/${appSlugId}`; const label = `── ${appSlugId}${currentAppSlugId === appSlugId ? ' (current app)' : ''}`; this.log(); this.showModels(models, label, { pathPrefix }); } } } showModels(models, label, options) { this.log(label); const sortedModels = [...models].sort((a, b) => { const pathA = this.getModelPath(a, options?.pathPrefix); const pathB = this.getModelPath(b, options?.pathPrefix); return pathA.localeCompare(pathB); }); for (const model of sortedModels) { const modelPath = this.getModelPath(model, options?.pathPrefix); this.log(` ${modelPath}`); const sortedSubModels = Object.values(model.fields) .filter((field) => field.type === 'collection') .map((field) => field.name) .sort((a, b) => a.localeCompare(b)); for (const sub of sortedSubModels) { const subPath = options?.pathPrefix ? `${options.pathPrefix}/${sub}` : `/${sub}`; this.log(` ${subPath}`); } } } getModelPath(model, pathPrefix) { if (pathPrefix) { return `${pathPrefix}/${model.name}`; } if (model.namespace) { return `/${model.namespace}/${model.name}`; } return `/${model.name}`; } async showModelDetail(path, json) { const [modelPath, subModelKey, namespace] = await this.resolveModelPath(path); let model = null; if (namespace) { // For namespaced models, fetch by name and namespace filter // since the API may return a different model with the same name const modelName = modelPath.slice(1); // Remove leading / const { results } = await this.api.get({ adminPath: '/data/:models' }, { query: { name: modelName, namespace, $app: true, }, }); model = results?.[0] ?? null; } else { model = await this.api.get({ adminPath: `/data/:models${modelPath}`, }, { query: { $app: true } }); } if (!model) { this.throwModelNotFound(path); } if (!subModelKey) { this.emitModel(model, json); return; } const subModel = model.fields[subModelKey]; if (!subModel) { this.throwModelNotFound(path); } // Sub-model fields have no runtime address of their own; emit JSON only. this.log(JSON.stringify(subModel, null, 2)); } async resolveModelPath(path) { if (path.startsWith('/apps/')) { return this.resolveAppModelPath(path); } const [modelPath, subModelKey] = path.split(':'); // Check if this is a namespaced path (e.g., /content/blogs) const pathParts = modelPath.slice(1).split('/'); if (pathParts.length === 2) { const [namespace, modelName] = pathParts; // Return model name and namespace for filtering return [`/${modelName}`, subModelKey, namespace]; } return [modelPath, subModelKey]; } async resolveAppModelPath(path) { const parts = path.split('/'); const appSlugId = parts[2]; const model = parts[3]; const app = await this.api.get({ adminPath: `/apps/${appSlugId}` }); if (!app) { this.throwModelNotFound(path); } return this.resolveModelPath(`/app_${app.id}.${model}`); } throwModelNotFound(path) { throw new Error(`No model found for collection '${path}'.\nList available collections: swell inspect models`); } emitModel(model, json) { this.log(JSON.stringify(model, null, 2)); if (json) { return; } const lines = this.modelHints(model); if (lines.length === 0) { return; } this.log(); this.log('Next steps:'); for (const line of lines) { this.log(` ${line}`); } this.log(); } /** * Detail-mode hints for a model record. Mirrors `InspectResourceCommand.hints` * but lives here because `models` doesn't extend that base. * * Two hints: the records collection at the model's runtime address, and the * mutation event stream filtered by canonical model string. Both interpolate * from the record's own fields — `app_id`, `namespace`, `name` — so we stay * consistent with column 1 if the platform renames anything. * * Canonical model string: `accounts` (system), `content/blogs` (namespaced * system), `apps/<hex>/<col>` (app). The records path mirrors that with a * leading slash. */ modelHints(model) { if (!model.name) { return []; } let apiPath; let canonical; if (model.app_id) { apiPath = `/apps/${model.app_id}/${model.name}`; canonical = `apps/${model.app_id}/${model.name}`; } else if (model.namespace) { apiPath = `/${model.namespace}/${model.name}`; canonical = `${model.namespace}/${model.name}`; } else { apiPath = `/${model.name}`; canonical = model.name; } return [ `swell api get '${apiPath}?limit=10'`, `swell api get '/events?where[model]=${canonical}&limit=10'`, ]; } }