@swell/cli
Version:
Swell's command line interface/utility
384 lines (383 loc) • 13.2 kB
JavaScript
import { getEncoding } from 'istextorbinary';
import isEmpty from 'lodash/isEmpty.js';
import { detectFilenameMime } from 'mime-detect';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { bundleFunction, bundleComponent, bundleWorkflow } from '../bundle.js';
import { analyzeFunctionSource, hasKindDiagnostic, workflowStaticConfigError, } from '../function-source-analysis.js';
import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, } from './index.js';
export class IgnoringFileError extends Error {
constructor(message) {
super(message);
this.name = 'IgnoringFileError';
}
}
export class FunctionProcessingError extends Error {
cause;
constructor(message, error) {
super(message);
this.cause = error;
this.name = 'FunctionProcessingError';
}
}
/**
* An app configuration as defined by the Swell API. This implies that the
* app & the configuration are saved to the API.
*/
export class AppConfig {
// Used when pushing a theme and the app config is considered
appConfigId;
// Full local path of remote AppConfig
appPath;
// Size of local file in bytes and mb
byteSize;
date_created;
date_updated;
file;
file_path;
// Buffer of local file data
fileData;
fileHash;
// Relative path of remote AppConfig, same as file_path if defined
filePath;
hash;
// Server-side props
id;
mbSize;
name;
parent_id;
slug;
values = {};
version;
constructor(attrs = {}) {
Object.assign(this, attrs);
let { appPath, file_path, filePath, name, type } = attrs;
this.appPath = appPath || '';
this.filePath = filePath || '';
if (!type || !name) {
throw new Error('Missing app config type or name.');
}
if (!appPath) {
throw new Error('Missing app path to initialize config.');
}
// Make sure file path is set from server prop or type/name
if (!filePath) {
if (file_path) {
filePath = file_path;
}
else {
// Note this shouldn't be necessary unless the config was created before file_path was added
const typePath = AllConfigPaths[String(type).toUpperCase()];
filePath = `${typePath}/${name}`;
if (!typePath) {
throw new Error(`Missing path for app config type: ${type}`);
}
}
this.filePath = filePath;
}
if (!appPath.endsWith(filePath)) {
this.appPath = path.join(appPath, filePath);
}
}
async load() {
if (await filePathExistsAsync(this.appPath)) {
this.fileData = await fs.readFile(this.appPath);
this.fileHash = hashFile(this.appPath, this.fileData, this.filePath);
this.hash = this.hash || this.fileHash;
this.byteSize = this.fileData.length;
this.mbSize = this.byteSize / 1024 / 1024;
}
}
static async create(attrs = {}) {
let instance;
switch (attrs.type) {
case ConfigType.ASSET: {
instance = new AppConfigAsset(attrs);
break;
}
case ConfigType.FUNCTION: {
instance = new AppConfigFunction(attrs);
break;
}
case ConfigType.MODEL: {
instance = new AppConfigModel(attrs);
break;
}
case ConfigType.CONTENT: {
instance = new AppConfigContent(attrs);
break;
}
case ConfigType.NOTIFICATION: {
instance = new AppConfigNotification(attrs);
break;
}
case ConfigType.SETTING: {
instance = new AppConfigSetting(attrs);
break;
}
case ConfigType.WEBHOOK: {
instance = new AppConfigWebhook(attrs);
break;
}
case ConfigType.FRONTEND: {
instance = new AppConfigFrontend(attrs);
break;
}
case ConfigType.THEME: {
instance = new AppConfigTheme(attrs);
break;
}
case ConfigType.COMPONENT: {
instance = new AppConfigComponent(attrs);
break;
}
default: {
// the default type is file
const defaultConfig = new AppConfigDefault(attrs);
defaultConfig.type = attrs.type;
instance = defaultConfig;
break;
}
}
await instance.load();
return instance;
}
isRootConfig(configDir) {
const pos = this.filePath.startsWith(configDir + '/')
? configDir.length + 1
: 0;
return !this.filePath.includes('/', pos);
}
async postData() {
if (this.fileData === undefined) {
return;
}
if (this.hasValues && this.filePath.endsWith('.json')) {
try {
this.values = JSON.parse(this.fileData.toString('utf8'));
}
catch {
throw new IgnoringFileError('Invalid JSON');
}
if (isEmpty(this.values)) {
throw new IgnoringFileError('Empty JSON');
}
}
// Detect file content type
const contentType = this.filePath.endsWith('.liquid') || this.filePath.endsWith('.liquidx')
? 'application/liquid'
: detectFilenameMime(this.filePath);
const postData = {
file: {
content_type: contentType,
data: this.prepareFileData(),
},
file_path: this.filePath,
hash: this.hash,
// Name is either configured or the last part of the path without extension
name: this.values?.name || this.name,
type: this.type,
};
if (this.hasValues) {
postData.values = this.values || {};
}
return this.preparePostData(postData);
}
prepareFileData() {
if (!this.fileData) {
return null;
}
return getEncoding(this.fileData) === 'binary'
? { $base64: this.fileData.toString('base64') }
: this.fileData?.toString('utf8');
}
}
export class AppConfigDefault extends AppConfig {
hasValues = false;
type = ConfigType.FILE;
preparePostData(postData) {
return postData;
}
}
export class AppConfigModel extends AppConfig {
hasValues = true;
type = ConfigType.MODEL;
preparePostData(postData) {
postData.values.collection =
postData.values.collection || postData.values.model || this.name;
return postData;
}
}
export class AppConfigContent extends AppConfig {
hasValues = true;
type = ConfigType.CONTENT;
preparePostData(postData) {
postData.values.collection = postData.values.collection || this.name;
return postData;
}
}
export class AppConfigNotification extends AppConfig {
hasValues = true;
type = ConfigType.NOTIFICATION;
async preparePostData(postData) {
const isTemplate = this.isRootConfig('notifications') &&
(this.filePath.endsWith('.tpl') || this.filePath.endsWith('.liquid'));
if (isTemplate) {
// Get json config to match collection/model name
try {
const jsonPath = this.filePath
.replace('.tpl', '.json')
.replace('.liquid', '.json');
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
postData.values.collection = jsonData?.collection || jsonData?.model;
}
catch {
// noop
}
postData.file = {
content_type: 'text/plain',
data: this.fileData?.toString('utf8')?.replaceAll('\r\n', '\n'),
};
}
return postData;
}
}
export class AppConfigSetting extends AppConfig {
hasValues = true;
type = ConfigType.SETTING;
preparePostData(postData) {
return postData;
}
}
export class AppConfigWebhook extends AppConfig {
hasValues = true;
type = ConfigType.WEBHOOK;
preparePostData(postData) {
return postData;
}
}
export class AppConfigFunction extends AppConfig {
hasValues = true;
type = ConfigType.FUNCTION;
isRootFunction() {
return (this.isRootConfig('functions') &&
(this.filePath.endsWith('.js') || this.filePath.endsWith('.ts')));
}
async preparePostData(postData) {
if (this.isRootFunction()) {
try {
// get file contents and if it's empty ignore
const fileData = this.prepareFileData();
if (!fileData) {
return;
}
const analysis = await analyzeFunctionSource(this.appPath);
if (analysis.kind === 'workflow') {
if (analysis.diagnostics.length > 0) {
throw new Error(analysis.diagnostics.join('\n'));
}
const { code, config } = await bundleWorkflow(this.appPath, analysis);
postData.file = {
data: fileData,
};
postData.build_file = {
content_type: 'application/javascript+module',
data: code,
};
postData.values = config;
return postData;
}
if (hasKindDiagnostic(analysis)) {
throw new Error(analysis.diagnostics.join('\n'));
}
const { code, config } = await bundleFunction(this.appPath);
if (!config) {
throw new IgnoringFileError('Function must export a `config` object.');
}
if (config.kind === 'workflow') {
throw workflowStaticConfigError();
}
// Save the original file and the bundled version
postData.file = {
data: fileData,
};
postData.build_file = {
content_type: 'text/javascript',
data: code,
};
postData.values = config;
}
catch (error) {
throw new FunctionProcessingError(`Unable to compile function ${this.name}`, error);
}
}
return postData;
}
}
export class AppConfigComponent extends AppConfig {
hasValues = true;
type = ConfigType.COMPONENT;
isRootComponent() {
return (this.isRootConfig('components') &&
(this.filePath.endsWith('.jsx') || this.filePath.endsWith('.tsx')));
}
async preparePostData(postData) {
if (!this.isRootComponent()) {
return postData;
}
try {
// get file contents and if it's empty ignore
const fileData = this.prepareFileData();
if (!fileData) {
return;
}
const { code, config } = await bundleComponent(this.filePath);
if (!config) {
throw new IgnoringFileError('Component must export a `config` object.');
}
// Save the original file and the bundled version
postData.file = {
data: fileData,
};
postData.build_file = {
content_type: 'application/javascript',
data: code,
};
postData.values = config;
}
catch (error) {
throw new FunctionProcessingError(`Unable to compile component ${this.name}`, error);
}
return postData;
}
}
// Assets do not get installed but saved as plain files
export class AppConfigAsset extends AppConfigDefault {
hasValues = false;
type = ConfigType.ASSET;
}
export class AppConfigFrontend extends AppConfigDefault {
hasValues = false;
type = ConfigType.FRONTEND;
}
export class AppConfigTheme extends AppConfigDefault {
hasValues = false;
type = ConfigType.THEME;
async preparePostData(postData) {
const isConfigJson = this.filePath && /^theme\/config\/[^./]+\.json$/.test(this.filePath);
// Files in theme/config/*.json are treated as settings with values
if (isConfigJson) {
try {
const jsonData = JSON.parse(await fs.readFile(this.filePath, 'utf8'));
postData.values = jsonData;
}
catch (error) {
// If the file is not valid JSON, ignore it silently.
if (!(error instanceof SyntaxError)) {
throw error;
}
}
}
return postData;
}
}