@swell/cli
Version:
Swell's command line interface/utility
458 lines (457 loc) • 17.2 kB
JavaScript
import { createHash as createBlake3Hash } from 'blake3-wasm';
import { pluralize, titleize } from 'inflection';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { detectPackageManager, transformCommand } from '../package-manager.js';
import { AppConfig } from './app-config.js';
export { AppConfig, FunctionProcessingError, IgnoringFileError, } from './app-config.js';
export { allBaseFilesInDir, allConfigDirsPaths, allConfigFilesInDir, allConfigFilesPaths, allConfigFilesPathsByType, getAllConfigPaths, globAllFilesByPath, isPathDirectory, } from './paths.js';
export const PUSH_CONCURRENCY = 3;
export var SwellJsonFields;
(function (SwellJsonFields) {
SwellJsonFields["DESCRIPTION"] = "description";
SwellJsonFields["EXTENSIONS"] = "extensions";
SwellJsonFields["ID"] = "id";
SwellJsonFields["KIND"] = "kind";
SwellJsonFields["NAME"] = "name";
SwellJsonFields["PERMISSIONS"] = "permissions";
SwellJsonFields["STOREFRONT"] = "storefront";
SwellJsonFields["THEME"] = "theme";
SwellJsonFields["TYPE"] = "type";
SwellJsonFields["VERSION"] = "version";
})(SwellJsonFields || (SwellJsonFields = {}));
export var ConfigType;
(function (ConfigType) {
ConfigType["ASSET"] = "asset";
ConfigType["CONTENT"] = "content";
ConfigType["FILE"] = "file";
ConfigType["FRONTEND"] = "frontend";
ConfigType["COMPONENT"] = "component";
ConfigType["FUNCTION"] = "function";
ConfigType["MODEL"] = "model";
ConfigType["NOTIFICATION"] = "notification";
ConfigType["SETTING"] = "setting";
ConfigType["THEME"] = "theme";
ConfigType["WEBHOOK"] = "webhook";
})(ConfigType || (ConfigType = {}));
// Models and content should come first in order
const ConfigTypeBatchOrder = [
ConfigType.MODEL,
ConfigType.CONTENT,
ConfigType.ASSET,
ConfigType.FUNCTION,
ConfigType.NOTIFICATION,
ConfigType.SETTING,
ConfigType.WEBHOOK,
ConfigType.THEME,
ConfigType.FRONTEND,
ConfigType.COMPONENT,
ConfigType.FILE,
];
// All available configs
const AllConfigTypes = [
'ASSET',
'CONTENT',
'FRONTEND',
'COMPONENT',
'FUNCTION',
'MODEL',
'NOTIFICATION',
'SETTING',
'THEME',
'WEBHOOK',
];
// Configs available for admin/integration app types
const StandardConfigTypes = [
'MODEL',
'CONTENT',
'ASSET',
'FUNCTION',
'FRONTEND',
'NOTIFICATION',
'SETTING',
'WEBHOOK',
];
// Configs available for storefront app types
export const StorefrontConfigTypes = [
'FRONTEND',
'ASSET',
'SETTING',
];
// Configs available for theme types
export const ThemeConfigTypes = ['THEME', 'ASSET'];
const getConfigPaths = (types) =>
// eslint-disable-next-line unicorn/no-array-reduce
Object.keys(ConfigType).reduce((acc, type) => {
if (types.includes(type)) {
// a bit of a hack to make the path singular for content configs
// need to refactor this to a function if we add more exceptions
acc[type] =
type === 'CONTENT' || type === 'FRONTEND' || type === 'THEME'
? type.toLowerCase()
: pluralize(type.toLowerCase());
}
return acc;
}, {});
export const AllConfigPaths = {
...getConfigPaths(AllConfigTypes),
};
export const StandardConfigPaths = {
...getConfigPaths(StandardConfigTypes),
};
export const StorefrontConfigPaths = {
...getConfigPaths(StorefrontConfigTypes),
};
export const ThemeConfigPaths = {
...getConfigPaths(ThemeConfigTypes),
};
export var ConfigInputFields;
(function (ConfigInputFields) {
ConfigInputFields["FILE"] = "file";
ConfigInputFields["NAME"] = "name";
ConfigInputFields["TYPE"] = "type";
ConfigInputFields["VALUES"] = "values";
ConfigInputFields["VERSION"] = "version";
})(ConfigInputFields || (ConfigInputFields = {}));
// Slug for unrecognized frontend frameworks (manual dev server mode)
export const CUSTOM_FRAMEWORK_SLUG = 'custom';
// Order matters: getFrontendProjectType is first-match-wins on mainPackage.
// react-storefront precedes hono (chassis ships hono as a peer); base react
// is last (any react-using framework above must be caught first).
// Pinned by test/lib/frontend-detection.test.ts.
export const FrontendProjectTypes = [
{
// Template ref is `main`; pin to an immutable tag before announcement.
// `--no-agents` pre-answers C3's agents-helper prompt so install
// doesn't hang behind our spinner.
appTypes: ['storefront'],
devCommand: 'npm run dev -- --port ${PORT}',
displayOrder: 7,
installCommand: 'npm create cloudflare@latest -- frontend --template=swellstores/storefront-react-ai-template#main --deploy=false --git=false --no-agents',
mainPackage: '@swell/storefront-app-sdk-react',
name: 'Swell React Storefront',
slug: 'react-storefront',
},
{
buildCommand: 'npx astro build',
devCommand: 'npx astro dev --port ${PORT}',
displayOrder: 2,
installCommand: 'npm create cloudflare@latest -- frontend --framework=astro --deploy=false --git=false -- --no-git --yes --skip-houston --typescript strict',
mainPackage: 'astro',
name: 'Astro',
slug: 'astro',
},
{
buildCommand: 'npx ng build',
devCommand: 'npx ng serve --port ${PORT}',
displayOrder: 6,
installCommand: 'npm create cloudflare@latest -- frontend --framework=angular --deploy=false --git=false -- --style=sass --zoneless --ai-config=none',
mainPackage: '@angular/core',
name: 'Angular',
slug: 'angular',
},
{
devCommand: 'npm run dev -- --port ${PORT}',
displayOrder: 5,
installCommand: 'npm create cloudflare@latest -- frontend --framework=hono --deploy=false --git=false',
mainPackage: 'hono',
name: 'Hono',
slug: 'hono',
},
{
buildCommand: 'npx nuxt build',
devCommand: 'npx nuxt dev --port ${PORT}',
displayOrder: 3,
installCommand: 'npm create cloudflare@latest -- frontend --framework=nuxt --deploy=false --git=false -- --no-modules -f',
mainPackage: 'nuxt',
name: 'Nuxt',
slug: 'nuxt',
},
{
buildCommand: 'npx opennextjs-cloudflare build',
devCommand: 'npx next dev --turbopack --port ${PORT}',
displayOrder: 1,
installCommand: 'npm create cloudflare@latest -- frontend --framework=next --deploy=false --git=false -- --typescript --use-npm --src-dir --app --eslint --import-alias "@/*" --tailwind --turbopack',
mainPackage: 'next',
name: 'Next.js',
slug: 'nextjs',
},
{
// `--variant=react-ts` pre-answers C3's variant prompt (TS Vite +
// plugin-react, not SWC) so install doesn't hang behind our spinner.
devCommand: 'npm run dev -- --port ${PORT}',
displayOrder: 4,
installCommand: 'npm create cloudflare@latest -- frontend --framework=react --deploy=false --git=false --variant=react-ts',
mainPackage: 'react',
name: 'React',
slug: 'react',
},
];
export function getFrontendProjectSlugs(withNone = true, withLegacy = true) {
const types = withLegacy
? FrontendProjectTypes
: FrontendProjectTypes.filter((projectType) => projectType.installCommand);
// Sort so user-facing surfaces match the prompt; detection order is
// independent.
const slugs = [...types]
.sort((a, b) => (a.displayOrder ?? Number.POSITIVE_INFINITY) -
(b.displayOrder ?? Number.POSITIVE_INFINITY))
.map((projectType) => projectType.slug);
if (withNone) {
slugs.push('none');
}
return slugs;
}
export function getFrontendProjectValidValues(withNone = true, withLegacy = true) {
return getFrontendProjectSlugs(withNone, withLegacy).join(', ');
}
export async function getFrontendProjectType(appPath) {
// Try frontend/package.json first (workspace structure), then root package.json (legacy)
const pkgPaths = [
path.join(appPath, 'frontend', 'package.json'),
path.join(appPath, 'package.json'),
];
for (const pkgPath of pkgPaths) {
// eslint-disable-next-line no-await-in-loop
if (!(await filePathExistsAsync(pkgPath))) {
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
const content = await fs.promises.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(content);
for (const projectType of FrontendProjectTypes) {
if (pkg.dependencies?.[projectType.mainPackage] ||
pkg.devDependencies?.[projectType.mainPackage]) {
// Create a copy to avoid mutating the original
const detectedType = { ...projectType };
// If buildCommand not explicitly set in framework definition, detect it
// Check if package.json has a "build" script, if not set to empty string (no build needed)
detectedType.buildCommand ||= pkg.scripts?.build
? 'npm run build'
: '';
return detectedType;
}
}
// Fallback: if package.json has a dev script, treat as unrecognized framework
if (pkg.scripts?.dev) {
return {
buildCommand: pkg.scripts?.build ? 'npm run build' : '',
devCommand: '',
mainPackage: '',
name: 'Custom',
slug: CUSTOM_FRAMEWORK_SLUG,
};
}
}
catch (error) {
// Ignore JSON parse errors, try next path
if (error instanceof SyntaxError) {
continue;
}
// Ignore file not found errors, try next path
if (error.code === 'ENOENT') {
continue;
}
throw error;
}
}
return undefined;
}
/**
* Get project commands transformed for the detected package manager.
* Detects the package manager from lock files in appPath and transforms
* npm-style commands to the equivalent for that package manager.
*/
export async function getProjectCommands(appPath, projectType) {
const pm = await detectPackageManager(appPath);
return {
buildCommand: projectType.buildCommand
? transformCommand(projectType.buildCommand, pm)
: undefined,
devCommand: transformCommand(projectType.devCommand, pm),
};
}
/**
* Get a suggested dev command for an unrecognized frontend project,
* translated for the detected package manager.
*/
export async function getManualDevHint(appPath, port) {
const pm = await detectPackageManager(appPath);
return transformCommand(`npm run dev -- --port ${port}`, pm);
}
export function getConfigTypeFromPath(path) {
for (const type of Object.keys(AllConfigPaths)) {
if (AllConfigPaths[type] === path) {
const configType = ConfigType[type];
return configType;
}
}
return undefined;
}
export function getConfigTypeKeyFromValue(value) {
return Object.keys(ConfigType).find((key) => ConfigType[key] === value);
}
export function filePathExists(filePath) {
try {
// eslint-disable-next-line no-bitwise
fs.accessSync(filePath, fs.constants.R_OK | fs.constants.W_OK);
return true;
}
catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
return false;
}
}
export async function filePathExistsAsync(filePath) {
try {
// eslint-disable-next-line no-bitwise
await fs.promises.access(filePath, fs.constants.R_OK | fs.constants.W_OK);
return true;
}
catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
return false;
}
}
export async function writeFile(file, data) {
// Convert from base64 if needed
let contents;
try {
const decoded = Buffer.from(data, 'base64');
const isBase64 = decoded.toString('base64') === data;
contents = isBase64 ? decoded : data;
}
catch {
contents = data;
}
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(file, contents);
}
export function deleteFile(file) {
return fs.promises.unlink(file);
}
export async function writeJsonFile(file, data = {}) {
return writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
}
// Hash method adapted from wrangler-sdk
export function hashFile(filePath, fileContents, relativePath) {
const contents = fileContents ?? fs.readFileSync(filePath);
// Include relative file path because we typically cache by hash including file metadata
// This allows de-duplication of cache entries for the same file contents
return hashString(contents, relativePath || '');
}
export function hashString(...contents) {
const hash = createBlake3Hash();
for (const content of contents) {
hash.update(content);
}
return hash.digest('hex', { length: 16 });
}
export async function appAssetImage(appPath, fileName) {
const assetsDirPath = path.join(appPath, AllConfigPaths['ASSET']);
if (!(await filePathExistsAsync(assetsDirPath))) {
return;
}
for (const configFile of await fs.promises.readdir(assetsDirPath)) {
if (path.parse(configFile).name === fileName) {
return path.join(assetsDirPath, configFile);
}
}
}
function getNameFromFilePath(filePath) {
return path.parse(filePath).name.replaceAll('_', '-');
}
export async function appConfigFromFile(filePath, configType, appPath) {
const name = getNameFromFilePath(filePath);
// Notification basenames must not contain dots: the inspect identifier
// grammar (app.<slug>.<model>.<name>) splits on '.', so a dotted name
// would silently misparse on lookup.
if (configType === ConfigType.NOTIFICATION && name.includes('.')) {
throw new Error(`Notification file '${filePath}': basename cannot contain '.'. Rename the file.`);
}
const config = await AppConfig.create({
name,
filePath,
appPath,
type: configType,
file_path: filePath,
});
// Do not allow files larger than 10MB
if (config.mbSize && config.mbSize > 10) {
throw new Error(`App file size too large: ${config.filePath} (${config.mbSize} MB)`);
}
return config;
}
export function findAppConfig(app, filePath, configType) {
const name = getNameFromFilePath(filePath);
const config = app?.configs?.find((c) => c && c.type === configType && c.name === name);
return config;
}
/**
* Get files to push in batches prioritized by dependencies
*
* @param {AppConfig[]} configs - List of app configs
* @returns {BatchAppFiles[]} Batches of files to push
*/
export function batchAppFilesByType(configs) {
const modelsWithExtends = configs.filter((config) => config.type === ConfigType.MODEL && config.values.extends);
const modelsWithoutExtends = configs.filter((config) => config.type === ConfigType.MODEL && !config.values.extends);
const contentTypesWithCollection = configs.filter((config) => config.type === ConfigType.CONTENT && config.values.collection);
const contentTypesWithoutCollection = configs.filter((config) => config.type === ConfigType.CONTENT && !config.values.collection);
const prioritized = new Set([
...modelsWithExtends,
...modelsWithoutExtends,
...contentTypesWithCollection,
...contentTypesWithoutCollection,
]);
const sorted = [
...prioritized.values(),
...configs.filter((config) => !prioritized.has(config)),
];
const batches = [];
for (const configType of ConfigTypeBatchOrder) {
const configsByType = sorted.filter((config) => config.type === configType);
const label = configType === ConfigType.FILE ? 'App files' : titleize(configType);
const concurrency = configType === ConfigType.NOTIFICATION ||
configType === ConfigType.ASSET ||
configType === ConfigType.SETTING
? 1
: PUSH_CONCURRENCY;
batches.push({
configs: configsByType,
type: configType,
label,
concurrency,
});
}
// Add base files if config types are not specified
return batches;
}
export function hasAppContext() {
return filePathExistsAsync('swell.json');
}
export async function getCurrentAppSlugId() {
if (!(await hasAppContext())) {
return;
}
try {
const appConfig = JSON.parse(await fs.promises.readFile('swell.json', 'utf8'));
return appConfig.id;
}
catch (error) {
// Ignore JSON parse error
if (error instanceof SyntaxError) {
return;
}
// Ignore error when swell.json does not exists
if (error.code === 'ENOENT') {
return;
}
throw error;
}
}