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