@push.rocks/smartconfig 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,548 @@
1
+ import * as plugins from './plugins.js';
2
+ import { KeyValueStore } from './classes.keyvaluestore.js';
3
+
4
+ // ============================================================================
5
+ // Singleton Qenv Provider
6
+ // ============================================================================
7
+ let sharedQenv: plugins.qenv.Qenv | undefined;
8
+
9
+ function getQenv(): plugins.qenv.Qenv {
10
+ if (!sharedQenv) {
11
+ sharedQenv = new plugins.qenv.Qenv(
12
+ process.cwd(),
13
+ plugins.path.join(process.cwd(), '.nogit')
14
+ );
15
+ }
16
+ return sharedQenv;
17
+ }
18
+
19
+ // ============================================================================
20
+ // Security - Redaction for sensitive data
21
+ // ============================================================================
22
+ /**
23
+ * Redacts sensitive values in logs to prevent exposure of secrets
24
+ */
25
+ function redactSensitiveValue(key: string, value: unknown): string {
26
+ // List of patterns that indicate sensitive data
27
+ const sensitivePatterns = [
28
+ /secret/i, /token/i, /key/i, /password/i, /pass/i,
29
+ /api/i, /credential/i, /auth/i, /private/i, /jwt/i,
30
+ /cert/i, /signature/i, /bearer/i
31
+ ];
32
+
33
+ // Check if key contains sensitive pattern
34
+ const isSensitive = sensitivePatterns.some(pattern => pattern.test(key));
35
+
36
+ if (isSensitive) {
37
+ if (typeof value === 'string') {
38
+ // Show first 3 chars and length for debugging
39
+ return value.length > 3
40
+ ? `${value.substring(0, 3)}...[${value.length} chars]`
41
+ : '[redacted]';
42
+ }
43
+ return '[redacted]';
44
+ }
45
+
46
+ // Check if value looks like a JWT token or base64 secret
47
+ if (typeof value === 'string') {
48
+ // JWT tokens start with eyJ
49
+ if (value.startsWith('eyJ')) {
50
+ return `eyJ...[${value.length} chars]`;
51
+ }
52
+ // Very long strings might be encoded secrets
53
+ if (value.length > 100) {
54
+ return `${value.substring(0, 50)}...[${value.length} chars total]`;
55
+ }
56
+ }
57
+
58
+ return JSON.stringify(value);
59
+ }
60
+
61
+ // ============================================================================
62
+ // Type Converters - Centralized conversion logic
63
+ // ============================================================================
64
+ function toBoolean(value: unknown): boolean {
65
+ // If already boolean, return as-is
66
+ if (typeof value === 'boolean') {
67
+ console.log(` 🔹 toBoolean: value is already boolean: ${value}`);
68
+ return value;
69
+ }
70
+
71
+ // Handle null/undefined
72
+ if (value == null) {
73
+ console.log(` 🔹 toBoolean: value is null/undefined, returning false`);
74
+ return false;
75
+ }
76
+
77
+ // Handle string representations
78
+ const s = String(value).toLowerCase().trim();
79
+
80
+ // True values: "true", "1", "yes", "y", "on"
81
+ if (['true', '1', 'yes', 'y', 'on'].includes(s)) {
82
+ console.log(` 🔹 toBoolean: converting "${value}" to true`);
83
+ return true;
84
+ }
85
+
86
+ // False values: "false", "0", "no", "n", "off"
87
+ if (['false', '0', 'no', 'n', 'off'].includes(s)) {
88
+ console.log(` 🔹 toBoolean: converting "${value}" to false`);
89
+ return false;
90
+ }
91
+
92
+ // Default: non-empty string = true, empty = false
93
+ const result = s.length > 0;
94
+ console.log(` 🔹 toBoolean: defaulting "${value}" to ${result}`);
95
+ return result;
96
+ }
97
+
98
+ function toJson<T = any>(value: unknown): T | undefined {
99
+ if (value == null) return undefined;
100
+ if (typeof value === 'string') {
101
+ try {
102
+ return JSON.parse(value);
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+ return value as T;
108
+ }
109
+
110
+ function fromBase64(value: unknown): string | undefined {
111
+ if (value == null) return undefined;
112
+ try {
113
+ return Buffer.from(String(value), 'base64').toString('utf8');
114
+ } catch {
115
+ return String(value);
116
+ }
117
+ }
118
+
119
+ function toNumber(value: unknown): number | undefined {
120
+ if (value == null) return undefined;
121
+ const num = Number(value);
122
+ return Number.isNaN(num) ? undefined : num;
123
+ }
124
+
125
+ function toString(value: unknown): string | undefined {
126
+ if (value == null) return undefined;
127
+ return String(value);
128
+ }
129
+
130
+ // ============================================================================
131
+ // Declarative Pipeline Architecture
132
+ // ============================================================================
133
+ type Transform = 'boolean' | 'json' | 'base64' | 'number';
134
+
135
+ type MappingSpec = {
136
+ source:
137
+ | { type: 'env'; key: string }
138
+ | { type: 'hard'; value: string };
139
+ transforms: Transform[];
140
+ };
141
+
142
+ // Transform registry for extensibility
143
+ const transformRegistry: Record<string, (v: unknown) => unknown> = {
144
+ boolean: toBoolean,
145
+ json: toJson,
146
+ base64: fromBase64,
147
+ number: toNumber,
148
+ };
149
+
150
+ /**
151
+ * Parse a mapping string into a declarative spec
152
+ */
153
+ function parseMappingSpec(input: string): MappingSpec {
154
+ const transforms: Transform[] = [];
155
+ let remaining = input;
156
+
157
+ // Check for hardcoded prefixes with type conversion
158
+ if (remaining.startsWith('hard_boolean:')) {
159
+ return {
160
+ source: { type: 'hard', value: remaining.slice(13) },
161
+ transforms: ['boolean']
162
+ };
163
+ }
164
+
165
+ if (remaining.startsWith('hard_json:')) {
166
+ return {
167
+ source: { type: 'hard', value: remaining.slice(10) },
168
+ transforms: ['json']
169
+ };
170
+ }
171
+
172
+ if (remaining.startsWith('hard_base64:')) {
173
+ return {
174
+ source: { type: 'hard', value: remaining.slice(12) },
175
+ transforms: ['base64']
176
+ };
177
+ }
178
+
179
+ // Check for generic hard: prefix
180
+ if (remaining.startsWith('hard:')) {
181
+ remaining = remaining.slice(5);
182
+ // Check for legacy suffixes on hardcoded values
183
+ if (remaining.endsWith('_JSON')) {
184
+ transforms.push('json');
185
+ remaining = remaining.slice(0, -5);
186
+ } else if (remaining.endsWith('_BASE64')) {
187
+ transforms.push('base64');
188
+ remaining = remaining.slice(0, -7);
189
+ }
190
+ return {
191
+ source: { type: 'hard', value: remaining },
192
+ transforms
193
+ };
194
+ }
195
+
196
+ // Check for env var prefixes
197
+ if (remaining.startsWith('boolean:')) {
198
+ transforms.push('boolean');
199
+ remaining = remaining.slice(8);
200
+ } else if (remaining.startsWith('json:')) {
201
+ transforms.push('json');
202
+ remaining = remaining.slice(5);
203
+ } else if (remaining.startsWith('base64:')) {
204
+ transforms.push('base64');
205
+ remaining = remaining.slice(7);
206
+ }
207
+
208
+ // Check for legacy suffixes on env vars
209
+ if (remaining.endsWith('_JSON')) {
210
+ transforms.push('json');
211
+ remaining = remaining.slice(0, -5);
212
+ } else if (remaining.endsWith('_BASE64')) {
213
+ transforms.push('base64');
214
+ remaining = remaining.slice(0, -7);
215
+ }
216
+
217
+ return {
218
+ source: { type: 'env', key: remaining },
219
+ transforms
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Resolve the source value (env var or hardcoded)
225
+ */
226
+ async function resolveSource(source: MappingSpec['source']): Promise<unknown> {
227
+ if (source.type === 'hard') {
228
+ return source.value;
229
+ }
230
+ // source.type === 'env'
231
+ // Workaround for Qenv bug where empty strings are treated as undefined
232
+ // Check process.env directly first to preserve empty strings
233
+ if (Object.prototype.hasOwnProperty.call(process.env, source.key)) {
234
+ return process.env[source.key];
235
+ }
236
+ // Fall back to Qenv for other sources (env.json, docker secrets, etc.)
237
+ return await getQenv().getEnvVarOnDemand(source.key);
238
+ }
239
+
240
+ /**
241
+ * Apply transformations in sequence
242
+ */
243
+ function applyTransforms(value: unknown, transforms: Transform[]): unknown {
244
+ return transforms.reduce((acc, transform) => {
245
+ const fn = transformRegistry[transform];
246
+ return fn ? fn(acc) : acc;
247
+ }, value);
248
+ }
249
+
250
+ /**
251
+ * Process a mapping value through the complete pipeline
252
+ */
253
+ async function processMappingValue(mappingString: string): Promise<unknown> {
254
+ const spec = parseMappingSpec(mappingString);
255
+ const keyName = spec.source.type === 'env' ? spec.source.key : 'hardcoded';
256
+
257
+ console.log(` 🔍 Processing mapping: "${mappingString}"`);
258
+ console.log(` Source: ${spec.source.type === 'env' ? `env:${spec.source.key}` : `hard:${spec.source.value}`}`);
259
+ console.log(` Transforms: ${spec.transforms.length > 0 ? spec.transforms.join(', ') : 'none'}`);
260
+
261
+ const rawValue = await resolveSource(spec.source);
262
+ console.log(` Raw value: ${redactSensitiveValue(keyName, rawValue)} (type: ${typeof rawValue})`);
263
+
264
+ if (rawValue === undefined || rawValue === null) {
265
+ console.log(` ⚠️ Raw value is undefined/null, returning undefined`);
266
+ return undefined;
267
+ }
268
+
269
+ const result = applyTransforms(rawValue, spec.transforms);
270
+ console.log(` Final value: ${redactSensitiveValue(keyName, result)} (type: ${typeof result})`);
271
+ return result;
272
+ }
273
+
274
+ /**
275
+ * Recursively evaluate mapping values (strings or nested objects)
276
+ */
277
+ async function evaluateMappingValue(mappingValue: any): Promise<any> {
278
+ // Handle null explicitly - it should return null, not be treated as object
279
+ if (mappingValue === null) {
280
+ console.log(` 📌 Value is null, returning null`);
281
+ return null;
282
+ }
283
+
284
+ // Handle strings (mapping specs)
285
+ if (typeof mappingValue === 'string') {
286
+ return processMappingValue(mappingValue);
287
+ }
288
+
289
+ // Handle objects (but not arrays or null)
290
+ if (mappingValue && typeof mappingValue === 'object' && !Array.isArray(mappingValue)) {
291
+ console.log(` 📂 Processing nested object with ${Object.keys(mappingValue).length} keys`);
292
+ const result: any = {};
293
+ for (const [key, value] of Object.entries(mappingValue)) {
294
+ console.log(` → Processing nested key "${key}"`);
295
+ const evaluated = await evaluateMappingValue(value);
296
+ // Important: Don't filter out false or other falsy values!
297
+ // Only skip if explicitly undefined
298
+ if (evaluated !== undefined) {
299
+ result[key] = evaluated;
300
+ console.log(` ✓ Nested key "${key}" = ${redactSensitiveValue(key, evaluated)} (type: ${typeof evaluated})`);
301
+ } else {
302
+ console.log(` ⚠️ Nested key "${key}" evaluated to undefined, skipping`);
303
+ }
304
+ }
305
+ return result;
306
+ }
307
+
308
+ // For any other type (numbers, booleans, etc.), return as-is
309
+ // Note: We don't have key context here, so we'll just indicate the type
310
+ console.log(` 📎 Returning value as-is: [value] (type: ${typeof mappingValue})`);
311
+ return mappingValue;
312
+ }
313
+
314
+ // ============================================================================
315
+ // AppData Interface and Class
316
+ // ============================================================================
317
+ export interface IAppDataOptions<T = any> {
318
+ dirPath?: string;
319
+ requiredKeys?: Array<keyof T>;
320
+
321
+ /**
322
+ * Whether keys should be persisted on disk or not
323
+ */
324
+ ephemeral?: boolean;
325
+
326
+ /**
327
+ * @deprecated Use 'ephemeral' instead
328
+ */
329
+ ephermal?: boolean;
330
+
331
+ /**
332
+ * kvStoreKey: 'MY_ENV_VAR'
333
+ */
334
+ envMapping?: plugins.tsclass.typeFest.PartialDeep<T>;
335
+ overwriteObject?: plugins.tsclass.typeFest.PartialDeep<T>;
336
+ }
337
+
338
+ export class AppData<T = any> {
339
+ /**
340
+ * creates appdata. If no pathArg is given, data will be stored here:
341
+ * ${PWD}/.nogit/appdata
342
+ * @param pathArg
343
+ * @returns
344
+ */
345
+ public static async createAndInit<T = any>(
346
+ optionsArg: IAppDataOptions<T> = {},
347
+ ): Promise<AppData<T>> {
348
+ const appData = new AppData<T>(optionsArg);
349
+ await appData.readyDeferred.promise;
350
+ return appData;
351
+ }
352
+
353
+ /**
354
+ * Static helper to get an environment variable as a boolean
355
+ * @param envVarName The name of the environment variable
356
+ * @returns boolean value (true if env var is "true", false otherwise)
357
+ */
358
+ public static async valueAsBoolean(envVarName: string): Promise<boolean> {
359
+ const value = await getQenv().getEnvVarOnDemand(envVarName);
360
+ return toBoolean(value);
361
+ }
362
+
363
+ /**
364
+ * Static helper to get an environment variable as parsed JSON
365
+ * @param envVarName The name of the environment variable
366
+ * @returns Parsed JSON object/array
367
+ */
368
+ public static async valueAsJson<R = any>(envVarName: string): Promise<R | undefined> {
369
+ const value = await getQenv().getEnvVarOnDemand(envVarName);
370
+ return toJson<R>(value);
371
+ }
372
+
373
+ /**
374
+ * Static helper to get an environment variable as base64 decoded string
375
+ * @param envVarName The name of the environment variable
376
+ * @returns Decoded string
377
+ */
378
+ public static async valueAsBase64(envVarName: string): Promise<string | undefined> {
379
+ const value = await getQenv().getEnvVarOnDemand(envVarName);
380
+ return fromBase64(value);
381
+ }
382
+
383
+ /**
384
+ * Static helper to get an environment variable as a string
385
+ * @param envVarName The name of the environment variable
386
+ * @returns String value
387
+ */
388
+ public static async valueAsString(envVarName: string): Promise<string | undefined> {
389
+ const value = await getQenv().getEnvVarOnDemand(envVarName);
390
+ return toString(value);
391
+ }
392
+
393
+ /**
394
+ * Static helper to get an environment variable as a number
395
+ * @param envVarName The name of the environment variable
396
+ * @returns Number value
397
+ */
398
+ public static async valueAsNumber(envVarName: string): Promise<number | undefined> {
399
+ const value = await getQenv().getEnvVarOnDemand(envVarName);
400
+ return toNumber(value);
401
+ }
402
+
403
+ // instance
404
+ public readyDeferred = plugins.smartpromise.defer<void>();
405
+ public options: IAppDataOptions<T>;
406
+ private kvStore: KeyValueStore<T>;
407
+
408
+ constructor(optionsArg: IAppDataOptions<T> = {}) {
409
+ this.options = optionsArg;
410
+ this.init();
411
+ }
412
+
413
+ /**
414
+ * inits app data
415
+ */
416
+ private async init() {
417
+ console.log('🚀 Initializing AppData...');
418
+
419
+ // Handle backward compatibility for typo
420
+ const isEphemeral = this.options.ephemeral ?? this.options.ephermal ?? false;
421
+ if (this.options.ephermal && !this.options.ephemeral) {
422
+ console.warn('⚠️ Option "ephermal" is deprecated, use "ephemeral" instead.');
423
+ }
424
+
425
+ if (this.options.dirPath) {
426
+ console.log(` 📁 Using custom directory: ${this.options.dirPath}`);
427
+ } else if (isEphemeral) {
428
+ console.log(` 💨 Using ephemeral storage (in-memory only)`);
429
+ } else {
430
+ const appDataDir = '/app/data';
431
+ const dataDir = '/data';
432
+ const nogitAppData = '.nogit/appdata';
433
+ const appDataExists = plugins.smartfile.fs.isDirectory(appDataDir);
434
+ const dataExists = plugins.smartfile.fs.isDirectory(dataDir);
435
+ if (appDataExists) {
436
+ this.options.dirPath = appDataDir;
437
+ console.log(` 📁 Auto-selected container directory: ${appDataDir}`);
438
+ } else if (dataExists) {
439
+ this.options.dirPath = dataDir;
440
+ console.log(` 📁 Auto-selected data directory: ${dataDir}`);
441
+ } else {
442
+ await plugins.smartfile.fs.ensureDir(nogitAppData);
443
+ this.options.dirPath = nogitAppData;
444
+ console.log(` 📁 Auto-selected local directory: ${nogitAppData}`);
445
+ }
446
+ }
447
+
448
+ this.kvStore = new KeyValueStore<T>({
449
+ typeArg: isEphemeral ? 'ephemeral' : 'custom',
450
+ identityArg: 'appkv',
451
+ customPath: this.options.dirPath,
452
+ mandatoryKeys: this.options.requiredKeys as Array<keyof T>,
453
+ });
454
+
455
+ if (this.options.envMapping) {
456
+ console.log(`📦 Processing envMapping for AppData...`);
457
+ const totalKeys = Object.keys(this.options.envMapping).length;
458
+ let processedCount = 0;
459
+
460
+ // Process each top-level key in envMapping
461
+ for (const key in this.options.envMapping) {
462
+ try {
463
+ const mappingSpec = this.options.envMapping[key];
464
+ const specType = mappingSpec === null ? 'null' :
465
+ typeof mappingSpec === 'string' ? mappingSpec :
466
+ typeof mappingSpec === 'object' ? 'nested object' :
467
+ typeof mappingSpec;
468
+ console.log(` → Processing key "${key}" with spec: ${specType}`);
469
+
470
+ const evaluated = await evaluateMappingValue(mappingSpec);
471
+ // Important: Don't skip false, 0, empty string, or null values!
472
+ // Only skip if explicitly undefined
473
+ if (evaluated !== undefined) {
474
+ await this.kvStore.writeKey(key as keyof T, evaluated);
475
+ processedCount++;
476
+ const valueType = evaluated === null ? 'null' :
477
+ Array.isArray(evaluated) ? 'array' :
478
+ typeof evaluated;
479
+ const valuePreview = evaluated === null ? 'null' :
480
+ typeof evaluated === 'object' ?
481
+ (Array.isArray(evaluated) ? `[${evaluated.length} items]` : `{${Object.keys(evaluated).length} keys}`) :
482
+ redactSensitiveValue(key, evaluated);
483
+ console.log(` ✅ Successfully processed key "${key}" = ${valuePreview} (type: ${valueType})`);
484
+ } else {
485
+ console.log(` ⚠️ Key "${key}" evaluated to undefined, skipping`);
486
+ }
487
+ } catch (err) {
488
+ console.error(` ❌ Failed to evaluate envMapping for key "${key}":`, err);
489
+ }
490
+ }
491
+
492
+ console.log(`📊 EnvMapping complete: ${processedCount}/${totalKeys} keys successfully processed`);
493
+ }
494
+
495
+ // Apply overwrite object after env mapping
496
+ if (this.options.overwriteObject) {
497
+ const overwriteKeys = Object.keys(this.options.overwriteObject);
498
+ console.log(`🔄 Applying overwriteObject with ${overwriteKeys.length} key(s)...`);
499
+
500
+ for (const key of overwriteKeys) {
501
+ const value = this.options.overwriteObject[key];
502
+ const valueType = Array.isArray(value) ? 'array' : typeof value;
503
+ console.log(` 🔧 Overwriting key "${key}" with ${valueType} value`);
504
+
505
+ await this.kvStore.writeKey(
506
+ key as keyof T,
507
+ value,
508
+ );
509
+ }
510
+
511
+ console.log(`✅ OverwriteObject complete: ${overwriteKeys.length} key(s) overwritten`);
512
+ }
513
+
514
+ this.readyDeferred.resolve();
515
+ console.log('✨ AppData initialization complete!');
516
+ }
517
+
518
+ /**
519
+ * returns a kvstore that resides in appdata
520
+ */
521
+ public async getKvStore(): Promise<KeyValueStore<T>> {
522
+ await this.readyDeferred.promise;
523
+ return this.kvStore;
524
+ }
525
+
526
+ public async logMissingKeys(): Promise<Array<keyof T>> {
527
+ const kvStore = await this.getKvStore();
528
+ const missingMandatoryKeys = await kvStore.getMissingMandatoryKeys();
529
+ if (missingMandatoryKeys.length > 0) {
530
+ console.log(
531
+ `The following mandatory keys are missing in the appdata:\n -> ${missingMandatoryKeys.join(
532
+ ',\n -> ',
533
+ )}`,
534
+ );
535
+ } else {
536
+ console.log('All mandatory keys are present in the appdata');
537
+ }
538
+ return missingMandatoryKeys;
539
+ }
540
+
541
+ public async waitForAndGetKey<K extends keyof T>(
542
+ keyArg: K,
543
+ ): Promise<T[K] | undefined> {
544
+ await this.readyDeferred.promise;
545
+ await this.kvStore.waitForKeysPresent([keyArg]);
546
+ return this.kvStore.readKey(keyArg);
547
+ }
548
+ }