import * as plugins from './npmextra.plugins.js'; import { KeyValueStore } from './npmextra.classes.keyvaluestore.js'; // ============================================================================ // Singleton Qenv Provider // ============================================================================ let sharedQenv: plugins.qenv.Qenv | undefined; function getQenv(): plugins.qenv.Qenv { if (!sharedQenv) { sharedQenv = new plugins.qenv.Qenv( process.cwd(), plugins.path.join(process.cwd(), '.nogit') ); } return sharedQenv; } // ============================================================================ // Type Converters - Centralized conversion logic // ============================================================================ function toBoolean(value: unknown): boolean { if (typeof value === 'boolean') return value; if (value == null) return false; const s = String(value).toLowerCase(); return s === 'true'; } function toJson(value: unknown): T | undefined { if (value == null) return undefined; if (typeof value === 'string') { try { return JSON.parse(value); } catch { return undefined; } } return value as T; } function fromBase64(value: unknown): string | undefined { if (value == null) return undefined; try { return Buffer.from(String(value), 'base64').toString('utf8'); } catch { return String(value); } } function toNumber(value: unknown): number | undefined { if (value == null) return undefined; const num = Number(value); return Number.isNaN(num) ? undefined : num; } function toString(value: unknown): string | undefined { if (value == null) return undefined; return String(value); } // ============================================================================ // Declarative Pipeline Architecture // ============================================================================ type Transform = 'boolean' | 'json' | 'base64' | 'number'; type MappingSpec = { source: | { type: 'env'; key: string } | { type: 'hard'; value: string }; transforms: Transform[]; }; // Transform registry for extensibility const transformRegistry: Record unknown> = { boolean: toBoolean, json: toJson, base64: fromBase64, number: toNumber, }; /** * Parse a mapping string into a declarative spec */ function parseMappingSpec(input: string): MappingSpec { const transforms: Transform[] = []; let remaining = input; // Check for hardcoded prefixes with type conversion if (remaining.startsWith('hard_boolean:')) { return { source: { type: 'hard', value: remaining.slice(13) }, transforms: ['boolean'] }; } if (remaining.startsWith('hard_json:')) { return { source: { type: 'hard', value: remaining.slice(10) }, transforms: ['json'] }; } if (remaining.startsWith('hard_base64:')) { return { source: { type: 'hard', value: remaining.slice(12) }, transforms: ['base64'] }; } // Check for generic hard: prefix if (remaining.startsWith('hard:')) { remaining = remaining.slice(5); // Check for legacy suffixes on hardcoded values if (remaining.endsWith('_JSON')) { transforms.push('json'); remaining = remaining.slice(0, -5); } else if (remaining.endsWith('_BASE64')) { transforms.push('base64'); remaining = remaining.slice(0, -7); } return { source: { type: 'hard', value: remaining }, transforms }; } // Check for env var prefixes if (remaining.startsWith('boolean:')) { transforms.push('boolean'); remaining = remaining.slice(8); } else if (remaining.startsWith('json:')) { transforms.push('json'); remaining = remaining.slice(5); } else if (remaining.startsWith('base64:')) { transforms.push('base64'); remaining = remaining.slice(7); } // Check for legacy suffixes on env vars if (remaining.endsWith('_JSON')) { transforms.push('json'); remaining = remaining.slice(0, -5); } else if (remaining.endsWith('_BASE64')) { transforms.push('base64'); remaining = remaining.slice(0, -7); } return { source: { type: 'env', key: remaining }, transforms }; } /** * Resolve the source value (env var or hardcoded) */ async function resolveSource(source: MappingSpec['source']): Promise { if (source.type === 'hard') { return source.value; } // source.type === 'env' return await getQenv().getEnvVarOnDemand(source.key); } /** * Apply transformations in sequence */ function applyTransforms(value: unknown, transforms: Transform[]): unknown { return transforms.reduce((acc, transform) => { const fn = transformRegistry[transform]; return fn ? fn(acc) : acc; }, value); } /** * Process a mapping value through the complete pipeline */ async function processMappingValue(mappingString: string): Promise { const spec = parseMappingSpec(mappingString); const rawValue = await resolveSource(spec.source); if (rawValue === undefined || rawValue === null) { return undefined; } return applyTransforms(rawValue, spec.transforms); } /** * Recursively evaluate mapping values (strings or nested objects) */ async function evaluateMappingValue(mappingValue: any): Promise { if (typeof mappingValue === 'string') { return processMappingValue(mappingValue); } if (mappingValue && typeof mappingValue === 'object' && !Array.isArray(mappingValue)) { const result: any = {}; for (const [key, value] of Object.entries(mappingValue)) { result[key] = await evaluateMappingValue(value); } return result; } return undefined; } // ============================================================================ // AppData Interface and Class // ============================================================================ export interface IAppDataOptions { dirPath?: string; requiredKeys?: Array; /** * Whether keys should be persisted on disk or not */ ephemeral?: boolean; /** * @deprecated Use 'ephemeral' instead */ ephermal?: boolean; /** * kvStoreKey: 'MY_ENV_VAR' */ envMapping?: plugins.tsclass.typeFest.PartialDeep; overwriteObject?: plugins.tsclass.typeFest.PartialDeep; } export class AppData { /** * creates appdata. If no pathArg is given, data will be stored here: * ${PWD}/.nogit/appdata * @param pathArg * @returns */ public static async createAndInit( optionsArg: IAppDataOptions = {}, ): Promise> { const appData = new AppData(optionsArg); await appData.readyDeferred.promise; return appData; } /** * Static helper to get an environment variable as a boolean * @param envVarName The name of the environment variable * @returns boolean value (true if env var is "true", false otherwise) */ public static async valueAsBoolean(envVarName: string): Promise { const value = await getQenv().getEnvVarOnDemand(envVarName); return toBoolean(value); } /** * Static helper to get an environment variable as parsed JSON * @param envVarName The name of the environment variable * @returns Parsed JSON object/array */ public static async valueAsJson(envVarName: string): Promise { const value = await getQenv().getEnvVarOnDemand(envVarName); return toJson(value); } /** * Static helper to get an environment variable as base64 decoded string * @param envVarName The name of the environment variable * @returns Decoded string */ public static async valueAsBase64(envVarName: string): Promise { const value = await getQenv().getEnvVarOnDemand(envVarName); return fromBase64(value); } /** * Static helper to get an environment variable as a string * @param envVarName The name of the environment variable * @returns String value */ public static async valueAsString(envVarName: string): Promise { const value = await getQenv().getEnvVarOnDemand(envVarName); return toString(value); } /** * Static helper to get an environment variable as a number * @param envVarName The name of the environment variable * @returns Number value */ public static async valueAsNumber(envVarName: string): Promise { const value = await getQenv().getEnvVarOnDemand(envVarName); return toNumber(value); } // instance public readyDeferred = plugins.smartpromise.defer(); public options: IAppDataOptions; private kvStore: KeyValueStore; constructor(optionsArg: IAppDataOptions = {}) { this.options = optionsArg; this.init(); } /** * inits app data */ private async init() { console.log('🚀 Initializing AppData...'); // Handle backward compatibility for typo const isEphemeral = this.options.ephemeral ?? this.options.ephermal ?? false; if (this.options.ephermal && !this.options.ephemeral) { console.warn('⚠️ Option "ephermal" is deprecated, use "ephemeral" instead.'); } if (this.options.dirPath) { console.log(` 📁 Using custom directory: ${this.options.dirPath}`); } else if (isEphemeral) { console.log(` 💨 Using ephemeral storage (in-memory only)`); } else { const appDataDir = '/app/data'; const dataDir = '/data'; const nogitAppData = '.nogit/appdata'; const appDataExists = plugins.smartfile.fs.isDirectory(appDataDir); const dataExists = plugins.smartfile.fs.isDirectory(dataDir); if (appDataExists) { this.options.dirPath = appDataDir; console.log(` 📁 Auto-selected container directory: ${appDataDir}`); } else if (dataExists) { this.options.dirPath = dataDir; console.log(` 📁 Auto-selected data directory: ${dataDir}`); } else { await plugins.smartfile.fs.ensureDir(nogitAppData); this.options.dirPath = nogitAppData; console.log(` 📁 Auto-selected local directory: ${nogitAppData}`); } } this.kvStore = new KeyValueStore({ typeArg: isEphemeral ? 'ephemeral' : 'custom', identityArg: 'appkv', customPath: this.options.dirPath, mandatoryKeys: this.options.requiredKeys as Array, }); if (this.options.envMapping) { console.log(`📦 Processing envMapping for AppData...`); const totalKeys = Object.keys(this.options.envMapping).length; let processedCount = 0; // Process each top-level key in envMapping for (const key in this.options.envMapping) { try { const mappingSpec = this.options.envMapping[key]; console.log(` → Processing key "${key}" with spec:`, typeof mappingSpec === 'string' ? mappingSpec : 'nested object'); const evaluated = await evaluateMappingValue(mappingSpec); if (evaluated !== undefined) { await this.kvStore.writeKey(key as keyof T, evaluated); processedCount++; const valueType = Array.isArray(evaluated) ? 'array' : typeof evaluated; console.log(` ✅ Successfully processed key "${key}" (type: ${valueType})`); } else { console.log(` ⚠️ Key "${key}" evaluated to undefined, skipping`); } } catch (err) { console.error(` ❌ Failed to evaluate envMapping for key "${key}":`, err); } } console.log(`📊 EnvMapping complete: ${processedCount}/${totalKeys} keys successfully processed`); } // Apply overwrite object after env mapping if (this.options.overwriteObject) { const overwriteKeys = Object.keys(this.options.overwriteObject); console.log(`🔄 Applying overwriteObject with ${overwriteKeys.length} key(s)...`); for (const key of overwriteKeys) { const value = this.options.overwriteObject[key]; const valueType = Array.isArray(value) ? 'array' : typeof value; console.log(` 🔧 Overwriting key "${key}" with ${valueType} value`); await this.kvStore.writeKey( key as keyof T, value, ); } console.log(`✅ OverwriteObject complete: ${overwriteKeys.length} key(s) overwritten`); } this.readyDeferred.resolve(); console.log('✨ AppData initialization complete!'); } /** * returns a kvstore that resides in appdata */ public async getKvStore(): Promise> { await this.readyDeferred.promise; return this.kvStore; } public async logMissingKeys(): Promise> { const kvStore = await this.getKvStore(); const missingMandatoryKeys = await kvStore.getMissingMandatoryKeys(); if (missingMandatoryKeys.length > 0) { console.log( `The following mandatory keys are missing in the appdata:\n -> ${missingMandatoryKeys.join( ',\n -> ', )}`, ); } else { console.log('All mandatory keys are present in the appdata'); } return missingMandatoryKeys; } public async waitForAndGetKey( keyArg: K, ): Promise { await this.readyDeferred.promise; await this.kvStore.waitForKeysPresent([keyArg]); return this.kvStore.readKey(keyArg); } }