UNPKG

@swell/cli

Version:

Swell's command line interface/utility

656 lines (655 loc) 28.6 kB
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(); } }