@swell/cli
Version:
Swell's command line interface/utility
403 lines (402 loc) • 17.6 kB
JavaScript
import { confirm, input, select } from '@inquirer/prompts';
import { Args, Flags } from '@oclif/core';
import path from 'node:path';
import ora from 'ora';
import { CreateAppCommand } from '../../create-app-command.js';
import { newConfig, swellConfigFileExists } from '../../lib/app-config.js';
import { toAppId, toAppName } from '../../lib/create/index.js';
import style from '../../lib/style.js';
/**
* Shared helpMeta for app creation commands (create app, app init).
* Defines the APP TYPES section and type-specific flags.
*/
export const appHelpMetaBase = {
typeSection: {
title: 'APP TYPES',
types: [
{
name: 'admin',
description: 'Dashboard extension',
optional: ['--frontend'],
},
{
name: 'storefront',
description: 'Hosted storefront',
optional: ['--frontend'],
},
{
name: 'theme',
description: 'Theme for existing storefront',
requires: ['--storefront-app'],
},
{
name: 'integration',
description: 'Third-party service',
requires: [
'--integration-type',
'--integration-id (for payment | shipping | tax)',
],
},
],
},
typeFlags: [
'frontend',
'storefront-app',
'integration-type',
'integration-id',
],
};
export default class CreateApp extends CreateAppCommand {
static args = {
id: Args.string({
default: '',
description: 'App identifier (e.g., my-app)',
}),
};
static description = 'Create an app.';
static examples = [
'$ swell create app',
'$ swell create app my_app -t admin -y',
'$ swell create app my_app -t admin --frontend astro -y',
'$ swell create app my_theme -t theme --storefront-app proxima -y',
'$ swell create app my_payment -t integration --integration-type payment --integration-id card -y',
'$ swell create app my_shipping -t integration --integration-type shipping --integration-id fedex -y',
];
static helpMeta = {
...appHelpMetaBase,
usageDirect: '<id> -t <type> [...] -y',
};
static flags = {
pkg: Flags.string({
char: 'p',
default: 'npm',
description: 'Package manager: npm | yarn | none',
options: ['npm', 'yarn', 'none'],
}),
type: Flags.string({
char: 't',
description: 'App type: admin | storefront | theme | integration',
options: ['admin', 'integration', 'storefront', 'theme'],
}),
name: Flags.string({
char: 'n',
description: 'Display name (default: capitalized ID)',
}),
version: Flags.string({
char: 'v',
description: 'Version (default: 1.0.0)',
}),
description: Flags.string({
char: 'd',
description: 'Description',
}),
};
appType = '';
async createSwellConfig({ allowOverwrite = true, inputId = '', inputStorefrontApp, inputType, inputName, inputVersion, inputDescription, inputFrontend, inputIntegrationType, inputIntegrationId, inputYes, nestedPath = true, }) {
const confirmYes = inputYes;
let appId = toAppId(inputId || '');
let storefrontInstalledApps;
let storefrontAppId;
let installedStorefrontApp;
// check missed parameters
if (confirmYes) {
if (!appId) {
this.error(`Missing application id for non-interactive mode\n\nYou need to provide an app id value for non-interactive mode\n\nExample: ${this.commandExample} reviews --type admin -y`, {
exit: 1,
});
}
if (!inputType) {
this.error(`Missing required flag for non-interactive mode: --type\n\nValid types: admin, integration, storefront, theme\n\nExample: ${this.commandExample} reviews --type admin -y`, {
exit: 1,
});
}
if (!inputName) {
inputName = toAppName(inputId);
}
if (!inputVersion) {
inputVersion = '1.0.0';
}
}
const type = inputType ||
(await select({
choices: [
{ name: 'Admin', value: 'admin' },
{ name: 'Integration', value: 'integration' },
{ name: 'Storefront', value: 'storefront' },
{ name: 'Theme', value: 'theme' },
],
message: `What is the primary purpose of the app?`,
}));
const typeLabel = type === 'theme' ? 'theme' : 'app';
const typeLabelTitle = type === 'theme' ? 'Theme' : 'App';
const name = inputName ||
(await input({
default: inputId ? toAppName(inputId) : '',
message: `${typeLabelTitle} name`,
validate: (answer) => answer.length > 2,
}));
if (!appId) {
appId = await input({
default: toAppId(name),
message: `${typeLabelTitle} ID`,
validate: (answer) => answer.length > 2,
});
appId = toAppId(appId);
}
const version = inputVersion ||
(await input({
default: '1.0.0',
message: `${typeLabelTitle} version`,
}));
const description = inputDescription ||
(confirmYes
? ''
: await input({
default: 'Something special',
message: `Describe your ${typeLabel}`,
}));
let integrationType;
let extensions;
if (type === 'integration') {
if (confirmYes && !inputIntegrationType) {
this.error(`Missing required flag for non-interactive mode for integration app: --integration-type\n\nValid types: generic, payment, shipping, tax\n\nExample: ${this.commandExample} reviews --type integration --integration-type generic -y`, {
exit: 1,
});
}
integrationType =
inputIntegrationType ||
(await select({
choices: [
{ name: 'Generic', value: 'generic' },
{ name: 'Payment gateway', value: 'payment' },
{ name: 'Shipping service', value: 'shipping' },
{ name: 'Tax service', value: 'tax' },
],
message: `What type of integration?`,
}));
// Create extensions array based on integration type
switch (integrationType) {
case 'generic': {
// No extension needed for generic integrations
break;
}
case 'payment':
case 'shipping':
case 'tax': {
// Unified validation for all extension-based integrations
if (confirmYes && !inputIntegrationId) {
const validValues = integrationType === 'payment'
? "use 'card' for credit card integration or unique custom ID for other payment integrations"
: 'unique custom ID';
this.error(`Missing required flag for non-interactive mode for ${integrationType} integration: --integration-id\n\nValid values: ${validValues}\n\nExample: ${this.commandExample} my_app --type integration --integration-type ${integrationType} --integration-id ${integrationType === 'payment' ? 'card' : 'myid'} -y`, {
exit: 1,
});
}
// Interactive prompting
let integrationId = inputIntegrationId;
if (!integrationId) {
if (integrationType === 'payment') {
const methodChoice = await select({
choices: [
{ name: 'Credit card', value: 'card' },
{ name: 'Custom method', value: 'custom' },
],
message: `What type of payment method?`,
});
integrationId =
methodChoice === 'custom'
? await input({
message: `Enter a unique ID for your payment method`,
validate: (answer) => answer.length > 2,
})
: methodChoice; // 'card'
}
else {
// Shipping or tax
const serviceLabel = integrationType === 'shipping'
? 'shipping service'
: 'tax service';
integrationId = await input({
message: `Enter a unique ID for your ${serviceLabel}`,
validate: (answer) => answer.length > 2,
});
}
}
// Unified validation
if (integrationId.length < 3) {
this.error(`--integration-id must contain at least 3 characters\n\nValid value is a custom ID containing at least 3 characters\n\nExample: ${this.commandExample} my_app --type integration --integration-type ${integrationType} --integration-id ${integrationType === 'payment' ? 'card' : 'myid'} -y`, {
exit: 1,
});
}
// Create extension
extensions = [
{
id: integrationId,
type: integrationType,
},
];
break;
}
default: {
this.error(`Incorrect value for --integration-type\n\nValid types: generic, payment, shipping, tax\n\nExample: ${this.commandExample} reviews --type integration --integration-type generic -y`, {
exit: 1,
});
}
}
}
if (type === 'theme') {
storefrontInstalledApps = await this.getInstalledStorefrontApps();
if (storefrontInstalledApps === false) {
this.error('There are no installed storefront apps\n\nTo use the theme type, you first need to install the storefront app', {
exit: 1,
});
}
storefrontAppId = inputStorefrontApp || '';
if (!storefrontAppId) {
if (storefrontInstalledApps.length === 1) {
storefrontAppId = storefrontInstalledApps[0].id;
}
else {
if (confirmYes) {
this.error(`There are several installed storefront apps to choose from\n\nYou should select a specific storefront app in the non-interactive mode using --storefront-app appid\n\nExample: ${this.commandExample} reviews --type theme --storefront-app proxima -y`, {
exit: 1,
});
}
storefrontAppId = await select({
choices: storefrontInstalledApps.map(({ app, id }) => ({
name: app.name,
value: id,
})),
message: `Which storefront app is your theme for?`,
});
}
}
installedStorefrontApp = storefrontInstalledApps.find((installedApp) => inputStorefrontApp
? inputStorefrontApp === installedApp.app_private_id ||
inputStorefrontApp === installedApp.app_public_id ||
inputStorefrontApp === installedApp.app_id ||
inputStorefrontApp === toAppId(installedApp.app_private_id)
: installedApp.id === storefrontAppId);
if (!installedStorefrontApp) {
this.error(`Could not find an installed storefront app by id: ${storefrontAppId}\n\nThe selected storefront app is not among the installed storefront apps`, {
exit: 1,
});
}
}
// check inputFrontend value
if (!installedStorefrontApp &&
confirmYes &&
inputFrontend &&
inputFrontend !== 'none') {
const projectType = this.getProjectType(inputFrontend);
if (!projectType) {
// error should be thrown from getProjectType
return;
}
}
const swellConfigJson = {
description,
id: appId,
name,
type,
version,
...(type === 'theme'
? {
theme: {
storefront: {
app: toAppId(installedStorefrontApp.app_private_id),
},
},
}
: {
permissions: [],
}),
...(extensions && { extensions }),
};
const appPath = nestedPath
? path.join(process.cwd(), inputId || appId)
: process.cwd();
const swellConfigPath = path.join(appPath, 'swell.json');
if (swellConfigFileExists(swellConfigPath)) {
if (allowOverwrite) {
if (!confirmYes) {
const continueCreateApp = await confirm({
message: `An app already exists in the target path ${appPath}. Overwrite?`,
});
if (!continueCreateApp) {
return;
}
}
}
else {
this.error(`An app already exists in the target path ${appPath}.\n\nCannot overwrite existing app`, {
exit: 1,
});
}
}
this.log(`\nCreating ${this.appType === 'theme' ? 'theme' : 'app'} ${style.appConfigValue(name)} in ${appPath}/swell.json`);
this.log(`\n${JSON.stringify(swellConfigJson, null, 2)}\n`);
const confirmCreateApp = confirmYes
? true
: await confirm({
message: 'Ok to continue?',
});
if (!confirmCreateApp) {
return;
}
const config = newConfig(nestedPath ? inputId || appId : '');
config.clear();
config.set(swellConfigJson);
return { appId, config, installedStorefrontApp, swellConfigJson };
}
async run() {
const { args, flags } = await this.parse(CreateApp);
const { id } = args;
const { pkg, yes, name, type, version, description, frontend } = flags;
const confirmYes = Boolean(yes);
const createParams = await this.createSwellConfig({
inputId: id,
inputStorefrontApp: flags['storefront-app'],
inputType: type,
inputYes: confirmYes,
inputName: name,
inputVersion: version,
inputDescription: description,
inputFrontend: frontend,
inputIntegrationType: flags['integration-type'],
inputIntegrationId: flags['integration-id'],
});
if (!createParams) {
return;
}
const { appId, config, installedStorefrontApp } = createParams;
!confirmYes && this.log();
const spinner = ora();
spinner.start('Creating app...');
if (pkg !== 'none') {
await this.tryPackageSetup(appId, config, pkg);
}
await this.createAppConfigFolders(config);
spinner.succeed(`${style.appConfigValue(config.get('name'))} app created.\n`);
let createdFrontend;
let createdTheme;
if (installedStorefrontApp) {
createdTheme = await this.createThemeApp(config, flags, installedStorefrontApp);
}
else if (config.get('type') === 'storefront') {
createdFrontend = await this.createStorefrontApp(config, flags);
}
else {
createdFrontend = await this.createFrontendApp(config, flags);
}
this.log('\nNext steps:');
this.log(`Run ${style.command('swell app push')} to push configurations to your test store.`);
this.log(`Run ${style.command('swell app install')} to install the app in another store or environment.`);
if (createdFrontend) {
this.log(`Run ${style.command('swell app frontend dev')} to start a local dev server for your frontend app.`);
}
else if (createdTheme) {
this.log(`Run ${style.command('swell app theme dev')} to start a local dev server for your theme.`);
}
}
}