@swell/cli
Version:
Swell's command line interface/utility
278 lines (277 loc) • 10.6 kB
JavaScript
import { Args, Flags } from '@oclif/core';
// eslint-disable-next-line import/no-named-as-default
import Ajv2020 from 'ajv/dist/2020.js';
import ajvErrors from 'ajv-errors';
import addFormats from 'ajv-formats';
import * as fs from 'node:fs';
import parseJson from 'parse-json';
import { bundleFunction } from '../lib/bundle.js';
import { SCHEMAS_BASE_URL } from '../lib/constants.js';
import { SwellCommand } from '../swell-command.js';
const SCHEMA_DEFINITIONS = Object.freeze({
model: {
description: 'Data model definitions (/models/*.json)',
},
notification: {
description: 'Notification definitions (/notifications/*.json)',
},
content: {
description: 'Content views definitions (/content/*.json)',
},
setting: {
description: 'App settings definitions (/settings/*.json)',
},
webhook: {
description: 'Webhook definitions (/webhooks/*.json)',
},
function: {
description: 'Function definitions (/functions/*.ts or *.js)',
hasJsonSchema: false,
},
});
export default class Schema extends SwellCommand {
static summary = 'View or validate Swell config schemas.';
static examples = [
{
description: 'List available schema types',
command: '<%= config.bin %> <%= command.id %>',
},
{
description: 'Output JSON Schema',
command: '<%= config.bin %> <%= command.id %> content --format=json-schema',
},
{
description: 'Output bundled JSON Schema (all $refs resolved)',
command: '<%= config.bin %> <%= command.id %> content --format=json-schema-bundle',
},
{
description: 'Output TypeScript declarations',
command: '<%= config.bin %> <%= command.id %> content --format=dts',
},
{
description: 'Validate a JSON config file',
command: '<%= config.bin %> <%= command.id %> content myfile.json',
},
{
description: 'Validate from stdin',
command: 'cat myfile.json | <%= config.bin %> <%= command.id %> content -',
},
{
description: 'Validate a function file',
command: '<%= config.bin %> <%= command.id %> function myfunction.ts',
},
];
static args = {
type: Args.string({
required: false,
description: 'Schema type (e.g. model, content, setting, notification, webhook, function)',
}),
file: Args.string({
required: false,
description: 'Path to file to validate (or use "-" for stdin)',
}),
};
static flags = {
format: Flags.string({
description: 'Output format: json-schema (with $ref pointers), json-schema-bundle (all $refs resolved), or dts (TypeScript declarations)',
options: ['json-schema', 'json-schema-bundle', 'dts'],
}),
yes: Flags.boolean({
char: 'y',
description: 'Skip informational output and print schema directly (equivalent to --format=json-schema).',
}),
};
async run() {
const { args, flags } = await this.parse(Schema);
const { type, file } = args;
const { format, yes } = flags;
// List available schema types
if (!type) {
this.listTypes();
return;
}
// Validate type
if (!(type in SCHEMA_DEFINITIONS)) {
throw new Error(`Unknown schema type '${type}'. Use 'swell schema' to list available types`);
}
// Validate file
if (file) {
if (format) {
throw new Error('--format cannot be used with file validation');
}
await (type === 'function'
? this.validateFunction(file)
: this.validate(type, file));
return;
}
// Show schema info or output schema
if (!format && !yes) {
await this.showSchemaInfo(type);
return;
}
// Output schema in requested format
const outputFormat = format || 'json-schema';
await this.outputSchema(type, outputFormat);
}
catch(error) {
return this.error(error.message, { exit: 1 });
}
listTypes() {
this.log('Available config schemas:\n');
const entries = Object.entries(SCHEMA_DEFINITIONS);
const maxKeyLength = Math.max(...entries.map(([key]) => key.length));
for (const [schemaType, config] of entries) {
const padded = schemaType.padEnd(maxKeyLength + 4);
this.log(` ${padded}${config.description}`);
}
}
async showSchemaInfo(_type) {
this.log(`Use --format to output the schema, or provide a file to validate.`);
this.log(`Run "swell schema --help" for more information.`);
}
async outputSchema(type, format) {
const definition = SCHEMA_DEFINITIONS[type];
// Guard against json-schema formats for types without JSON Schema
if ('hasJsonSchema' in definition &&
definition.hasJsonSchema === false &&
(format === 'json-schema' || format === 'json-schema-bundle')) {
throw new Error(`'${type}' does not have a JSON Schema. Use --format=dts for TypeScript declarations.`);
}
let output;
switch (format) {
case 'json-schema': {
const schemaUrl = this.getSchemaUrl(type);
const response = await fetch(schemaUrl);
if (!response.ok) {
throw new Error(`Failed to fetch schema: ${response.statusText}`);
}
const schema = await response.json();
output = JSON.stringify(schema, null, 2);
break;
}
case 'json-schema-bundle': {
const bundledUrl = this.getBundledSchemaUrl(type);
const response = await fetch(bundledUrl);
if (!response.ok) {
throw new Error(`Failed to fetch bundled schema: ${response.statusText}`);
}
const schema = await response.json();
output = JSON.stringify(schema, null, 2);
break;
}
case 'dts': {
const dtsUrl = this.getTypeScriptDeclarationsUrl(type);
const response = await fetch(dtsUrl);
if (!response.ok) {
throw new Error(`Failed to fetch TypeScript declarations: ${response.statusText}`);
}
output = await response.text();
break;
}
default: {
throw new Error(`Unknown format: ${format}`);
}
}
this.log(output);
}
async validate(type, file) {
const isStdin = file === '-';
const input = isStdin
? await this.readFromStdin()
: await this.getFileInput(file);
let json;
try {
json = parseJson(input);
}
catch (error) {
let errorTitle = 'Invalid JSON';
if (!isStdin) {
errorTitle += ` in '${file}'`;
}
throw new Error(`${errorTitle}: ${error.message}`);
}
const bundledUrl = this.getBundledSchemaUrl(type);
const response = await fetch(bundledUrl);
if (!response.ok) {
throw new Error(`Failed to fetch bundled schema: ${response.statusText}`);
}
const schema = await response.json();
const ajv = new Ajv2020({
allErrors: true,
strict: false,
});
addFormats(ajv);
ajvErrors(ajv);
const validate = ajv.compile(schema);
const valid = validate(json);
if (!valid) {
const { errors } = validate;
const errorLength = errors?.length;
let errorTitle = 'Invalid model definition';
if (typeof errorLength === 'number' && errorLength > 1) {
errorTitle += ` (${errorLength} errors)`;
}
let errorMessage = '';
for (const error of errors || []) {
const path = error.instancePath || '/';
errorMessage += `\n• ${path}: ${error.message} (schema: ${error.schemaPath})`;
}
throw new Error(`${errorTitle}\n${errorMessage}`);
}
this.log('Valid model definition');
}
async validateFunction(file) {
if (!fs.existsSync(file)) {
throw new Error(`File not found: ${file}`);
}
const filePath = fs.realpathSync(file);
// Attempt to bundle the function (validates syntax, imports, etc.)
let bundleResult;
try {
bundleResult = await bundleFunction(filePath);
}
catch (error) {
throw new Error(`Function compilation failed:\n• ${error.message}`);
}
const { config } = bundleResult;
// Validate config exists
if (!config) {
throw new Error('Invalid function:\n• Function must export a `config` object');
}
// Validate trigger exclusivity
const triggers = ['route', 'model', 'cron'].filter((t) => config[t]);
if (triggers.length === 0) {
throw new Error('Invalid function config:\n• Config must specify one of: route, model, cron');
}
if (triggers.length > 1) {
throw new Error(`Invalid function config:\n• Multiple triggers specified: ${triggers.join(', ')}. Use exactly one.`);
}
this.log('Valid function definition');
}
getFileInput(file) {
if (!fs.existsSync(file)) {
throw new Error(`File not found: ${file}`);
}
return fs.promises.readFile(file, 'utf8');
}
readFromStdin() {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
getSchemaUrl(type) {
return `${SCHEMAS_BASE_URL}/${type}.json`;
}
getBundledSchemaUrl(type) {
return `${SCHEMAS_BASE_URL}/schema-bundle/${type}.bundled.json`;
}
getTypeScriptDeclarationsUrl(type) {
return `${SCHEMAS_BASE_URL}/types/${type}.d.ts`;
}
}