@swell/cli
Version:
Swell's command line interface/utility
596 lines (595 loc) • 25 kB
JavaScript
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
}
}
}