@swell/cli
Version:
Swell's command line interface/utility
291 lines (289 loc) • 11.4 kB
JavaScript
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'`,
];
}
}