@swell/cli
Version:
Swell's command line interface/utility
377 lines (376 loc) • 13.9 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 { toAppId } from '../create/index.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["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.FILE,
];
// All available configs
const AllConfigTypes = [
'ASSET',
'CONTENT',
'FRONTEND',
'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 = {}));
// Legacy apps (pre-workspace) - Astro only (Proxima, Sunrise)
// Uses direct commands without workspace prefix
const LegacyFrontendProjectTypes = [
{
buildCommand: 'npx astro build',
devCommand: 'npx astro dev --port ${PORT}',
mainPackage: 'astro',
name: 'Astro',
slug: 'astro',
},
];
// Modern apps (workspace-based) - All frameworks
export const FrontendProjectTypes = [
{
buildCommand: 'npm exec --workspace=frontend -- astro build',
devCommand: 'npm exec --workspace=frontend -- astro dev --port ${PORT}',
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: 'npm exec --workspace=frontend -- ng build',
devCommand: 'npm exec --workspace=frontend -- ng serve --port ${PORT}',
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 exec --workspace=frontend -- wrangler dev --port ${PORT}',
installCommand: 'npm create cloudflare@latest -- frontend --framework=hono --deploy=false --git=false',
mainPackage: 'hono',
name: 'Hono',
slug: 'hono',
},
{
buildCommand: 'npm exec --workspace=frontend -- nuxt build',
devCommand: 'npm exec --workspace=frontend -- nuxt dev --port ${PORT}',
installCommand: 'npm create cloudflare@latest -- frontend --framework=nuxt --deploy=false --git=false -- --no-modules -f',
mainPackage: 'nuxt',
name: 'Nuxt',
slug: 'nuxt',
},
{
buildCommand: 'npm exec --workspace=frontend -- opennextjs-cloudflare build',
// devCommand: 'npm exec --workspace=frontend -- opennextjs-cloudflare build && npm exec --workspace=frontend -- opennextjs-cloudflare preview --port=${PORT}',
devCommand: 'npm exec --workspace=frontend -- next dev --turbopack --port ${PORT}',
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',
},
];
export function getFrontendProjectSlugs(withNone = true, withLegacy = true) {
const types = withLegacy
? FrontendProjectTypes
: FrontendProjectTypes.filter((projectType) => projectType.installCommand);
const slugs = types.map((projectType) => projectType.slug);
if (withNone) {
slugs.push('none');
}
return slugs;
}
export function getFrontendProjectValidValues(withNone = true, withLegacy = true) {
return getFrontendProjectSlugs(withNone, withLegacy).join(', ');
}
export function getAppSlugId(app) {
return toAppId(app.private_id) || app.public_id || app.id;
}
function hasWorkspaceStructure(appPath) {
// Check if root package.json has workspaces field including 'frontend'
const rootPkgPath = path.join(appPath, 'package.json');
if (!filePathExists(rootPkgPath)) {
return false;
}
try {
const content = fs.readFileSync(rootPkgPath, 'utf8');
const pkg = JSON.parse(content);
return Array.isArray(pkg.workspaces) && pkg.workspaces.includes('frontend');
}
catch {
return false;
}
}
export function getFrontendProjectType(appPath) {
// Detect workspace structure and select appropriate config
const hasWorkspace = hasWorkspaceStructure(appPath);
const projectTypes = hasWorkspace
? FrontendProjectTypes
: LegacyFrontendProjectTypes;
// Try frontend/package.json first (new workspace structure), then root package.json (old structure)
const pkgPaths = [
path.join(appPath, 'frontend', 'package.json'),
path.join(appPath, 'package.json'),
];
for (const pkgPath of pkgPaths) {
if (!filePathExists(pkgPath)) {
continue;
}
try {
const content = fs.readFileSync(pkgPath, 'utf8');
const pkg = JSON.parse(content);
for (const projectType of projectTypes) {
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
detectedType.buildCommand ||=
// Check if package.json has a "build" script, if not set to empty string (no build needed)
pkg.scripts?.build ? 'npm run build' : '';
return detectedType;
}
}
}
catch {
// Ignore JSON parse errors, try next path
continue;
}
}
return undefined;
}
export function getConfigTypeFromPath(path) {
for (const type in 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 function appAssetImage(appPath, fileName) {
const assetsDirPath = path.join(appPath, AllConfigPaths['ASSET']);
if (!filePathExists(assetsDirPath)) {
return;
}
for (const configFile of fs.readdirSync(assetsDirPath)) {
if (path.parse(configFile).name === fileName) {
return path.join(assetsDirPath, configFile);
}
}
}
export function appConfigFromFile(filePath, configType, appPath) {
const { name } = path.parse(filePath);
const config = 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 } = path.parse(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 === 'model' && config.values.extends);
const modelsWithoutExtends = configs.filter((config) => config.type === 'model' && !config.values.extends);
const contentTypesWithCollection = configs.filter((config) => config.type === 'content' && config.values.collection);
const contentTypesWithoutCollection = configs.filter((config) => config.type === '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
? 1
: PUSH_CONCURRENCY;
batches.push({
configs: configsByType,
type: configType,
label,
concurrency,
});
}
// Add base files if config types are not specified
return batches;
}