UNPKG

@swell/cli

Version:

Swell's command line interface/utility

878 lines (877 loc) 38.3 kB
import { confirm, input, select } from '@inquirer/prompts'; import { $ } from 'execa'; import getPort, { portNumbers } from 'get-port'; import * as fs from 'node:fs'; import * as path from 'node:path'; import Stream from 'node:stream'; import ora from 'ora'; import { default as swellConfig } from './lib/app-config.js'; import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, appConfigFromFile, filePathExists, filePathExistsAsync, findAppConfig, getAppSlugId, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, globAllFilesByPath, hashString, isPathDirectory, } from './lib/apps/index.js'; import { getGlobIgnorePathsChecker } from './lib/apps/paths.js'; import { default as localConfig } from './lib/config.js'; import { toAppId } from './lib/create/index.js'; import { getProxyUrl } from './lib/proxy.js'; import { selectEnvironmentId } from './lib/stores.js'; import style from './lib/style.js'; import { RemoteAppCommand } from './remote-app-command.js'; const PUSH_CONCURRENCY = 3; const WATCH_WINDOW_MS = 100; const DEV_SERVER_FALLBACK_PORT = 3000; export class PushAppCommand extends RemoteAppCommand { frontendPath = ''; logWatchChanges = true; onWatchChange; watchingChangeQueue = new Map(); watchingFiles = new Set(); watchingIgnoreFilter = null; watchingTimer = null; watchListener = async (eventType, filename) => { if (!filename) { return; } if (this.watchingIgnoreFilter && this.watchingIgnoreFilter(filename)) { return; } const configFile = filename; const pathParsed = path.parse(configFile); const fileDirs = pathParsed.dir.split(path.sep); // only the first directory is relevant for us const configDir = fileDirs[0]; // we only want to watch files in the root of the app directory // and ignore build files etc // TODO: renaming a folder doesn't seem to work // because isWatching = false for the new files if (!configDir) { return; } const configType = getConfigTypeFromPath(configDir); if (!configType) { return; } await this.setWatchingFiles(); const isWatching = this.watchingFiles.has(configFile); let watchFileEvent; // per node docs: // // On most platforms, 'rename' is emitted whenever a filename appears // or disappears in the directory. // // we want however to identify delete file events so we can send the // correct API request if (eventType === 'rename') { watchFileEvent = 'new'; if (!(await filePathExistsAsync(path.join(this.appPath, configFile)))) { watchFileEvent = 'deleted'; this.watchingFiles.delete(configFile); } else if (!isWatching) { return; } this.watchingFiles.add(configFile); } else if (isWatching) { watchFileEvent = 'change'; } else { return; } try { await this.handleWatchFileChange(configFile, configType, watchFileEvent); } catch (error) { console.error(error); // we don't want to break the watcher if an error is thrown while // handling a file change // for tests though, we want to break the watcher if (process.env.NODE_ENV === 'test') { // throwing does not break the test so we need to hard exit and log // the error console.error(style.error(error)); // eslint-disable-next-line no-process-exit, unicorn/no-process-exit process.exit(1); } } }; showFrontendMigrationError() { return this.error(style.funcWarn(`⚠️ Your frontend directory exists but appears to use an older structure.\n` + `Swell CLI v2.1.0+ requires a Workers-based frontend with package.json.\n\n` + `${style.basicHighlight('Migration options:')}\n\n` + `1. ${style.basicHighlight('Migrate to Workers')} (recommended)\n` + ` If you are using a Swell official application (e.g. Proxima), update to the latest version.\n` + ` Otherwise, initialize your frontend as a Workers project.\n` + ` Follow Cloudflare's official guide: ${style.link('https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/')}\n\n` + `2. ${style.basicHighlight('Use older CLI version')}\n` + ` Continue with Pages deployment:\n` + ` ${style.dim('$ npm install -g @swell/cli@2.0.20')}\n`)); } async buildFrontend(projectType) { if (!projectType) { this.showFrontendMigrationError(); return; // unreachable but helps TypeScript narrow the type } if (!projectType.buildCommand) { // No build command needed for this framework return; } this.log(`Building ${projectType.name} frontend...\n`); await this.execFrontend(projectType.buildCommand); } async chooseAppToPull(query) { const typeLabelPlural = this.appType === 'theme' ? 'themes' : 'apps'; const typeLabelPrefixed = this.appType === 'theme' ? 'a theme' : 'an app'; const typeQuery = this.appType === 'theme' ? { type: 'theme' } : { type: 'storefront' }; const apps = await this.api.get({ adminPath: '/apps' }, { query: { limit: null, ...typeQuery, ...query } }); if (!apps.count) { const currentStore = localConfig.getDefaultStore(); this.error(`There are no ${typeLabelPlural} associated with your store ${style.storeId(currentStore)}`); } const appSlugId = (await select({ choices: apps.results.map((app) => ({ name: `${style.appConfigValue(app.name)} (${getAppSlugId(app)})`, value: app.private_id, })), message: `Choose ${typeLabelPrefixed} to pull`, })); const appId = apps.results.find((app) => app.private_id === appSlugId).id; const app = await this.getApp(appId); this.log(); return app; } async createAppStorefront(hasOtherStorefronts = true) { let appId; let themeAppId; if (this.app.type === 'theme') { themeAppId = this.app.id; const storefrontApps = await this.handleRequestErrors(async () => this.api.get({ // TODO: improve this filter query adminPath: `/apps?type=storefront&$or[0][kind]=shop&$or[1][kind]=`, })); const compatibleStorefrontApps = storefrontApps.results.filter((app) => app.installed && app.storefront?.theme?.provider === 'app' && this.app.theme?.storefront?.app === toAppId(app.private_id)); if (compatibleStorefrontApps.length === 0) { this.error('No compatible storefront apps found for this theme. Install a new storefront app first.'); } const targetApp = compatibleStorefrontApps.length === 1 ? compatibleStorefrontApps[0] : await select({ choices: compatibleStorefrontApps.map((app) => ({ name: `${style.appConfigValue(app.name)} v${app.version}`, value: app, })), message: 'Choose a storefront app', }); appId = targetApp.id; this.log(`\n${style.success('✔')} Using ${style.appConfigValue(targetApp.name)} v${targetApp.version}\n`); } else if (this.app.type === 'storefront') { appId = this.app.id; const themeApps = await this.handleRequestErrors(async () => this.api.get({ adminPath: `/apps?type=theme`, })); const compatibleThemeApps = themeApps.results.filter((app) => app.installed && Object.keys(app.compatibilities || {}).includes(this.app.private_id)); if (compatibleThemeApps.length === 0) { this.error('No compatible theme apps found for this storefront. Install a new theme app first.'); } let targetApp; if (compatibleThemeApps.length === 1) { targetApp = compatibleThemeApps[0]; } else { targetApp = await select({ choices: compatibleThemeApps.map((app) => ({ name: `${style.appConfigValue(app.name)} v${app.version}`, value: app, })), message: 'Choose a theme app', }); this.log(); } themeAppId = targetApp.id; this.log(`${style.success('✔')} Using ${style.appConfigValue(targetApp.name)} v${targetApp.version}\n`); } else { this.error('App type not supported for storefront creation'); } let name; // Leave name blank when syncing theme app to avoid blocking push if (hasOtherStorefronts && !this.themeSyncApp) { name = await input({ default: this.app.name, message: 'Name your storefront', }); if (name === this.app.name) { name = undefined; // undefined allows the api to auto increment name } } return this.handleRequestErrors(async () => this.api.post({ adminPath: `/storefronts`, }, { body: { app_id: appId, name, theme_app_id: themeAppId, }, onAsyncGetPath: (response) => ({ adminPath: `/storefronts/${response.id}`, }), })); } async deployAppFrontend(force, log) { const currentStore = localConfig.getDefaultStore(); const projectType = this.getFrontendProjectType(false); if (!projectType) { return; } const deploymentHash = this.getFrontendDeploymentHash(); // Build and deploy only if there are files to push if (deploymentHash && (force || deploymentHash !== this.app.frontend?.deployment_hash)) { await this.buildFrontend(projectType); const deploymentUrl = await this.deployFrontend(projectType); await this.updateFrontendDeployment(currentStore, projectType, deploymentUrl, deploymentHash); } if (log !== false) { if (this.app.type === 'storefront' && this.storefront) { this.logStorefrontFrontendUrl(this.storefront); } else if (this.app.type !== 'storefront') { // Get updated app with `installed` property this.app = await this.getApp(this.app.id); this.logAppFrontendUrl(); } } } async deployFrontend(projectType) { // TODO: check package.json for a "deploy" command and run that if it exists // Note it must return a URL somehow try { return this.wranglerDeployFrontend(projectType); } catch (error) { this.error(`Error deploying frontend: ${error.message}`); } } async ensureAppExists(file, shouldCreate = true) { const klass = this.ctor; const currentStore = localConfig.getDefaultStore(); await this.ensureLoggedIn(); if (klass.orientation?.env) { await this.api.setStoreEnv(currentStore, klass.orientation?.env); } const app = await this.getAppWithConfig('').catch((_error) => // Ignore so we can continue to create it null); const isTheme = app?.type === 'theme'; if (app && !isTheme && this.appType === 'theme') { this.error(`App ${style.appConfigValue(app?.name)} is not a theme. Use 'swell app' commands instead.`); } // Confirm development app unless theme without app syncing if (!isTheme || this.themeSyncApp) { const confirmExistingApp = await this.confirmRemoveInstalledApp(app, currentStore); if (confirmExistingApp === false) { return false; } } // Do not create or update if app already exists and not forcing it if (!shouldCreate && app) { this.app = app; return true; } // If app exists but owned by another client, create a new instance if (app?.client_id && app.client_id !== currentStore) { // Create a new app this.app = await this.getCreateUpdateApp(); if (!this.app) { return false; } } else if (!file) { // Update existing app if not targeting a file this.app = await this.getCreateUpdateApp(app); } return true; } async ensureLoggedIn() { try { const user = await this.api.get({ adminPath: 'user' }); if (!user) { throw new Error('Failed to log in'); } } catch { this.log('Logging in...\n'); await this.config.runCommand('login'); this.log(); return true; } } async exec(command, cwd, onOutput) { const $$ = $({ cwd: cwd || this.appPath, shell: true, stderr: onOutput ? 'pipe' : 'inherit', stdin: 'inherit', stdout: onOutput ? 'pipe' : 'inherit', }); if (onOutput) { const outStream = new Stream.Writable(); outStream._write = (chunk, _encoding, next) => { const string = chunk.toString(); const out = onOutput(string); if (out !== false) { console.log(string); } next(); }; await $$ `${command}`.pipeStdout?.(outStream).pipeStderr?.(outStream); } else { await $$ `${command}`; } } async execFrontend(command, onOutput) { return this.exec(command, this.frontendPath, onOutput); } async getAllAppStorefronts(params) { const query = Object.entries(params || {}) .map(([key, value]) => `${key}=${value}`) .join('&'); return this.handleRequestErrors(async () => this.api.get({ adminPath: `/apps/${this.app.id}/storefronts?${query}`, })); } async getAllStorefronts(params) { const query = Object.entries(params || {}) .map(([key, value]) => `${key}=${value}`) .join('&'); return this.handleRequestErrors(async () => this.api.get({ adminPath: `/storefronts?${query}`, })); } async getAppStorefront(params) { const query = Object.entries(params || {}) .map(([key, value]) => `${key}=${value}`) .join('&'); try { return this.handleRequestErrors(async () => this.api.get({ adminPath: `/apps/${this.app.id}/storefront?${query}`, })); } catch { // noop } } async getAppToPull(args, shouldChoose = true) { const { appId, targetPath } = args; const klass = this.ctor; const currentStore = localConfig.getDefaultStore(); await this.api.setStoreEnv(currentStore, klass.orientation?.env); this.resolveAppPath(appId, targetPath); if (isPathDirectory(this.appPath)) { this.swellConfig = swellConfig(this.appPath); const localAppId = this.swellConfig.get('id'); const localAppType = this.swellConfig.get('type'); if (localAppId) { if (appId && appId !== localAppId) { this.error(`${localAppType === 'theme' ? 'A theme' : 'An app'} already exists in this path with a different ID: ${style.appConfigValue(localAppId)}`); } return this.getExistingApp(localAppId); } } const app = appId ? await this.getExistingApp(appId) : shouldChoose ? await this.chooseAppToPull() : undefined; if (shouldChoose) { this.swellConfig = swellConfig(this.appPath); } return app; } async getExistingApp(appId) { try { return this.getApp(appId); } catch (error) { if (error.status === 404) { const labelPrefix = this.appType === 'theme' ? 'Theme' : 'App'; this.error(`${labelPrefix} '${appId}' not found.`); } else { throw error; } } } getFrontendDeploymentHash() { const localConfigs = this.getLocalAppConfigs(); let frontendHashes = localConfigs .filter((config) => config.filePath?.startsWith(`frontend${path.sep}`)) .map((config) => config.hash) .sort() .join('|'); if (frontendHashes.length > 0) { const packageHash = localConfigs.find((config) => config.filePath === 'package.json')?.hash; frontendHashes += `|${packageHash}`; } return frontendHashes.length > 0 ? hashString(frontendHashes) : undefined; } getFrontendProjectType(required = true) { this.frontendPath = path.join(this.appPath, 'frontend'); const frontendExists = filePathExists(this.frontendPath); if (!required && !frontendExists) { return null; } const projectType = getFrontendProjectType(this.appPath); if (!projectType) { // If frontend not required, just return null (don't error) if (!required) { return null; } // Frontend IS required but not found // Check if frontend directory exists but lacks package.json (old structure) if (frontendExists) { this.showFrontendMigrationError(); } // Frontend directory doesn't exist at all this.error(`No valid frontend app found in ${this.frontendPath}. Supported frameworks include: ${getFrontendProjectValidValues(false)}.`); } return projectType; } async getStorefrontToPull(args = {}, flags = {}) { const { appId, targetPath } = args; const targetStorefront = flags['storefront-id']; const targetApp = await this.getAppToPull(args, false); this.resolveAppPath(appId, targetPath); // Only use default if path exists let defaultStorefront; if (isPathDirectory(this.appPath)) { defaultStorefront = localConfig.getDefaultStorefront(this.appPath); } await this.setStorefrontEnv(flags, defaultStorefront); let storefrontId = targetStorefront || targetApp ? defaultStorefront?.id : undefined; if (!storefrontId || flags['storefront-select']) { const storefronts = await this.getAllStorefronts(targetApp?.id ? this.appType === 'theme' ? { theme_app_id: targetApp?.id } : { app_id: targetApp?.id } : { type: this.appType }); if (storefronts.count > 0) { storefrontId = await select({ choices: storefronts.results.map((storefront) => ({ name: `${style.appConfigValue(storefront.name)} (${storefront.id}) ${storefront.primary ? '[primary]' : ''}`, value: storefront.id, })), message: 'Choose a storefront', }); this.storefront = storefronts.results.find((storefront) => storefront.id === storefrontId); const appId = this.appType === 'theme' ? this.storefront.theme_app_id : this.storefront.app_id; this.resolveAppPath(this.storefront.name, targetPath); this.app = await this.getApp(appId); } else { // Theme apps should always have a storefront, so this shouldn't be necessary // However it's a good fallback in case the storefront was deleted // Choosing a theme app will proceed to pull app files this.app = await this.chooseAppToPull(); this.resolveAppPath(this.app.name, targetPath); } } else { this.app = targetApp; } if (storefrontId && !this.storefront) { this.storefront = await this.getAppStorefront({ storefront_id: storefrontId, type: 'theme', }); if (!this.storefront) { this.error(`Storefront ${style.appConfigValue(storefrontId)} not found.`); } const appId = this.appType === 'theme' ? this.storefront.theme_app_id : this.storefront.app_id; this.app = await this.getApp(appId); } this.swellConfig = swellConfig(this.appPath); return this.storefront; } async getStorefrontToPush(flags = {}) { const targetStorefront = flags['storefront-id']; const defaultStorefront = localConfig.getDefaultStorefront(this.appPath); let storefrontId = targetStorefront || defaultStorefront?.id; // Force storefront select if pushing app from default storefront in live environment if (this.themeSyncApp && defaultStorefront?.id && !defaultStorefront.env) { storefrontId = ''; } if (!storefrontId || flags['storefront-select']) { const storefronts = await this.getAllAppStorefronts({ type: this.appType, }); // Choose storefront if multiple storefronts exist // Except if we are syncing a theme app, then force create new storefront if not found if (storefronts.count > 0 && !this.themeSyncApp) { storefrontId = await select({ choices: [ ...storefronts.results.map((storefront) => ({ name: `${style.appConfigValue(storefront.name)} (${storefront.id}) ${storefront.primary ? '[primary]' : ''}`, value: storefront.id, })), { name: 'Create new storefront', value: null, }, ], message: 'Choose a storefront', }); } if (!storefrontId) { this.storefront = await this.createAppStorefront(storefronts.count > 0); } } if (storefrontId) { this.storefront = await this.getAppStorefront({ storefront_id: storefrontId, type: this.appType, }); if (!this.storefront) { if (targetStorefront) { this.error(`Storefront ${style.appConfigValue(storefrontId)} not found.`); } this.log(`Storefront ${style.appConfigValue(storefrontId)} not found.\n`); localConfig.setDefaultStorefront(this.appPath, ''); return this.getStorefrontToPush(); } } this.app = await this.getApp(this.app.id); return this.storefront; } async setStorefrontEnv(flags, defaultStorefront) { const currentStore = localConfig.getDefaultStore(); const targetStorefront = flags['storefront-id']; if (defaultStorefront && !targetStorefront && !flags['storefront-id'] && !flags['storefront-select']) { const klass = this.ctor; if (klass.orientation?.env === undefined) { await this.api.setStoreEnv(currentStore, defaultStorefront?.env); } } else if (flags.env) { await this.api.setStoreEnv(currentStore, flags.env); } else { const hasTestEnv = await this.api.isTestEnvEnabled(); if (hasTestEnv) { const env = await selectEnvironmentId(); await this.api.setStoreEnv(currentStore, env); } } } async handleWatchFileChange(configFile, configType, event) { let config; switch (event) { case 'deleted': { config = findAppConfig(this.app, configFile, configType); if (!config) { return; } this.watchingChangeQueue.set(configFile, { action: 'remove', appConfig: config, configFile, }); break; } default: { config = appConfigFromFile(configFile, configType, this.appPath); this.watchingChangeQueue.set(configFile, { action: 'push', appConfig: config, configFile, }); break; } } if (this.watchingTimer === null) { this.watchingTimer = setTimeout(async () => { try { await this.runWatchChangeQueue(); } finally { this.watchingTimer = null; } }, WATCH_WINDOW_MS); } } logAppFrontendUrl() { const currentStore = localConfig.getDefaultStore(); // Shouldn't happen, but just in case if (!this.app.installed?.id) { return; } this.log(`View your app at ${style.link(this.appFrontendUrl(currentStore, this.app.installed.id))}.`); } logDashboardUrl(flags) { const currentStore = localConfig.getDefaultStore(); this.log(`View the ${style.appConfigValue(this.app.name)} app in your dashboard at ${style.link(this.dashboardUrl(flags, currentStore))}.`); } logStorefrontConnected() { if (!this.storefront) { this.error('Storefront not connected'); } const { name } = this.storefront; this.log(`[storefront] ${style.appName(name)}\n`); } logStorefrontFrontendUrl(storefront, branchId) { const currentStore = localConfig.getDefaultStore(); this.log(`View your storefront at ${style.link(this.storefrontFrontendUrl(currentStore, storefront, branchId))}.`); } async pushFile(relativePath) { const basePath = relativePath.split(path.sep)[0]; const configType = getConfigTypeFromPath(basePath) || ConfigType.FILE; const config = appConfigFromFile(relativePath, configType, this.appPath); await this.pushRemoteFile(config); } async pushFilePath(relativePath, force) { const topDir = relativePath.split(path.sep)[0]; const configType = (getConfigTypeFromPath(topDir) || 'file'); const configTypeKey = getConfigTypeKeyFromValue(configType) || 'file'; const exAppConfigs = (this.app?.configs || []).filter((config) => config.filePath?.startsWith(`${relativePath}/`)); const allFiles = []; const pushFiles = []; for (const { configFile } of allConfigFilesInDir(this.appPath, relativePath, configTypeKey)) { const config = appConfigFromFile(configFile, configType, this.appPath); allFiles.push(config); const exConfig = exAppConfigs.find((exConfig) => config.filePath === exConfig.filePath); if (!force && exConfig && exConfig.hash === config.hash) { continue; } pushFiles.push(config); } const filesToRemove = exAppConfigs.filter((config) => !allFiles.some((appConfig) => appConfig.filePath === config.filePath)); const hasChanges = pushFiles.length > 0 || filesToRemove.length > 0; if (pushFiles.length > 0) { const pushConcurrency = configTypeKey === 'ASSET' ? 1 : PUSH_CONCURRENCY; while (pushFiles.length > 0) { // eslint-disable-next-line no-await-in-loop await Promise.all(pushFiles .splice(0, pushConcurrency) .map(async (appConfig) => { await this.pushRemoteFile(appConfig); })); } } // 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, PUSH_CONCURRENCY).map(async (appConfig) => { await this.removeRemoteFile(appConfig); })); } } if (!hasChanges && !force) { this.log(`${style.success('✔')} All ${style.path(relativePath)} files are up to date.`); } else { this.log(); this.log(`${style.success('✔')} Pushed ${style.path(relativePath)} to ${style.appConfigValue(this.app.name)}.`); } } resolveAppPath(appId, targetPath) { const resolvedAppId = appId || ''; let resolvedPath = targetPath || resolvedAppId; // If app_id matches directory, use it as target path if (!targetPath && isPathDirectory(path.resolve(process.cwd(), resolvedAppId))) { resolvedPath = resolvedAppId; } // Reset app path if appId is provided if (resolvedPath && this.appPath === process.cwd()) { this.appPath = path.resolve(process.cwd(), resolvedPath); } return this.appPath; } async runWatchChangeQueue() { if (this.watchingChangeQueue.size <= 0) { return; } const queue = [...this.watchingChangeQueue.values()]; this.watchingChangeQueue.clear(); // Process queue in batches with concurrency control /* eslint-disable no-await-in-loop */ while (queue.length > 0) { try { await Promise.all(queue .splice(0, PUSH_CONCURRENCY) .map(async ({ action, appConfig }) => { const result = await (action === 'remove' ? this.removeRemoteFile(appConfig, this.logWatchChanges) : this.pushRemoteFile(appConfig, this.logWatchChanges)); this.onWatchChange?.(appConfig, action, result); })); } catch { // noop } } /* eslint-enable no-await-in-loop */ // refetch app to get updated configs this.app = await this.getAppWithConfig(this.app.id); // Process next accumulated changes return this.runWatchChangeQueue(); } saveCurrentStorefront() { const currentStorefront = localConfig.getDefaultStorefront(this.appPath); if (this.storefront && this.storefront.id !== currentStorefront) { localConfig.setDefaultStorefront(this.appPath, this.storefront.id, this.api.envId); } } async setWatchingFiles() { this.watchingFiles = new Set(await globAllFilesByPath(this.appPath)); } async startProxyServer(port) { // Find an open port starting at 3000 const freePort = port || (await getPort({ port: portNumbers(3000, 3100) })) || DEV_SERVER_FALLBACK_PORT; // Start proxy const proxyUrl = await getProxyUrl(freePort); await this.updateLocalProxy(proxyUrl, this.storefront?.id); return freePort; } async updateFrontendDeployment(currentStore, projectType, deploymentUrl, deploymentHash) { const spinner = ora(); spinner.start('Updating frontend deployment...'); await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${this.app.id}` }, { body: { $client_id: currentStore, $environment_id: 'test', frontend: { deployment_hash: deploymentHash, framework: projectType.slug, service: 'cloudflare', url: deploymentUrl, }, }, }), () => spinner.fail('Error updating app deployment')); spinner.succeed('Updated frontend deployment.\n'); } async updateLocalProxy(proxyUrl, storefrontId) { const storefront = this.app.type === 'storefront' && (await this.getAppStorefront({ storefront_id: storefrontId })); await this.handleRequestErrors(async () => this.api.put({ adminPath: `/client/apps/${this.app.id}/local-proxy` }, { body: { proxy_url: proxyUrl, storefront_id: storefront?.id || null, storefront_slug: storefront?.slug || null, }, })); } async watchForChanges({ logChanges, onChange, syncAll, } = {}) { this.watchingIgnoreFilter = await getGlobIgnorePathsChecker(this.appPath); this.logWatchChanges = logChanges ?? true; this.onWatchChange = onChange; await this.setWatchingFiles(); if (syncAll) { await this.pushAppConfigs(); fs.watch(this.appPath, { recursive: true }, async (_eventType, _filename) => { await this.pushAppConfigs(); await this.setWatchingFiles(); }); return; } fs.watch(this.appPath, { recursive: true }, this.watchListener); } async wranglerDeployFrontend(_projectType) { let deploymentUrl; let interactiveError = false; let pagesProjectError = false; this.log(`\nDeploying to Cloudflare...\n`); try { await this.execFrontend(`npx wrangler deploy`, (string) => { // Dependent on wrangler output // Parse the deployment URL from the wrangler output. const match = string.match(/^\s*(https:\/\/\S+\.workers\.dev)\s*[\S\s]*?Current Version ID: [\w-]+/m); if (match && match.length > 0) { deploymentUrl = match[1]; return false; } // Check for Pages project error if (string.includes("It looks like you've run a Workers-specific command in a Pages project") || string.includes('please run `wrangler pages deploy` instead') || string.includes('Missing entry-point')) { pagesProjectError = true; return false; } if (interactiveError || string.includes('non-interactive mode') || string.includes('non-interactive environment')) { interactiveError = true; return false; } }); } catch (error) { // noop if (pagesProjectError) { this.error(style.funcWarn(`⚠️ Version incompatibility!\n` + `Your project is configured for Cloudflare Pages, but Swell CLI v2.1.0+ uses Cloudflare Workers.\n\n` + `To fix this, you can either:\n` + `1. Update your app project (for Swell official apps) or reconfigure it for workers\n` + ` (https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/)\n` + `2. Use an older Swell CLI version: npm install -g @swell/cli@2.0.20\n`)); } else if (interactiveError) { this.error(style.funcWarn(`Your Cloudflare environment must be initialized by logging in with \`wrangler login\`, and exporting the \`CLOUDFLARE_ACCOUNT_ID\` environment variable. Refer to https://developers.cloudflare.com/workers/wrangler/configuration/ for details, and re-run this command to connect the deployment with your app.`)); } else { throw error; } } if (!deploymentUrl) { this.error('Unable to retrieve deployment URL.'); } return deploymentUrl; } async confirmRemoveInstalledApp(app, currentStore) { if (!app?.private_id) { return true; } const existingInstalledApp = await this.api.get({ adminPath: `/client/apps/${app.private_id}`, }, { query: { versioned: true } }); if (existingInstalledApp) { const continuePush = await confirm({ default: false, message: `${style.storeId(currentStore)} has an installed version of ${app.name ? style.appConfigValue(app.name) : 'this app'} v${existingInstalledApp.version}. Do you want to replace it with a development instance?`, }); if (!continuePush) { return false; } // uninstall the existing app const spinner = ora(); spinner.start('Removing installed app...'); try { await this.api.put({ adminPath: `/client/apps/${app.id}`, }, { body: { uninstalled: true }, }); spinner.succeed('Installed app removed.'); this.log(); } catch (error) { spinner.fail(`Error removing installed app from ${style.storeId(currentStore)}.`); this.logError(error.message); return false; } } return existingInstalledApp; } }