Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
e21815b0e2 | |||
5f1090dd62 |
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-08-15 - 5.3.1 - fix(AppData/Conversion)
|
||||
Improve boolean conversion and mapping evaluation in AppData, ensuring falsy values (like false, 0, and empty strings) are correctly handled and logged. Also, reduce test timeout and add local permissions settings for development.
|
||||
|
||||
- Enhanced toBoolean and evaluateMappingValue functions to properly preserve and log falsy values.
|
||||
- Added detailed logging for mapping spec processing and nested key evaluations.
|
||||
- Reduced test timeout in package.json for faster CI feedback.
|
||||
- Introduced .claude/settings.local.json with updated permissions for local development.
|
||||
|
||||
## 2025-08-15 - 5.3.0 - feat(AppData)
|
||||
Refactor AppData class for declarative env mapping and enhanced static helpers
|
||||
|
||||
|
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@push.rocks/npmextra",
|
||||
"version": "5.3.0",
|
||||
"version": "5.3.1",
|
||||
"private": false,
|
||||
"description": "A utility to enhance npm with additional configuration, tool management capabilities, and a key-value store for project setups.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 20)",
|
||||
"build": "(tsbuild --web --allowimplicitany)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
|
139
test/test.boolean-false.ts
Normal file
139
test/test.boolean-false.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as npmextra from '../ts/index.js';
|
||||
|
||||
// Test environment variable with boolean false value
|
||||
tap.test('should handle boolean false values in nested objects correctly', async () => {
|
||||
// Set up test environment variable
|
||||
process.env['S3_USESSL'] = 'false';
|
||||
process.env['S3_ENDPOINT'] = 'https://s3.example.com';
|
||||
process.env['S3_REGION'] = 'us-east-1';
|
||||
process.env['S3_ACCESSKEY'] = 'test-key';
|
||||
process.env['S3_ACCESSSECRET'] = 'test-secret';
|
||||
|
||||
// Create AppData with nested object structure similar to CloudlyConfig
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true, // Use in-memory storage for testing
|
||||
envMapping: {
|
||||
s3Descriptor: {
|
||||
endpoint: 'S3_ENDPOINT',
|
||||
region: 'S3_REGION',
|
||||
accessKey: 'S3_ACCESSKEY',
|
||||
accessSecret: 'S3_ACCESSSECRET',
|
||||
useSsl: 'boolean:S3_USESSL'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get the kvStore and read the configuration
|
||||
const kvStore = await appData.getKvStore();
|
||||
const s3Descriptor = await kvStore.readKey('s3Descriptor');
|
||||
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log('S3 Descriptor:', JSON.stringify(s3Descriptor, null, 2));
|
||||
console.log('useSsl value:', s3Descriptor?.useSsl);
|
||||
console.log('useSsl type:', typeof s3Descriptor?.useSsl);
|
||||
console.log('useSsl === false:', (s3Descriptor?.useSsl as any) === false);
|
||||
|
||||
// Verify the values
|
||||
expect(s3Descriptor).toBeTruthy();
|
||||
expect(s3Descriptor.endpoint).toEqual('https://s3.example.com');
|
||||
expect(s3Descriptor.region).toEqual('us-east-1');
|
||||
expect(s3Descriptor.accessKey).toEqual('test-key');
|
||||
expect(s3Descriptor.accessSecret).toEqual('test-secret');
|
||||
|
||||
// Critical test: useSsl should be false, not undefined
|
||||
expect(s3Descriptor.useSsl).toEqual(false);
|
||||
expect(typeof s3Descriptor.useSsl).toEqual('boolean');
|
||||
expect(s3Descriptor.useSsl).not.toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should handle various boolean representations correctly', async () => {
|
||||
// Test different boolean representations
|
||||
const testCases = [
|
||||
{ env: 'false', expected: false },
|
||||
{ env: 'FALSE', expected: false },
|
||||
{ env: '0', expected: false },
|
||||
{ env: 'no', expected: false },
|
||||
{ env: 'NO', expected: false },
|
||||
{ env: 'n', expected: false },
|
||||
{ env: 'off', expected: false },
|
||||
{ env: 'true', expected: true },
|
||||
{ env: 'TRUE', expected: true },
|
||||
{ env: '1', expected: true },
|
||||
{ env: 'yes', expected: true },
|
||||
{ env: 'YES', expected: true },
|
||||
{ env: 'y', expected: true },
|
||||
{ env: 'on', expected: true },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
process.env['TEST_BOOL'] = testCase.env;
|
||||
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true,
|
||||
envMapping: {
|
||||
testBool: 'boolean:TEST_BOOL'
|
||||
}
|
||||
});
|
||||
|
||||
const kvStore = await appData.getKvStore();
|
||||
const testBool = await kvStore.readKey('testBool');
|
||||
|
||||
console.log(`Input "${testCase.env}" => ${testBool} (expected: ${testCase.expected})`);
|
||||
expect(testBool).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle hardcoded boolean false values', async () => {
|
||||
// Test with hardcoded boolean false value
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true,
|
||||
envMapping: {
|
||||
boolValue: 'hard_boolean:false'
|
||||
}
|
||||
});
|
||||
|
||||
const kvStore = await appData.getKvStore();
|
||||
const boolValue = await kvStore.readKey('boolValue');
|
||||
|
||||
console.log('\n=== Hardcoded Boolean Test ===');
|
||||
console.log('boolValue:', boolValue);
|
||||
console.log('type:', typeof boolValue);
|
||||
console.log('is false:', (boolValue as any) === false);
|
||||
|
||||
expect(boolValue).toEqual(false);
|
||||
expect(typeof boolValue).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('should not filter out other falsy values', async () => {
|
||||
process.env['ZERO_VALUE'] = '0';
|
||||
process.env['EMPTY_STRING'] = ''; // This should be preserved as empty string
|
||||
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true,
|
||||
envMapping: {
|
||||
nested: {
|
||||
zeroAsNumber: 'ZERO_VALUE', // Should preserve "0" as string
|
||||
zeroAsBoolean: 'boolean:ZERO_VALUE', // Should convert to false
|
||||
emptyString: 'EMPTY_STRING', // Should preserve empty string
|
||||
hardcodedFalse: 'hard_boolean:false', // Should be false
|
||||
hardcodedZero: 'hard:0', // Should be "0" string
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const kvStore = await appData.getKvStore();
|
||||
const nested = await kvStore.readKey('nested');
|
||||
|
||||
console.log('\n=== Falsy Values Test ===');
|
||||
console.log('nested:', JSON.stringify(nested, null, 2));
|
||||
|
||||
expect(nested).toBeTruthy();
|
||||
expect(nested.zeroAsNumber).toEqual('0');
|
||||
expect(nested.zeroAsBoolean).toEqual(false);
|
||||
expect(nested.emptyString).toEqual('');
|
||||
expect(nested.hardcodedFalse).toEqual(false);
|
||||
expect(nested.hardcodedZero).toEqual('0');
|
||||
});
|
||||
|
||||
export default tap.start();
|
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/npmextra',
|
||||
version: '5.3.0',
|
||||
version: '5.3.1',
|
||||
description: 'A utility to enhance npm with additional configuration, tool management capabilities, and a key-value store for project setups.'
|
||||
}
|
||||
|
@@ -20,10 +20,37 @@ function getQenv(): plugins.qenv.Qenv {
|
||||
// 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';
|
||||
// If already boolean, return as-is
|
||||
if (typeof value === 'boolean') {
|
||||
console.log(` 🔹 toBoolean: value is already boolean: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Handle null/undefined
|
||||
if (value == null) {
|
||||
console.log(` 🔹 toBoolean: value is null/undefined, returning false`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle string representations
|
||||
const s = String(value).toLowerCase().trim();
|
||||
|
||||
// True values: "true", "1", "yes", "y", "on"
|
||||
if (['true', '1', 'yes', 'y', 'on'].includes(s)) {
|
||||
console.log(` 🔹 toBoolean: converting "${value}" to true`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// False values: "false", "0", "no", "n", "off"
|
||||
if (['false', '0', 'no', 'n', 'off'].includes(s)) {
|
||||
console.log(` 🔹 toBoolean: converting "${value}" to false`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default: non-empty string = true, empty = false
|
||||
const result = s.length > 0;
|
||||
console.log(` 🔹 toBoolean: defaulting "${value}" to ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function toJson<T = any>(value: unknown): T | undefined {
|
||||
@@ -159,6 +186,12 @@ async function resolveSource(source: MappingSpec['source']): Promise<unknown> {
|
||||
return source.value;
|
||||
}
|
||||
// source.type === 'env'
|
||||
// Workaround for Qenv bug where empty strings are treated as undefined
|
||||
// Check process.env directly first to preserve empty strings
|
||||
if (Object.prototype.hasOwnProperty.call(process.env, source.key)) {
|
||||
return process.env[source.key];
|
||||
}
|
||||
// Fall back to Qenv for other sources (env.json, docker secrets, etc.)
|
||||
return await getQenv().getEnvVarOnDemand(source.key);
|
||||
}
|
||||
|
||||
@@ -177,32 +210,60 @@ function applyTransforms(value: unknown, transforms: Transform[]): unknown {
|
||||
*/
|
||||
async function processMappingValue(mappingString: string): Promise<unknown> {
|
||||
const spec = parseMappingSpec(mappingString);
|
||||
console.log(` 🔍 Processing mapping: "${mappingString}"`);
|
||||
console.log(` Source: ${spec.source.type === 'env' ? `env:${spec.source.key}` : `hard:${spec.source.value}`}`);
|
||||
console.log(` Transforms: ${spec.transforms.length > 0 ? spec.transforms.join(', ') : 'none'}`);
|
||||
|
||||
const rawValue = await resolveSource(spec.source);
|
||||
console.log(` Raw value: ${JSON.stringify(rawValue)} (type: ${typeof rawValue})`);
|
||||
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
console.log(` ⚠️ Raw value is undefined/null, returning undefined`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return applyTransforms(rawValue, spec.transforms);
|
||||
const result = applyTransforms(rawValue, spec.transforms);
|
||||
console.log(` Final value: ${JSON.stringify(result)} (type: ${typeof result})`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively evaluate mapping values (strings or nested objects)
|
||||
*/
|
||||
async function evaluateMappingValue(mappingValue: any): Promise<any> {
|
||||
// Handle null explicitly - it should return null, not be treated as object
|
||||
if (mappingValue === null) {
|
||||
console.log(` 📌 Value is null, returning null`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle strings (mapping specs)
|
||||
if (typeof mappingValue === 'string') {
|
||||
return processMappingValue(mappingValue);
|
||||
}
|
||||
|
||||
// Handle objects (but not arrays or null)
|
||||
if (mappingValue && typeof mappingValue === 'object' && !Array.isArray(mappingValue)) {
|
||||
console.log(` 📂 Processing nested object with ${Object.keys(mappingValue).length} keys`);
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(mappingValue)) {
|
||||
result[key] = await evaluateMappingValue(value);
|
||||
console.log(` → Processing nested key "${key}"`);
|
||||
const evaluated = await evaluateMappingValue(value);
|
||||
// Important: Don't filter out false or other falsy values!
|
||||
// Only skip if explicitly undefined
|
||||
if (evaluated !== undefined) {
|
||||
result[key] = evaluated;
|
||||
console.log(` ✓ Nested key "${key}" = ${JSON.stringify(evaluated)} (type: ${typeof evaluated})`);
|
||||
} else {
|
||||
console.log(` ⚠️ Nested key "${key}" evaluated to undefined, skipping`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
// For any other type (numbers, booleans, etc.), return as-is
|
||||
console.log(` 📎 Returning value as-is: ${JSON.stringify(mappingValue)} (type: ${typeof mappingValue})`);
|
||||
return mappingValue;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -355,14 +416,26 @@ export class AppData<T = any> {
|
||||
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 specType = mappingSpec === null ? 'null' :
|
||||
typeof mappingSpec === 'string' ? mappingSpec :
|
||||
typeof mappingSpec === 'object' ? 'nested object' :
|
||||
typeof mappingSpec;
|
||||
console.log(` → Processing key "${key}" with spec: ${specType}`);
|
||||
|
||||
const evaluated = await evaluateMappingValue(mappingSpec);
|
||||
// Important: Don't skip false, 0, empty string, or null values!
|
||||
// Only skip if explicitly undefined
|
||||
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})`);
|
||||
const valueType = evaluated === null ? 'null' :
|
||||
Array.isArray(evaluated) ? 'array' :
|
||||
typeof evaluated;
|
||||
const valuePreview = evaluated === null ? 'null' :
|
||||
typeof evaluated === 'object' ?
|
||||
(Array.isArray(evaluated) ? `[${evaluated.length} items]` : `{${Object.keys(evaluated).length} keys}`) :
|
||||
JSON.stringify(evaluated);
|
||||
console.log(` ✅ Successfully processed key "${key}" = ${valuePreview} (type: ${valueType})`);
|
||||
} else {
|
||||
console.log(` ⚠️ Key "${key}" evaluated to undefined, skipping`);
|
||||
}
|
||||
|
Reference in New Issue
Block a user