@swell/cli
Version:
Swell's command line interface/utility
656 lines (655 loc) • 28.6 kB
JavaScript
import { confirm } from '@inquirer/prompts';
import { inflect } from 'inflection';
import sortBy from 'lodash/sortBy.js';
import * as path from 'node:path';
import { default as ora } from 'ora';
import { AppCommand } from './app-command.js';
import { readRcFile, writeRcFile } from './lib/app-config.js';
import { AppConfig, FunctionProcessingError, IgnoringFileError, SwellJsonFields, allBaseFilesInDir, allConfigFilesPaths, appConfigFromFile, batchAppFilesByType, deleteFile, hashFile, writeFile, writeJsonFile, PUSH_CONCURRENCY, } from './lib/apps/index.js';
import { default as localConfig } from './lib/config.js';
import { getAppFrontendHost, getLocalFrontendHost, getLoginHost, getStorefrontFrontendHost, } from './lib/constants.js';
import { toAppId } from './lib/create/index.js';
import style from './lib/style.js';
/**
* Extends the AppCommand with methods that ease managing remote apps and
* configurations.
*/
export class RemoteAppCommand extends AppCommand {
appType = 'any';
// allows child commands to set orientation
static delayOrientation = false;
// most of the times this will be the target environment
static orientation;
// API instance of the current app definition
app = {};
// Indicates the app was created during the command execution
appCreated = false;
// API instance of the target storefront, if applicable
storefront = null;
// Indicates we are syncing with a local app frontend via proxy
storefrontLocalUrl = '';
// Indicates app configs are being synced along with theme configs
themeSyncApp = false;
appFrontendUrl(storeId, installedAppId) {
return getAppFrontendHost(storeId, installedAppId);
}
async cleanRemoteConfigs(remoteConfigs, deleteDeprecated = true) {
// Delete remote configs that do not have file_path etc
const deprecatedConfigs = remoteConfigs.filter((config) => this.isConfigDeprecated(config));
if (deleteDeprecated && deprecatedConfigs.length > 0) {
const spinner = ora();
spinner.start('Cleaning up deprecated configurations, please wait...');
await Promise.all(deprecatedConfigs.map((config) => this.removeRemoteFile(config, false)));
spinner.stop();
}
const validConfigs = remoteConfigs.filter((config) => !this.isConfigDeprecated(config));
return validConfigs;
}
dashboardUrl(flags = {}, storeId) {
const { env } = flags;
const envFlag = env || this.ctor.orientation?.env;
const outputEnv = envFlag === 'live' ? '' : envFlag ? 'test' : '';
return `${getLoginHost(storeId)}/admin/${outputEnv ? `${outputEnv}/` : ''}apps/${this.app.id}`;
}
async deleteConfig(configId, filePath, appConfigId) {
const themeId = this.app.type === 'theme' && this.storefront?.theme_id;
return this.api.delete({
adminPath: `/apps/${this.app.id}/configs/${appConfigId || configId}`,
}, {
query: {
...(filePath ? { file_path: filePath } : undefined),
...(themeId ? { theme_id: themeId } : undefined),
...(appConfigId ? { app_config_id: appConfigId } : undefined),
...(this.themeSyncApp ? { $sync_app: true } : undefined),
},
});
}
async getApp(id = '') {
const app = await this.api.get({ adminPath: `/apps/${id}` });
const themeId = this.appType === 'theme' && this.storefront?.theme_id;
const canPullConfigs = themeId || app.owned;
if (canPullConfigs) {
// TODO: paginate configs to support up to 10,000 (otherwise 1,000)
// Get app configs for processing
const configs = await this.api.get({ adminPath: `/apps/${id}/configs` }, {
query: {
limit: 1000,
...(themeId ? { theme_id: themeId } : undefined),
},
});
// Instantiate AppConfig for each object
app.configs = (configs.results || []).map((config) => AppConfig.create({
...config,
appPath: this.appPath,
}));
// Pull app configs to compare with theme for push/pull
if (themeId && this.themeSyncApp) {
const appConfigs = await this.api.get({ adminPath: `/apps/${id}/configs` }, { query: { limit: 1000 } });
for (const appConfig of appConfigs.results || []) {
const themeConfig = app.configs.find((config) => config.filePath === appConfig.file_path);
if (themeConfig && themeConfig.fileHash !== appConfig.hash) {
themeConfig.hash = appConfig.hash;
}
else if (!themeConfig && appConfig) {
app.configs.push(AppConfig.create({
...appConfig,
appConfigId: appConfig.id,
appPath: this.appPath,
id: undefined,
}));
}
}
for (const themeConfig of app.configs) {
const appConfig = appConfigs.results.find((config) => themeConfig.filePath === config.file_path);
if (!appConfig) {
themeConfig.hash = 'x';
}
}
}
}
return app;
}
async getAppWithConfig(id = '') {
const swellId = this.app?.id;
const swellConfigId = this.swellConfig.get('id');
if (!swellConfigId) {
this.error(`App ${style.appConfigName('id')} is not defined in swell.json.`);
}
let appId = id || swellId;
if (!appId) {
const privateId = this.app?.private_id;
if (!privateId) {
this.error(`App ${style.appConfigName('id')} is not defined in swell.json.`);
}
if (privateId !== swellConfigId) {
this.error(`App ${style.appConfigName('id')} must be declared in snake_case: ${style.appConfigValue(swellConfigId)}.`);
}
appId = privateId;
}
// we initialize the app with the id so we can use it in the API calls
let app = {};
try {
app = await this.getApp(appId);
this.app.id = app.id;
}
catch (error) {
let errorJson;
try {
errorJson = JSON.parse(error.message);
}
catch {
// if there's a JSON parse error, throw the original error
this.error(error);
}
// if the app doesn't exist, we don't want to throw an error as we might
// want to create it.
//
// if the `id` was passed as argument though, we want to throw an error so
// we don't create a loop.
//
// we don't throw an error ever sometimes, like when pushing an app
const shouldThrow = errorJson.error.message !== 'App not found' || id;
if (shouldThrow) {
const errorMessage = typeof errorJson.error === 'string'
? errorJson.error
: errorJson.error.message;
this.error(errorMessage);
}
if (swellId && swellId !== id && swellId !== this.app?.id) {
app = await this.getAppWithConfig(swellId);
}
}
return app;
}
async getConfigData(configId) {
const themeId = this.app.type === 'theme' && this.storefront?.theme_id;
return this.api.get({
adminPath: `/apps/${this.app.id}/configs/${configId}/data`,
}, { query: { ...(themeId ? { theme_id: themeId } : undefined) } });
}
async getCreateUpdateApp(updateApp) {
const spinner = ora();
let sourceApp;
try {
sourceApp = await readRcFile(this.swellRcPath());
}
catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
if (!this.swellConfig.store.id) {
throw new Error('App swell.json is missing or `id` is not defined.');
}
const body = {
...this.swellConfig.store,
// we store locally the private id as the id, but we need to switch it
// when posting to the API
id: undefined,
...(this.swellConfig.store.id
? { private_id: this.swellConfig.store.id }
: undefined),
...(sourceApp?.id ? { source_id: sourceApp.id } : undefined),
};
const appLabel = this.app.type === 'theme' ? 'theme' : 'app';
let app = updateApp || {};
if (!app.id) {
// May already have a dev instance
let existingDevApp;
try {
existingDevApp = await this.api.get({
adminPath: `/apps/${this.swellConfig.store.id}`,
});
}
catch {
// noop
}
spinner.start(`Creating ${appLabel}...`);
if (existingDevApp) {
app = await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${existingDevApp.id}` }, { body }), () => spinner.fail('Error updating app.'));
}
else {
app = await this.handleRequestErrors(async () => this.api.post({ adminPath: '/apps' }, { body }), () => spinner.fail('Error creating app.'));
this.appCreated = true;
}
}
else if (app.id) {
spinner.start(`Updating ${appLabel}...`);
app = await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${app.id}` }, { body }), () => spinner.fail('Error updating app.'));
}
if (app?.id) {
if (!sourceApp) {
await writeRcFile(this.swellRcPath(), { id: app.id });
}
// if not failed, we want to display correct messaging
if (spinner.isSpinning) {
if (this.appCreated) {
spinner.succeed(`Development ${appLabel} created.`);
}
else {
spinner.succeed(`${appLabel === 'theme' ? 'Theme app' : 'App'} details up to date.`);
}
this.log();
}
return this.getApp(app.id);
}
return {};
}
async getInstalledApp() {
// check if app is already installed on store
return this.api.get({ adminPath: `/client/apps/${this.app.id}` }, {});
}
getLocalAppConfigs() {
const localConfigs = [];
for (const { configFile, configType } of allConfigFilesPaths(this.appPath)) {
const config = appConfigFromFile(configFile, configType, this.appPath);
localConfigs.push(config);
}
for (const filePath of allBaseFilesInDir(this.appPath)) {
const config = appConfigFromFile(filePath, 'file', this.appPath);
localConfigs.push(config);
}
return localConfigs;
}
async getVersion(version) {
const versionsResponse = await this.api.get({ adminPath: `/apps/${this.app.id}/versions` }, {
query: {
limit: 1,
...(version ? { version } : undefined),
},
});
return versionsResponse.results?.[0];
}
async getVersions(version) {
const versionsResponse = await this.api.get({ adminPath: `/apps/${this.app.id}/versions` }, {
query: {
...(version ? { version } : undefined),
},
});
return versionsResponse.results || [];
}
async handleRequestErrors(request, spinnerError) {
let showedErrors = false;
try {
const response = await request();
if (response?.status !== 200 && response?.errors) {
this.showErrors(response.errors, spinnerError);
showedErrors = true;
this.error('Something went wrong.');
}
return response;
}
catch (error) {
let jsonError;
try {
jsonError = JSON.parse(error.message);
}
catch {
// noop, likely JSON parse error
}
if (!showedErrors) {
if (jsonError && jsonError.error) {
this.showErrors(jsonError, spinnerError);
}
else if (typeof error.message === 'string') {
this.showErrors(error, spinnerError);
}
}
throw error;
}
}
async init() {
await super.init();
this.app = this.initApp();
this.storefront = null;
const klass = this.ctor;
if (klass.orientation && !klass.delayOrientation) {
await this.logOrientation();
}
}
async installApp(version, spinner) {
return this.api.post({ adminPath: `/client/apps` }, {
body: { app_id: this.app.id, version },
onAsyncGetPath: (response) => ({
adminPath: `/client/app-async-status/${response.id}`,
}),
spinner,
});
}
isConfigDeprecated(config) {
return !config.file_path;
}
localFrontendUrl(storeId, sessionId) {
return getLocalFrontendHost(storeId, sessionId);
}
async logOrientation(orientation) {
const klass = this.ctor;
const { flags } = await this.parse(klass);
if (klass.orientation || orientation) {
this.showOrientation(klass.orientation || orientation, flags);
}
}
async postConfigData(configJson) {
const themeId = this.app.type === 'theme' && this.storefront?.theme_id;
return this.api.post({ adminPath: `/apps/${this.app.id}/configs` }, {
body: {
...configJson,
...(themeId
? { theme_id: themeId, $environment_id: this.api.envId }
: undefined),
...(this.themeSyncApp ? { $sync_app: true } : undefined),
...(this.storefrontLocalUrl
? { $storefront_local_url: this.storefrontLocalUrl }
: undefined),
},
});
}
async pullAppConfigs(force, confirmRemove = true) {
const pulled = [];
const removed = [];
const localConfigs = this.getLocalAppConfigs();
const remoteConfigs = await this.cleanRemoteConfigs(await this.pullAppJsonAndRemoteConfigs(), false);
const batches = batchAppFilesByType(remoteConfigs);
for (const batch of batches) {
const remoteBatchConfigs = remoteConfigs.filter((config) => config.type === batch.type);
const localBatchConfigs = localConfigs.filter((config) => config.type === batch.type);
const pullFiles = batch.configs.filter((batchConfig) => {
if (force)
return true;
try {
const localConfig = localBatchConfigs.find((config) => config.filePath === batchConfig.filePath);
if (localConfig) {
const hash = hashFile(batchConfig.appPath, undefined, batchConfig.filePath);
if (hash === batchConfig.hash) {
return false;
}
}
}
catch { }
return true;
});
const filesToRemove = localBatchConfigs.filter((config) => !batch.configs.some((batchConfig) => batchConfig.filePath === config.filePath));
// Skip if there are no config changes
if (pullFiles.length === 0 && filesToRemove.length === 0) {
continue;
}
const updatedLog = pullFiles.length > 0
? `(${pullFiles.length}/${remoteBatchConfigs.length})`
: '';
if (pullFiles.length > 0 || filesToRemove.length > 0) {
this.log(`${style.appConfigName(`${batch.label} ${updatedLog}`)}`);
}
if (pullFiles.length > 0) {
while (pullFiles.length > 0) {
// eslint-disable-next-line no-await-in-loop
await Promise.all(pullFiles.splice(0, PUSH_CONCURRENCY).map(async (config) => {
await this.pullRemoteFile(config);
pulled.push(config.id);
}));
}
}
// Remove any deleted files
if (filesToRemove.length > 0) {
if (confirmRemove && !force) {
const removeList = filesToRemove
.map((config) => config.filePath)
.join('\n');
// eslint-disable-next-line no-await-in-loop
const confirmed = await confirm({
default: false,
message: `The following files are not in the remote app:\n\n${removeList}\n\nDo you want to delete these files locally?`,
});
if (!confirmed) {
filesToRemove.splice(0);
}
}
while (filesToRemove.length > 0) {
// eslint-disable-next-line no-await-in-loop
await Promise.all(filesToRemove.splice(0, PUSH_CONCURRENCY).map(async (config) => {
await this.removeLocalFile(config);
removed.push(config.id);
}));
}
}
this.log();
}
if (pulled.length === 0 && removed.length === 0) {
this.log(`${style.success('✔')} All files up to date.\n`);
}
return { pulled, removed };
}
async pullAppJsonAndRemoteConfigs() {
const configs = this.app.configs || [];
const allOtherConfigs = sortBy(configs.filter((config) => config.filePath !== 'swell.json'), 'filePath');
const swellJson = configs.find((config) => config.filePath === 'swell.json');
if (swellJson) {
await this.pullRemoteFile(swellJson, false);
}
else {
const swellConfigJson = {};
for (const key of Object.keys(SwellJsonFields)) {
const configKey = SwellJsonFields[key];
swellConfigJson[configKey] = this.app[configKey];
}
swellConfigJson.id = toAppId(this.app.private_id);
await writeJsonFile(path.join(this.appPath, 'swell.json'), swellConfigJson);
}
const swellConfig = appConfigFromFile('swell.json', 'file', this.appPath);
swellConfig.id = swellJson?.id;
return [swellConfig, ...allOtherConfigs];
}
async pullRemoteFile(config, log = true) {
const spinner = ora();
log && spinner.start(`Pulling ${config.filePath}`);
try {
const result = await this.handleRequestErrors(async () => this.getConfigData(config.id), () => log && spinner.fail(`Error pulling ${config.filePath}`));
await writeFile(config.appPath, result);
log && spinner.succeed(`${this.timestampStyled()} ${config.filePath}`);
return result;
}
catch (error) {
log && spinner.fail(error.message);
throw error;
}
}
async pushAppConfigs(force) {
const pushed = [];
const removed = [];
const localConfigs = this.getLocalAppConfigs();
const remoteConfigs = await this.cleanRemoteConfigs(this.app?.configs || []);
// Do not allow more than 10K files
if (localConfigs.length > 10000) {
this.error(`Apps only support up to 10,000 files. Found ${localConfigs.length} files.`);
}
const batches = batchAppFilesByType(localConfigs);
for (const batch of batches) {
const remoteBatchConfigs = remoteConfigs.filter((config) => config.type === batch.type);
const pushFiles = batch.configs.filter((batchConfig) => {
const remoteConfig = remoteBatchConfigs.find((config) => batchConfig.filePath === config.filePath);
return Boolean(force || !remoteConfig || remoteConfig.hash !== batchConfig.hash);
});
const filesToRemove = remoteBatchConfigs.filter((config) => !batch.configs.some((batchConfig) => batchConfig.filePath === config.filePath));
// Skip if there are no config changes
if (pushFiles.length === 0 && filesToRemove.length === 0) {
continue;
}
const updatedLog = pushFiles.length > 0
? remoteBatchConfigs.length > 0
? `(${pushFiles.length}/${batch.configs.length})`
: `(${pushFiles.length})`
: '';
if (pushFiles.length > 0 || filesToRemove.length > 0) {
this.log(`${style.appConfigName(`${batch.label} ${updatedLog}`)}`);
}
if (pushFiles.length > 0) {
while (pushFiles.length > 0) {
// eslint-disable-next-line no-await-in-loop
await Promise.all(pushFiles.splice(0, batch.concurrency).map(async (config) => {
const pushedConfig = await this.pushRemoteFile(config);
if (pushedConfig) {
pushed.push(pushedConfig.id);
if (config.filePath === 'swell.json') {
this.app = await this.getApp(this.app.id);
}
}
}));
}
}
// Remove any deleted files
if (filesToRemove.length > 0) {
while (filesToRemove.length > 0) {
// eslint-disable-next-line no-await-in-loop
await Promise.all(filesToRemove.splice(0, batch.concurrency).map(async (config) => {
await this.removeRemoteFile(config);
removed.push(config.id);
}));
}
}
this.log();
}
if (pushed.length === 0 && removed.length === 0) {
this.log(`${style.success('✔')} All files up to date.\n`);
}
return { pushed, removed };
}
async pushRemoteFile(config, log = true) {
const spinner = ora();
log && spinner.start(`Pushing ${config.filePath}`);
try {
const postData = await config.postData();
if (!postData) {
spinner.stop();
return;
}
const result = await this.handleRequestErrors(async () => this.postConfigData(postData), () => log && spinner.fail(`Error pushing ${config.filePath}`));
log && spinner.succeed(`${this.timestampStyled()} ${config.filePath}`);
return result;
}
catch (error) {
if (error instanceof IgnoringFileError) {
log &&
spinner.fail(`Ignoring file: ${error.message} in ${config.filePath}`);
return;
}
if (error instanceof FunctionProcessingError) {
log &&
spinner.fail(`${error.message} ${error.originalError?.message?.toLowerCase()}`);
return;
}
log && spinner.fail(error.message);
throw error;
}
}
async removeLocalFile(config, log = true) {
if (!config.fileData) {
return;
}
const spinner = ora();
log && spinner.start(`Removing ${config.filePath}`);
const result = await this.handleRequestErrors(async () => deleteFile(config.appPath), () => log && spinner.fail(`Error removing ${config.filePath}`));
log &&
spinner.warn(`${this.timestampStyled()} ${style.strike(config.filePath)}`);
return result;
}
async removeRemoteFile(config, log = true) {
const spinner = ora();
log && spinner.start(`Removing ${config.filePath}`);
const result = await this.handleRequestErrors(async () => this.deleteConfig(config.id, config.filePath, config.appConfigId), () => log && spinner.fail(`Error removing ${config.filePath}`));
log &&
spinner.warn(`${this.timestampStyled()} ${style.strike(config.filePath)}`);
return result;
}
showErrors(errors, spinnerError) {
const actualErrors = Object.keys(errors).filter((err) => Object.prototype.hasOwnProperty.call(errors, err));
if (spinnerError) {
spinnerError();
}
else {
this.logError(inflect('Error', actualErrors.length));
}
for (const err of actualErrors) {
if (!errors[err]?.message)
continue;
// we need to display `id` instead of `private_id`
// developers don't have any knowledge of private ids
const displayErr = err === 'private_id' ? 'id' : err;
this.logError([
' -',
`${displayErr}:`.toLowerCase().replace(/^error:/, ''),
errors[err].message,
]
.filter(Boolean)
.join(' '));
}
}
storefrontFrontendUrl(storeId, storefront, branchId, preview = true) {
const storefrontSlug = storefront && preview
? `${storeId}--${storefront.id.toLowerCase()}--preview`
: storeId;
return getStorefrontFrontendHost(storefrontSlug, branchId);
}
swellRcPath() {
return path.join(this.appPath, '.swellrc');
}
timestampStyled() {
const date = new Date();
return style.dim(`[${date.toLocaleTimeString()}]`);
}
async updateInstalledApp(version, spinner) {
await this.api.put({ adminPath: `/client/apps/${this.app.id}` }, {
body: { version },
onAsyncGetPath: (response) => ({
adminPath: `/client/app-async-status/${response.id}`,
}),
spinner,
});
}
async updateVersion(version, body, spinner) {
await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${this.app.id}/versions` }, { body: { version, ...body } }), () => spinner?.fail('Error updating version.'));
}
/**
* Reads the .swellrc file and sets up the remote app configuration.
*
* @returns App
*/
initApp() {
const app = this.swellConfig.store || {};
// id refers to remote app id, so it's undefined initially
app.id = undefined;
// swell.json id refers to private id
if (this.swellConfig.get('id')) {
// use the same formula as the backend api
app.private_id = toAppId(this.swellConfig.get('id'));
}
// Default name to private id
if (!app.name) {
app.name = app.private_id || app.id;
}
return app;
}
showOrientation(orientation, flags) {
const orientationOutput = [];
const currentStore = localConfig.getDefaultStore();
const { env, store } = flags;
if (this.app.name) {
// Show theme version if not syncing app
const version = !this.themeSyncApp && this.app.type === 'theme' && this.storefront?.theme_version
? this.storefront?.theme_version
: this.app.version;
orientationOutput.push(`[${this.app.type === 'theme' ? 'theme' : 'app'}] ${style.appName(this.app.name)}${version ? ` v${version}` : ''}`);
}
if (store) {
orientationOutput.push(`[store] ${style.storeId(store)}`);
}
else if (currentStore) {
orientationOutput.push(`[store] ${style.storeId(currentStore)}`);
}
const outputEnv = env || orientation.env;
if (outputEnv) {
const outputStyle = outputEnv === 'live' ? style.appEnvLive : style.appEnv;
orientationOutput.push(`[env] ${outputStyle(outputEnv)}`);
}
this.log(orientationOutput.join(' '));
this.log();
}
}