UNPKG

@swell/cli

Version:

Swell's command line interface/utility

377 lines (376 loc) 13.9 kB
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; }