UNPKG

@swell/cli

Version:

Swell's command line interface/utility

442 lines (441 loc) 17.6 kB
import { confirm, select } from '@inquirer/prompts'; import { Flags } from '@oclif/core'; import { $ } from 'execa'; import { exec } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import Stream from 'node:stream'; import { promisify } from 'node:util'; import ora from 'ora'; import Api from './lib/api.js'; import { FrontendProjectTypes, getFrontendProjectValidValues, getAllConfigPaths, getFrontendProjectSlugs, writeFile, writeJsonFile, } from './lib/apps/index.js'; import style from './lib/style.js'; import { SwellCommand } from './swell-command.js'; const execAsync = promisify(exec); export class CreateAppCommand extends SwellCommand { // Command name used in error message examples; override in subclasses commandExample = 'swell create app'; static baseFlags = { frontend: Flags.string({ description: 'Framework: astro | angular | hono | nuxt | nextjs | none', options: getFrontendProjectSlugs(true, false), }), 'storefront-app': Flags.string({ description: 'Target storefront app ID', }), 'integration-type': Flags.string({ description: 'Integration: generic | payment | shipping | tax', options: ['generic', 'payment', 'shipping', 'tax'], }), 'integration-id': Flags.string({ description: 'Service ID (e.g., card, fedex)', }), yes: Flags.boolean({ char: 'y', description: 'Skip prompts, require all arguments', }), }; devApi; constructor(argv, config) { super(argv, config); this.devApi = new Api(undefined, 'test'); } async addAllowedHostsToAngular(configPath) { const angularJsonPath = path.join(configPath, 'frontend', 'angular.json'); let content; try { content = await fs.readFile(angularJsonPath, 'utf8'); } catch { return; } let config; try { config = JSON.parse(content); } catch { return; } const { projects } = config; if (!projects || typeof projects !== 'object') { return; } const projectNames = Object.keys(projects); if (projectNames.length === 0) { return; } // Use first project (typically only one exists in new apps) const projectName = projectNames[0]; const project = projects[projectName]; if (!project?.architect?.serve) { return; } if (!project.architect.serve.options) { project.architect.serve.options = {}; } if (project.architect.serve.options.allowedHosts) { return; } project.architect.serve.options.allowedHosts = ['.ngrok.app', '.loca.lt']; await fs.writeFile(angularJsonPath, JSON.stringify(config, null, 2), 'utf8'); } async addAllowedHostsToAstro(configPath) { const astroConfigPath = path.join(configPath, 'frontend', 'astro.config.mjs'); let content; try { content = await fs.readFile(astroConfigPath, 'utf8'); } catch { return; } if (content.includes('allowedHosts')) { return; } const lines = content.split('\n'); let insertIndex = -1; for (const [i, line] of lines.entries()) { if (line.includes('defineConfig({')) { insertIndex = i + 1; break; } } if (insertIndex === -1) { return; } // Detect indentation from next line or use default const nextLine = lines[insertIndex]; const indentMatch = nextLine?.match(/^(\s+)/); const baseIndent = indentMatch ? indentMatch[1] : ' '; const viteConfig = [ `${baseIndent}vite: {`, `${baseIndent} server: {`, `${baseIndent} allowedHosts: ['.ngrok.app', '.loca.lt'],`, `${baseIndent} },`, `${baseIndent}},`, ]; lines.splice(insertIndex, 0, ...viteConfig); await fs.writeFile(astroConfigPath, lines.join('\n'), 'utf8'); } async addAllowedHostsToNuxt(configPath) { const nuxtConfigPath = path.join(configPath, 'frontend', 'nuxt.config.ts'); let content; try { content = await fs.readFile(nuxtConfigPath, 'utf8'); } catch { return; } if (content.includes('allowedHosts')) { return; } const lines = content.split('\n'); let insertIndex = -1; for (const [i, line] of lines.entries()) { if (line.includes('defineNuxtConfig({')) { insertIndex = i + 1; break; } } if (insertIndex === -1) { return; } const nextLine = lines[insertIndex]; const indentMatch = nextLine?.match(/^(\s+)/); const baseIndent = indentMatch ? indentMatch[1] : ' '; const viteConfig = [ `${baseIndent}vite: {`, `${baseIndent} server: {`, `${baseIndent} allowedHosts: ['.ngrok.app', '.loca.lt'],`, `${baseIndent} },`, `${baseIndent}},`, ]; lines.splice(insertIndex, 0, ...viteConfig); await fs.writeFile(nuxtConfigPath, lines.join('\n'), 'utf8'); } async createAppConfigFolders(swellConfig) { for (const type of getAllConfigPaths(swellConfig.get('type'))) { // Create a config folder // eslint-disable-next-line no-await-in-loop await execAsync(`mkdir -p ${type}`, { cwd: path.dirname(swellConfig.path), }); } } async createFrontendApp(swellConfig, flags, directCreate, kind) { const spinner = ora(); const configPath = path.dirname(swellConfig.path); // use 'none' by default for non-interactive mode const inputFrontend = flags.yes ? flags.frontend || 'none' : flags.frontend; const frameworkType = inputFrontend || (await select({ choices: [ ...(directCreate ? [] : [ { name: 'Do not install a frontend framework', value: null, }, ]), ...FrontendProjectTypes.map(({ name, slug }) => ({ name, value: slug, })), ], message: directCreate ? `Choose framework for your hosted${kind ? ` ${kind} ` : ' '}app frontend` : `Install framework for a hosted${kind ? ` ${kind} ` : ' '}app frontend?`, })); if (frameworkType && frameworkType !== 'none') { const projectType = this.getProjectType(frameworkType); this.log(); spinner.start(`Creating ${projectType?.name} frontend app (this may take a while)...`); try { await execAsync(`mkdir -p frontend`, { cwd: configPath, }); await execAsync(projectType.installCommand, { cwd: configPath, }); // Use this command to debug output, i.e.e when command becomes non-responsive /* await this.execWithStdio( configPath, projectType.installCommand, ); */ } catch (error) { spinner.fail(`Error creating ${projectType?.name} app:\n`); this.log(error.stdout); this.log(); return false; } // Ensure frontend package.json has correct name for workspace const frontendPkgPath = path.join(configPath, 'frontend', 'package.json'); try { const pkgContent = await fs.readFile(frontendPkgPath, 'utf8'); const pkg = JSON.parse(pkgContent); if (pkg.name !== 'frontend') { pkg.name = 'frontend'; await fs.writeFile(frontendPkgPath, JSON.stringify(pkg, null, 2), 'utf8'); } } catch { // Ignore if package.json doesn't exist or can't be read } await this.addFrontendAllowedHosts(projectType, configPath); // Re-run npm install at root to properly initialize workspace structure // (removes frontend/package-lock.json, hoists dependencies, creates root lock file) spinner.start('Initializing workspace...'); try { const execAsync = promisify(exec); await execAsync('npm install', { cwd: configPath }); spinner.succeed('Workspace initialized'); } catch { spinner.warn('Failed to initialize workspace'); } spinner.succeed(`${projectType?.name} initialized app in ${configPath}/frontend/`); if (directCreate) { this.log(); } return true; } return false; } async createStorefrontApp(swellConfig, flags, directCreate) { return this.createFrontendApp(swellConfig, flags, directCreate, 'storefront'); } async createThemeApp(config, flags, installedStorefrontApp) { const spinner = ora(); const configPath = path.dirname(config.path); const defaultThemeConfigs = await this.devApi.get({ adminPath: `/client/apps/${installedStorefrontApp.app_id}/theme-template-configs`, }); if (defaultThemeConfigs.results?.length > 0) { const { name: appName, version: appVersion } = installedStorefrontApp.app; const shouldCreate = flags.yes || (await confirm({ message: `Create a theme template for ${style.appConfigValue(appName)} ${appVersion ? `v${appVersion}` : ''}?`, })); if (shouldCreate) { this.log(); spinner.start(`Creating theme template...`); try { await execAsync(`mkdir -p theme`, { cwd: configPath, }); for (const themeConfig of defaultThemeConfigs.results) { const filePath = themeConfig.file_path.replace(/^frontend\/theme-template\//, ''); const fileContent = themeConfig.file_data; const isJson = filePath.endsWith('.json'); // eslint-disable-next-line no-await-in-loop await execAsync(`mkdir -p ${path.dirname(filePath)}`, { cwd: configPath, }); // eslint-disable-next-line no-await-in-loop await (isJson ? writeJsonFile(path.join(configPath, filePath), fileContent) : writeFile(path.join(configPath, filePath), fileContent)); } } catch (error) { spinner.fail(`Error creating theme template:\n`); this.log(error.stdout); this.log(); return false; } spinner.succeed(`Theme template initialized in ${configPath}/theme/`); } } return true; } async doesPackageManagerExist(packageManager) { try { await execAsync(`${packageManager} --version`); return true; } catch { return false; } } async execWithStdio(cwd, command, onOutput) { const $$ = $({ cwd, shell: true, stderr: 'inherit', stdin: 'inherit', stdout: 'pipe', }); 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(); }; try { await $$ `${command}`.pipeStdout?.(outStream); } catch (error) { console.log(error); } } async findPackageManager(pkg = '') { const packageManager = pkg; // Determine if system has yarn or npm install if (packageManager) { if (await this.doesPackageManagerExist(packageManager)) { return packageManager; } } else { if (await this.doesPackageManagerExist('npm')) { return 'npm'; } if (await this.doesPackageManagerExist('yarn')) { return 'yarn'; } } } async getInstalledStorefrontApps() { const spinner = ora(); spinner.start('Retrieving installed storefront apps...'); const installedApps = await this.devApi.get({ adminPath: `/client/apps`, }); const storefrontInstalledApps = installedApps.results.filter((installedApp) => installedApp.app?.type === 'storefront'); if (storefrontInstalledApps.length === 0) { spinner.fail(`You must have at least one storefront app installed in your ${style.appEnv('test')} environment before creating a theme.`); return false; } spinner.stop(); return storefrontInstalledApps; } getProjectType(frameworkType) { const projectType = FrontendProjectTypes.find((type) => type.slug === frameworkType); if (!projectType) { this.error(`Could not find project type: ${frameworkType}\n\nValid values: ${getFrontendProjectValidValues(true, false)}\n\nExample: ${this.commandExample} reviews --type admin --frontend astro -y`, { exit: 1, }); } if (!projectType.installCommand) { this.error(`Project type ${projectType.name} cannot be installed (legacy type)\n\nValid values: ${getFrontendProjectValidValues(true, false)}\n\nExample: ${this.commandExample} reviews --type admin --frontend astro -y`, { exit: 1, }); } return { ...projectType, installCommand: projectType.installCommand || '', }; } async setupPackage(name, config, pkg) { const execAsync = promisify(exec); const configPath = path.dirname(config.path); const packageJson = { description: config.get('description'), // Include workspace even if frontend doesn't exist yet // npm tolerates missing workspace directories without errors workspaces: ['frontend'], devDependencies: { '@swell/app-types': '^1.0.5', typescript: '^5.9.3', }, name, scripts: { typecheck: '([ -z "$(find functions -name \'*.ts\' 2>/dev/null | head -1)" ] || tsc --noEmit) && ([ ! -f test/tsconfig.json ] || tsc --noEmit -p test) && ([ ! -f frontend/tsconfig.json ] || tsc --noEmit -p frontend)', }, version: config.get('version'), }; const tsConfig = { compilerOptions: { lib: ['esnext', 'webworker'], module: 'esnext', target: 'esnext', moduleResolution: 'bundler', types: ['@swell/app-types'], }, exclude: ['node_modules', 'frontend', 'test', 'vitest.config.ts'], }; await writeJsonFile(path.join(configPath, 'package.json'), packageJson); await writeJsonFile(path.join(configPath, 'tsconfig.json'), tsConfig); await writeFile(path.join(configPath, '.gitignore'), `node_modules`); try { const packageManager = await this.findPackageManager(pkg); await execAsync(`${packageManager} install`, { cwd: configPath, }); } catch (error) { this.error(error.message); } } async tryPackageSetup(name, config, pkg) { try { await this.setupPackage(name, config, pkg); } catch (error) { this.log(`There was an error setting up the package manager: ${error.message}`); } } async addFrontendAllowedHosts(projectType, configPath) { // Configure allowedHosts for frameworks using Vite dev server // This allows tunneling services (ngrok, localtunnel) to proxy requests to the local dev environment switch (projectType.slug) { case 'astro': { await this.addAllowedHostsToAstro(configPath); break; } case 'angular': { await this.addAllowedHostsToAngular(configPath); break; } case 'nuxt': { await this.addAllowedHostsToNuxt(configPath); break; } // Other frameworks don't require allowedHosts configuration } } }