import { parsePackageJson, parsePackageJsonAsync } from './app' import * as z from 'zod' import { path } from './path' import { AsyncFileSystem, FileSystem } from './fs' import { Logger } from './logger' import { Validator as JsonSchemaValidator, ValidationError } from 'jsonschema' import readJson, { readJsonAsync } from './util/read-json' const NOW_CONFIG_SCHEMA = require('../now.config.schema.json') export const NowConfigSchema = z .object({ scope: z.string(), scopeId: z.string(), name: z.string().optional(), serverModulesDir: z.string().default(path.join('src', 'server')), modulePaths: z.record(z.string(), z.string()).default({}), metadataDir: z.string().default('metadata'), fluentDir: z.string().default(path.join('src', 'fluent')), generatedDir: z.string().default(`generated`), appOutputDir: z.string().default(path.join('dist', 'app')), packOutputDir: z.string().default('target'), npmUpdateCheck: z.literal(false).or(z.number()).default(10), dependencies: z .object({ applications: z .record( z.string(), z.object({ tables: z.array(z.string()).or(z.literal('*')).optional(), }) ) .optional(), }) .default({}), ignoreTransformTableList: z.array(z.string()).default([]), tsconfigPath: z.string().optional(), moduleDir: z.string().optional(), // Deprecated compileOutputDir: z.string().optional(), // Deprecated sourceDir: z.string().default('src'), // Deprecated transpiledSourceDir: z.string().optional(), // Deprecated }) .refine((data) => { if (!data.generatedDir.startsWith(data.fluentDir)) { data.generatedDir = path.join(data.fluentDir, data.generatedDir) } const sdkDirectories: Array< keyof Omit< typeof data, | 'scope' | 'scopeId' | 'npmUpdateCheck' | 'dependencies' | 'ignoreTransformTableList' | 'modulePaths' | 'tsconfigPath' > > = [ 'serverModulesDir', 'metadataDir', 'fluentDir', 'generatedDir', 'appOutputDir', 'sourceDir', 'transpiledSourceDir', 'packOutputDir', 'moduleDir', 'compileOutputDir', ] sdkDirectories.forEach((dir) => { if (data[dir]) { data[dir] = path.normalize(data[dir]) } }) return data }) export type NowConfig = z.output export type NowConfigOptions = z.input export type NowConfigDependenciesApplicationTables = string[] | '*' export const NowConfigFileName = 'now.config.json' export function getNowConfigFileValidationResults(json: unknown) { const validator = new JsonSchemaValidator() const result = validator.validate(json, NOW_CONFIG_SCHEMA) return result } function getParsedNowConfigData(config: Record): NowConfig { const parsedNowConfig = NowConfigSchema.safeParse(config) if (!parsedNowConfig.success) { let errMsg = `Invalid '${NowConfigFileName}' file:\n` parsedNowConfig.error.errors.map((err) => (errMsg += `${err.path}: ${err.message}\n`)) throw new Error(errMsg) } return parsedNowConfig.data } async function validateNowConfigJsonFileAsync(filePath: string, fs: AsyncFileSystem) { const json: unknown = await readJsonAsync(filePath, '', fs) if (!isObjectWithStringKeys(json)) { throw new Error(`Config file '${NowConfigFileName}' is empty or malformed`) } const result = getNowConfigFileValidationResults(json) if (result.errors.length > 0) { throw new Error(`${NowConfigFileName} - error: ${formatValidationErrorMessage(result.errors)}`) } return json } function validateNowConfigJsonFile(filePath: string, fs: FileSystem) { const json: unknown = readJson(filePath, '', fs) if (!isObjectWithStringKeys(json)) { throw new Error(`Config file '${NowConfigFileName}' is empty or malformed`) } const result = getNowConfigFileValidationResults(json) if (result.errors.length > 0) { throw new Error(`${NowConfigFileName} - error: ${formatValidationErrorMessage(result.errors)}`) } return json } function formatValidationErrorMessage(errors: ValidationError[]) { return errors .map((e) => { if (!e.path.length) { if (e.message === 'is of prohibited type [object Object]') { return e.argument.message } return e.message } return `"${e.path.join('.')}" ${e.message}` }) .join(', ') } async function readNowConfigJsonFileAsync(filePath: string, fs: AsyncFileSystem) { const exists = await fs.exists(filePath) if (exists) { return validateNowConfigJsonFileAsync(filePath, fs) } return null } function readNowConfigJsonFile(filePath: string, fs: FileSystem) { if (FileSystem.existsSync(fs, filePath)) { return validateNowConfigJsonFile(filePath, fs) } return null } function isObjectWithStringKeys(obj: unknown): obj is Record { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return false } for (const key in obj) { if (typeof key !== 'string') { return false } } return true } export const TABLES_DECLARATIONS_FILE = 'tables.ts' export const getSchemaGeneratedPaths = (workingDir: string, config: NowConfig) => { const schemaDir = path.join(workingDir, config.generatedDir, 'schema') return { dir: schemaDir, declarationsFile: path.join(schemaDir, TABLES_DECLARATIONS_FILE), } } type DeprecatedField = { replacedBy?: string transfer?: boolean } const DEPRECATED_FIELDS: Record = { moduleDir: { replacedBy: 'serverModulesDir', transfer: true }, compileOutputDir: { replacedBy: 'appOutputDir', transfer: true }, entitiesDir: { replacedBy: 'fluentDir', transfer: true }, transpiledSourceDir: { replacedBy: 'modulePaths' }, sourceDir: {}, } function replaceDeprecatedFields(config: Record, logger: Logger) { for (const [name, { replacedBy, transfer }] of Object.entries(DEPRECATED_FIELDS)) { if (name in config) { logger.warn( `'${name}' is deprecated${replacedBy ? `, please replace with '${replacedBy}'` : ' and can be removed'}` ) if (transfer && replacedBy) { config[replacedBy] = config[name] } } } } function checkRemovedProperties(config: Record): void { for (const [property, message] of Object.entries(ERROR_ON_DEPRECATED_PROPS)) { if (property in config) { throw new Error(message) } } } export const ERROR_ON_DEPRECATED_PROPS = { transpiledSourceDir: `NowConfig: 'modulePaths' replaces 'transpiledSourceDir', please remove 'transpiledSourceDir' and map original source files to transpiled output using 'modulePaths' Ex: "modulePaths": { "src/server/*.js": "dist/modules/*.js", "src/server/*.ts": "dist/modules/*.js", } If you are not intending to do custom module transpilation, please remove 'transpiledSourceDir' and ensure 'serverModulesDir' is set to the directory containing modules `, } /** * * @param config Configuration Records read from now.config.json * @param rootDir Root project directory used to parse tsconfig from tsconfigPath * @param fs FileSystem * @param logger * @returns zod parsed NowConfig */ export function parseNowConfig(config: Record, logger: Logger): NowConfig { checkRemovedProperties(config) replaceDeprecatedFields(config, logger) return getParsedNowConfigData(config) } export const parseNowConfigFromPath = (path: string, fs: FileSystem, logger: Logger): NowConfig => { const nowConfig = readNowConfigJsonFile(path, fs) if (!nowConfig) { throw new Error(`Failed to parse config file at path ${path}`) } return parseNowConfig(nowConfig, logger) } export const parseNowConfigFromDirectoryAsync = async ( dir: string, fs: AsyncFileSystem, logger: Logger ): Promise => { const nowConfigJSON = await readNowConfigJsonFileAsync(path.join(dir, NowConfigFileName), fs) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return parseNowConfig(nowConfigJSON ?? (await parsePackageJsonAsync(dir, fs))['now'] ?? {}, dir, fs, logger) } export const parseNowConfigFromDirectory = (dir: string, fs: FileSystem, logger: Logger): NowConfig => { return parseNowConfig( readNowConfigJsonFile(path.join(dir, NowConfigFileName), fs) ?? parsePackageJson(dir, fs)['now'] ?? {}, logger ) } export const hasNowConfig = (dir: string, fs: FileSystem): boolean => { if (FileSystem.existsSync(fs, path.join(dir, NowConfigFileName))) { return true } else if (FileSystem.existsSync(fs, path.join(dir, 'package.json')) && parsePackageJson(dir, fs)['now']) { return true } return false }