UNPKG

@swell/cli

Version:

Swell's command line interface/utility

278 lines (277 loc) 10.6 kB
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`; } }