import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { mergeWith, once, set } from 'lodash-es'; import { z } from 'zod'; import { type EnvConfigType, parseEnvValue } from './env'; import { AppConfigByPath } from './types'; export type JSONSchema = { description?: string } & ( | { type?: undefined; oneOf?: JSONSchema[] } | { type: 'string' | 'number' | 'boolean'; enum?: string[]; } | { type: 'array'; items?: JSONSchema; } | { type: 'object'; properties?: Record; } ); type ConfigType = EnvConfigType | 'array' | 'object' | 'any'; export type ConfigDescriptor = { desc: string; type: ConfigType; validate: (value: T) => z.SafeParseReturnType; schema: JSONSchema; default: T; env?: [string, EnvConfigType]; link?: string; }; type ConfigDefineDescriptor = { desc: string; default: T; validate?: (value: T) => z.SafeParseReturnType; shape?: z.ZodType; env?: string | [string, EnvConfigType]; link?: string; schema?: JSONSchema; }; function typeFromShape(shape: z.ZodType): ConfigType { switch (shape.constructor) { case z.ZodString: return 'string'; case z.ZodNumber: return 'float'; case z.ZodBoolean: return 'boolean'; case z.ZodArray: return 'array'; case z.ZodObject: return 'object'; default: return 'any'; } } function shapeFromType(type: ConfigType): z.ZodType { switch (type) { case 'string': return z.string(); case 'float': return z.number(); case 'boolean': return z.boolean(); case 'integer': return z.number().int(); case 'array': return z.array(z.any()); case 'object': return z.object({}); default: return z.any(); } } function typeFromSchema(schema: JSONSchema): ConfigType { if ('type' in schema) { switch (schema.type) { case 'string': return 'string'; case 'number': return 'float'; case 'boolean': return 'boolean'; case 'array': return 'array'; case 'object': return 'object'; } } return 'any'; } function schemaFromType(type: ConfigType): JSONSchema['type'] { switch (type) { case 'any': return undefined; case 'float': case 'integer': return 'number'; default: return type; } } function typeFromDefault(defaultValue: T): ConfigType { if (Array.isArray(defaultValue)) { return 'array'; } switch (typeof defaultValue) { case 'string': return 'string'; case 'number': return 'float'; case 'boolean': return 'boolean'; case 'object': return 'object'; default: return 'any'; } } function standardizeDescriptor( desc: ConfigDefineDescriptor ): ConfigDescriptor { const env = desc.env ? Array.isArray(desc.env) ? desc.env : ([desc.env, 'string'] as [string, EnvConfigType]) : undefined; let type: ConfigType = 'any'; if (desc.default !== undefined && desc.default !== null) { type = typeFromDefault(desc.default); } else if (env) { type = env[1]; } else if (desc.shape) { type = typeFromShape(desc.shape); } else if (desc.schema) { type = typeFromSchema(desc.schema); } const shape = desc.shape ?? shapeFromType(type); return { desc: desc.desc, default: desc.default, type, validate: (value: T) => { return desc.validate ? desc.validate(value) : shape.safeParse(value); }, env, link: desc.link, schema: { type: schemaFromType(type), description: desc.desc, ...desc.schema, }, }; } type ModuleConfigDescriptors = { [K in keyof T]: ConfigDefineDescriptor; }; export const APP_CONFIG_DESCRIPTORS: Record< string, Record> > = {}; export const getDescriptors = once(() => { return Object.entries(APP_CONFIG_DESCRIPTORS).map( ([module, descriptors]) => ({ module, descriptors: Object.entries(descriptors).map(([key, descriptor]) => ({ key, descriptor, })), }) ); }); export function defineModuleConfig( module: T, defs: ModuleConfigDescriptors> ) { const descriptors: Record> = {}; Object.entries(defs).forEach(([key, desc]) => { descriptors[key] = standardizeDescriptor( desc as ConfigDefineDescriptor ); }); APP_CONFIG_DESCRIPTORS[module] = { ...APP_CONFIG_DESCRIPTORS[module], ...descriptors, }; } const CONFIG_JSON_PATHS = [ join(env.projectRoot, 'config.json'), `${homedir()}/.affine/config/config.json`, ]; function readConfigJSONOverrides(path: string) { const overrides: DeepPartial = {}; if (existsSync(path)) { try { const config = JSON.parse(readFileSync(path, 'utf-8')) as AppConfig; Object.entries(config).forEach(([key, value]) => { if (key === '$schema') { return; } Object.entries(value).forEach(([k, v]) => { set(overrides, `${key}.${k}`, v); }); }); } catch (e) { console.error('Invalid json config file', e); } } return overrides; } export function override(config: AppConfig, update: DeepPartial) { Object.keys(update).forEach(module => { const moduleDescriptors = APP_CONFIG_DESCRIPTORS[module]; const configKeys = new Set(Object.keys(moduleDescriptors)); const moduleConfig = config[module as keyof AppConfig]; const moduleOverrides = update[module as keyof AppConfig]; const merge = (left: any, right: any, path: string = '') => { // if we found the key in the config keys // we should use the override object instead of merge it with left if (configKeys.has(path)) { return right; } // go deeper return mergeWith(left, right, (left, right, key) => { return merge(left, right, path === '' ? key : `${path}.${key}`); }); }; config[module as keyof AppConfig] = merge(moduleConfig, moduleOverrides); }); } export function getDefaultConfig(): AppConfig { const config = {} as AppConfig; const envs = process.env; for (const [module, defs] of Object.entries(APP_CONFIG_DESCRIPTORS)) { const modulizedConfig = {}; for (const [key, desc] of Object.entries(defs)) { let defaultValue = desc.default; if (desc.env) { const [env, parser] = desc.env; const envValue = envs[env]; if (envValue) { defaultValue = parseEnvValue(envValue, parser); } } const { success, error } = desc.validate(defaultValue); if (!success) { throw new Error( error.issues .map(issue => { return `Invalid config for module [${module}] with key [${key}] Value: ${JSON.stringify(defaultValue)} Error: ${issue.message}`; }) .join('\n') ); } set(modulizedConfig, key, defaultValue); } // @ts-expect-error all keys are known config[module] = modulizedConfig; } CONFIG_JSON_PATHS.forEach(path => { const overrides = readConfigJSONOverrides(path); override(config, overrides); }); return config as AppConfigSchema; }