Compare commits

..

4 Commits

Author SHA1 Message Date
2cc0da4462 5.3.2
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 4m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-16 12:42:51 +00:00
b1e4ab09db fix(dependencies): Bump @push.rocks/qenv to ^6.1.3 and add local Claude settings 2025-08-16 12:42:51 +00:00
e21815b0e2 5.3.1
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 4m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-15 18:14:57 +00:00
5f1090dd62 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. 2025-08-15 18:14:57 +00:00
6 changed files with 246 additions and 20 deletions

View File

@@ -1,5 +1,19 @@
# Changelog
## 2025-08-16 - 5.3.2 - fix(dependencies)
Bump @push.rocks/qenv to ^6.1.3 and add local Claude settings
- Bumped dependency @push.rocks/qenv from ^6.1.2 to ^6.1.3 in package.json
- Added .claude/settings.local.json to provide local Claude permissions and tooling allowances for development/CI helpers
## 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

View File

@@ -1,12 +1,12 @@
{
"name": "@push.rocks/npmextra",
"version": "5.3.0",
"version": "5.3.2",
"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"
},
@@ -21,7 +21,7 @@
},
"homepage": "https://code.foss.global/push.rocks/npmextra#readme",
"dependencies": {
"@push.rocks/qenv": "^6.1.2",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartfile": "^11.2.5",
"@push.rocks/smartjson": "^5.0.20",
"@push.rocks/smartlog": "^3.1.8",

12
pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@push.rocks/qenv':
specifier: ^6.1.2
version: 6.1.2
specifier: ^6.1.3
version: 6.1.3
'@push.rocks/smartfile':
specifier: ^11.2.5
version: 11.2.5
@@ -578,8 +578,8 @@ packages:
'@push.rocks/mongodump@1.0.8':
resolution: {integrity: sha512-oDufyjNBg8I50OaJvbHhc0RnRpJQ544dr9her0G6sA8JmI3hD2/amTdcPLVIX1kzYf5GsTUKeWuRaZgdNqz3ew==}
'@push.rocks/qenv@6.1.2':
resolution: {integrity: sha512-epb5Ey7E3jVCjxvNmQ5bcjPs9+7d1z/5bV/V8+qwrPqZrbgXnslOnsQWOh9usAatO0VJqqZmSvLYSpjnm3NEcA==}
'@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
'@push.rocks/smartarchive@3.0.8':
resolution: {integrity: sha512-1jPmR0b7hXmjYQoRiTlRXrIbZcdcFmSdGOfznufjcDpGPe86Km0d8TBnzqghTx4dTihzKC67IxAaz/DM3lvxpA==}
@@ -4725,7 +4725,7 @@ snapshots:
'@git.zone/tsbundle': 2.5.1
'@git.zone/tsrun': 1.3.3
'@push.rocks/consolecolor': 2.0.3
'@push.rocks/qenv': 6.1.2
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartbrowser': 2.0.8(typescript@5.8.3)
'@push.rocks/smartchok': 1.1.1
'@push.rocks/smartcrypto': 2.0.4
@@ -4953,7 +4953,7 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@push.rocks/qenv@6.1.2':
'@push.rocks/qenv@6.1.3':
dependencies:
'@api.global/typedrequest': 3.1.10
'@configvault.io/interfaces': 1.0.17

139
test/test.boolean-false.ts Normal file
View 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();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/npmextra',
version: '5.3.0',
version: '5.3.2',
description: 'A utility to enhance npm with additional configuration, tool management capabilities, and a key-value store for project setups.'
}

View File

@@ -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`);
}