UNPKG

@swell/cli

Version:

Swell's command line interface/utility

458 lines (457 loc) 17.2 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 { 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; } }