UNPKG

@swell/cli

Version:

Swell's command line interface/utility

596 lines (595 loc) 25 kB
import { confirm, select } from '@inquirer/prompts'; import { Flags } from '@oclif/core'; import { $ } from 'execa'; import { exec } from 'node:child_process'; import * as fs from 'node:fs/promises'; import * as 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 { getPackageManagerCommands, transformCreateCommand, } from './lib/package-manager.js'; import style from './lib/style.js'; import { SwellCommand } from './swell-command.js'; const execAsync = promisify(exec); /** Allowed hosts for tunnel providers used in local development */ const TUNNEL_ALLOWED_HOSTS = ['.trycloudflare.com', '.swell.store']; /** * Find the closing brace position for a config function call (e.g., defineConfig, defineNuxtConfig). * Uses brace counting to find the matching `}` for the opening `{`. * @returns Position of the closing `}`, or -1 if not found */ function findConfigClosingBrace(content, functionName) { const pattern = new RegExp(`${functionName}\\s*\\(\\s*\\{`); const match = content.match(pattern); if (!match || match.index === undefined) { return -1; } const startPos = match.index + match[0].length; let depth = 1; for (let i = startPos; i < content.length && depth > 0; i++) { const char = content[i]; if (char === '{') { depth++; } else if (char === '}') { depth--; if (depth === 0) { return i; } } } return -1; } 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: ${getFrontendProjectSlugs(true, false).join(' | ')}`, 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 = TUNNEL_ALLOWED_HOSTS; await fs.writeFile(angularJsonPath, JSON.stringify(config, null, 2), 'utf8'); } async addAllowedHostsToAstro(configPath) { return this.addViteAllowedHosts(configPath, 'astro.config.mjs', 'defineConfig'); } async addAllowedHostsToNuxt(configPath) { return this.addViteAllowedHosts(configPath, 'nuxt.config.ts', 'defineNuxtConfig'); } /** * For frameworks whose dev server is Vite directly. Astro/Nuxt nest the * vite block inside their own defineConfig — see addViteAllowedHosts. */ async addAllowedHostsToVite(configPath) { const candidates = [ 'vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs', ]; let configFilePath; let content; for (const fileName of candidates) { const candidatePath = path.join(configPath, 'frontend', fileName); try { // eslint-disable-next-line no-await-in-loop content = await fs.readFile(candidatePath, 'utf8'); configFilePath = candidatePath; break; } catch { /* try next */ } } if (!configFilePath || content === undefined) { return; } if (content.includes('allowedHosts')) { return; } const serverBlock = ` server: { allowedHosts: ${JSON.stringify(TUNNEL_ALLOWED_HOSTS)}, },`; const emptyConfig = 'defineConfig({})'; if (content.includes(emptyConfig)) { content = content.replace(emptyConfig, `defineConfig({\n${serverBlock}\n})`); await fs.writeFile(configFilePath, content, 'utf8'); return; } const closingPos = findConfigClosingBrace(content, 'defineConfig'); if (closingPos === -1) { return; } const before = content.slice(0, closingPos); const after = content.slice(closingPos); const needsComma = before.trimEnd().slice(-1) !== ','; const separator = needsComma ? ',\n' : '\n'; content = before.trimEnd() + separator + serverBlock + '\n' + after; await fs.writeFile(configFilePath, content, 'utf8'); } /** * Add vite allowedHosts configuration to a framework config file. * Supports config files that use a defineX({}) pattern (Astro, Nuxt, etc.) */ async addViteAllowedHosts(configPath, configFile, functionName) { const configFilePath = path.join(configPath, 'frontend', configFile); let content; try { content = await fs.readFile(configFilePath, 'utf8'); } catch { return; } if (content.includes('allowedHosts')) { return; } const viteConfig = ` vite: { server: { allowedHosts: ${JSON.stringify(TUNNEL_ALLOWED_HOSTS)}, }, },`; // Handle empty config: functionName({}) const emptyConfig = `${functionName}({})`; if (content.includes(emptyConfig)) { content = content.replace(emptyConfig, `${functionName}({\n${viteConfig}\n})`); await fs.writeFile(configFilePath, content, 'utf8'); return; } // Find the closing brace of the config function using brace counting const closingPos = findConfigClosingBrace(content, functionName); if (closingPos === -1) { return; } const before = content.slice(0, closingPos); const after = content.slice(closingPos); // Ensure proper comma before vite config const needsComma = before.trimEnd().slice(-1) !== ','; const separator = needsComma ? ',\n' : '\n'; content = before.trimEnd() + separator + viteConfig + '\n' + after; await fs.writeFile(configFilePath, content, '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 appType = swellConfig.get('type'); const frameworkType = inputFrontend || (await select({ choices: [ ...(directCreate ? [] : [ { name: 'Do not install a frontend framework', value: null, }, ]), ...FrontendProjectTypes.filter((pt) => !pt.appTypes || pt.appTypes.includes(appType)) .sort((a, b) => (a.displayOrder ?? Number.POSITIVE_INFINITY) - (b.displayOrder ?? Number.POSITIVE_INFINITY)) .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); if (projectType.appTypes && !projectType.appTypes.includes(appType)) { this.error(`${projectType.name} is not available for '${appType}' apps. Allowed app types: ${projectType.appTypes.join(', ')}.`, { exit: 1 }); } // Determine package manager - 'none' is not valid for frontend scaffolding let pkg = (flags.pkg || 'npm'); if (flags.pkg === 'none') { this.log(`\n${style.dim('Note: --pkg none is not available for frontend scaffolding, using npm.')}`); pkg = 'npm'; // Ensure root package.json exists (skipped when --pkg none) const rootPkgPath = path.join(configPath, 'package.json'); try { await fs.access(rootPkgPath); } catch { // Root package.json doesn't exist, create it await this.setupPackage(swellConfig.get('id'), swellConfig, pkg); } } this.log(); // Check for placeholder frontend/package.json and remove it before C3 scaffolding const frontendPath = path.join(configPath, 'frontend'); const frontendPkgPath = path.join(frontendPath, 'package.json'); try { const content = await fs.readFile(frontendPkgPath, 'utf8'); const frontendPkg = JSON.parse(content); // Only remove if it matches our placeholder signature if (frontendPkg.version === '0.0.0' && frontendPkg.name === 'frontend' && frontendPkg.private === true) { await fs.unlink(frontendPkgPath); } } catch { // No package.json or can't read, that's fine } // Check if frontend folder exists and has files (user-created content) try { const files = await fs.readdir(frontendPath); if (files.length > 0) { spinner.fail('frontend/ folder is not empty. Please remove existing files before scaffolding.'); return false; } } catch { // Folder doesn't exist, that's fine - C3 will create it } spinner.start(`Creating ${projectType?.name} frontend app (this may take a while)...`); try { await execAsync(`mkdir -p frontend`, { cwd: configPath, }); // Transform install command for the selected package manager const installCommand = transformCreateCommand(projectType.installCommand, pkg); await execAsync(installCommand, { cwd: configPath, }); // Use this command to debug output, i.e.e when command becomes non-responsive /* await this.execWithStdio( configPath, installCommand, ); */ } catch (error) { spinner.fail(`Error creating ${projectType?.name} app:`); const detail = [error.stdout, error.stderr] .map((s) => (s || '').trim()) .filter(Boolean) .join('\n') || (error.message ?? '').trim(); if (detail) this.log(detail); return false; } // Ensure frontend package.json has correct name for workspace try { const pkgContent = await fs.readFile(frontendPkgPath, 'utf8'); const frontendPkgJson = JSON.parse(pkgContent); if (frontendPkgJson.name !== 'frontend') { frontendPkgJson.name = 'frontend'; await fs.writeFile(frontendPkgPath, JSON.stringify(frontendPkgJson, null, 2), 'utf8'); } } catch { // Ignore if package.json doesn't exist or can't be read } await this.addFrontendAllowedHosts(projectType, configPath); // Run install at root to hoist dependencies to workspace spinner.start('Initializing workspace...'); try { const { install } = getPackageManagerCommands(pkg); await execAsync(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:`); const detail = [error.stdout, error.stderr] .map((s) => (s || '').trim()) .filter(Boolean) .join('\n') || (error.message ?? '').trim(); if (detail) this.log(detail); 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'), devDependencies: { '@swell/app-types': '^1.2.0', typescript: '^5.9.3', }, name, // Required for yarn workspaces, good practice for all package managers private: true, scripts: { typecheck: '([ -z "$(find functions -name \'*.ts\' 2>/dev/null | head -1)" ] || tsc --noEmit) && ([ ! -f test/tsconfig.json ] || tsc --build --noEmit test) && ([ ! -f frontend/tsconfig.json ] || tsc --build --noEmit frontend)', }, version: config.get('version'), }; // pnpm uses pnpm-workspace.yaml instead of workspaces field in package.json if (pkg !== 'pnpm') { packageJson.workspaces = ['frontend']; } 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\n.wrangler\n.dev.vars\n.dev.vars.*\n`); // Create pnpm-workspace.yaml for pnpm (required for workspace support) if (pkg === 'pnpm') { await writeFile(path.join(configPath, 'pnpm-workspace.yaml'), 'packages:\n - frontend\n'); } // Create placeholder frontend/package.json for bun and yarn // These package managers require workspace directories to exist with a package.json if (pkg === 'bun' || pkg === 'yarn') { const frontendPath = path.join(configPath, 'frontend'); await fs.mkdir(frontendPath, { recursive: true }); await writeJsonFile(path.join(frontendPath, 'package.json'), { name: 'frontend', private: true, version: '0.0.0', }); } // Install root dependencies using selected package manager const { install } = getPackageManagerCommands(pkg); await execAsync(install, { cwd: configPath }); } 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}`); } } /** * Fix wrangler.jsonc `main` field when it points to a build artifact that * doesn't exist at dev time. The @cloudflare/vite-plugin validates this path * at startup and crashes during `astro dev` if it can't resolve it. * * Only replaces the exact legacy value that C3 currently scaffolds. * Once C3 updates its template, this becomes a no-op. */ async fixAstroWranglerEntrypoint(configPath) { const filePath = path.join(configPath, 'frontend', 'wrangler.jsonc'); let content; try { content = await fs.readFile(filePath, 'utf8'); } catch { return; } const legacy = '"main": "./dist/_worker.js/index.js"'; if (!content.includes(legacy)) { return; } const updated = content.replace(legacy, '"main": "@astrojs/cloudflare/entrypoints/server"'); await fs.writeFile(filePath, updated, 'utf8'); } async addFrontendAllowedHosts(projectType, configPath) { // Configure allowedHosts for frameworks using Vite dev server // This allows tunneling services (localtunnel) to proxy requests to the local dev environment switch (projectType.slug) { case 'astro': { await this.addAllowedHostsToAstro(configPath); await this.fixAstroWranglerEntrypoint(configPath); break; } case 'angular': { await this.addAllowedHostsToAngular(configPath); break; } case 'nuxt': { await this.addAllowedHostsToNuxt(configPath); break; } case 'react': case 'react-storefront': { await this.addAllowedHostsToVite(configPath); break; } // Other frameworks don't require allowedHosts configuration } } }