Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c4ba7a7d0 | |||
| 4971279a22 | |||
| d82e98cfa1 | |||
| 4a2d625d79 | |||
| 488e78659c | |||
| 22a9aa9f3e | |||
| fdc2420238 | |||
| e3a76ca577 | |||
| 2cc0da4462 | |||
| b1e4ab09db | |||
| e21815b0e2 | |||
| 5f1090dd62 | |||
| b0df896a14 | |||
| ed969cee47 | |||
| 61fafd2c8f | |||
| 33a5b6b11c | |||
| c74ded8d5d | |||
| 62e61168a0 | |||
| 5e0edecf18 | |||
| 70cefc00fa | |||
| 6f14c73b5f | |||
| 1e6f636608 | |||
| eff77f8976 | |||
| b5f109d320 | |||
| 3a53938e8e | |||
| db90714a81 | |||
| b81ab9d9b2 | |||
| 5ec9124d29 | |||
| f7f035e878 | |||
| 3caf300544 | |||
| 6a70af9b6f | |||
| 2ad3da85a9 | |||
| 09e8b8b94c | |||
| 265f4df8b3 | |||
| 5461ec0636 | |||
| 937252f99e | |||
| 6f19c3cc71 | |||
| 4be54f6dcc | |||
| 5c6702b898 | |||
| f1ecda411a | |||
| 267d2c392d | |||
| 2ca82fb28b | |||
| b342602ca0 | |||
| bb72e1c2c0 | |||
| 40ad00e75a | |||
| de1db395cd | |||
| 83968d578c | |||
| 155cd8bd6a | |||
| 5eebd0fd02 | |||
| 686aa0d2e7 | |||
| f97d72da28 | |||
| 78d1540ce0 | |||
| 916dd116c1 | |||
| 5fd57cb56b | |||
| 1a106e322c | |||
| 36ded40032 | |||
| 44f5f84195 | |||
| 72655a6a0e | |||
| 961df11121 | |||
| ee6f5e63ee | |||
| a40b6adeef | |||
| 6ee324a0ef | |||
| 215a48a409 | |||
| aeaa6149e4 | |||
| 75059dfca8 | |||
| 90c0f30ce1 |
@@ -6,8 +6,8 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Install pnpm and npmci
|
- name: Install pnpm and npmci
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
|
|
||||||
- name: Run npm prepare
|
- name: Run npm prepare
|
||||||
run: npmci npm prepare
|
run: npmci npm prepare
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Audit production dependencies
|
- name: Audit production dependencies
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Test stable
|
- name: Test stable
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
pnpm install -g pnpm
|
pnpm install -g pnpm
|
||||||
pnpm install -g @shipzone/npmci
|
pnpm install -g @ship.zone/npmci
|
||||||
npmci npm prepare
|
npmci npm prepare
|
||||||
|
|
||||||
- name: Code quality
|
- name: Code quality
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,7 +3,6 @@
|
|||||||
# artifacts
|
# artifacts
|
||||||
coverage/
|
coverage/
|
||||||
public/
|
public/
|
||||||
pages/
|
|
||||||
|
|
||||||
# installs
|
# installs
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -17,4 +16,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dist_*/
|
dist_*/
|
||||||
|
|
||||||
# custom
|
#------# custom
|
||||||
48
.smartconfig.json
Normal file
48
.smartconfig.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"npmts": {
|
||||||
|
"testConfig": {
|
||||||
|
"parallel": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"projectType": "npm",
|
||||||
|
"module": {
|
||||||
|
"githost": "code.foss.global",
|
||||||
|
"gitscope": "push.rocks",
|
||||||
|
"gitrepo": "smartconfig",
|
||||||
|
"description": "A comprehensive configuration management library providing key-value storage, environment variable mapping, and tool configuration.",
|
||||||
|
"npmPackagename": "@push.rocks/smartconfig",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"npm",
|
||||||
|
"configuration management",
|
||||||
|
"tool management",
|
||||||
|
"key-value store",
|
||||||
|
"project setup",
|
||||||
|
"typescript",
|
||||||
|
"environment setup",
|
||||||
|
"dependencies management",
|
||||||
|
"npm package enhancement",
|
||||||
|
"automation",
|
||||||
|
"async operations",
|
||||||
|
"app configuration",
|
||||||
|
"smart file handling",
|
||||||
|
"workflow improvement",
|
||||||
|
"persistent storage"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@git.zone/tsdoc": {
|
||||||
|
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||||
|
},
|
||||||
|
"@ship.zone/szci": {
|
||||||
|
"globalNpmTools": []
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"json.schemas": [
|
"json.schemas": [
|
||||||
{
|
{
|
||||||
"fileMatch": ["/npmextra.json"],
|
"fileMatch": ["/.smartconfig.json"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
154
changelog.md
Normal file
154
changelog.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-24 - 6.1.0 - feat(docs)
|
||||||
|
refresh project documentation and migrate smartconfig tool configuration keys
|
||||||
|
|
||||||
|
- rewrites the README with a clearer quick start, updated class documentation, and issue reporting guidance
|
||||||
|
- updates .smartconfig.json to use scoped tool configuration keys and adds release registry settings
|
||||||
|
- adds a standalone MIT license file to the repository
|
||||||
|
|
||||||
|
## 2025-08-16 - 5.3.3 - fix(appdata)
|
||||||
|
Redact sensitive values in AppData logs and add redaction tests
|
||||||
|
|
||||||
|
- Add redactSensitiveValue helper in AppData to mask secrets (API keys, tokens, passwords, JWTs, etc.) during logging.
|
||||||
|
- Use redaction when logging raw and final mapping values in processMappingValue and nested key logging to avoid leaking sensitive data.
|
||||||
|
- Improve log output for long or special values (JWT/base64 detection, length-aware previews) while preserving actual stored values.
|
||||||
|
- Add test/test.redaction.ts to verify sensitive environment values are redacted in console output but still stored correctly in the kv store.
|
||||||
|
- Add local config .claude/settings.local.json (editor/CI permissions/settings).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Introduced a singleton Qenv provider to optimize environment variable resolution.
|
||||||
|
- Centralized type conversion logic with utility functions for boolean, JSON, base64, number, and string conversions.
|
||||||
|
- Replaced complex switch statements with a composable, declarative mapping pipeline for processing envMapping.
|
||||||
|
- Enhanced logging during AppData initialization to clearly report key processing and overwrite operations.
|
||||||
|
- Added new static helper methods for environment variable access (valueAsBoolean, valueAsJson, valueAsBase64, valueAsString, valueAsNumber).
|
||||||
|
- Fixed boolean conversion issues and ensured backward compatibility with the deprecated 'ephermal' option.
|
||||||
|
|
||||||
|
## 2025-08-15 - 5.2.0 - feat(AppData)
|
||||||
|
Major refactoring of AppData class for improved elegance and maintainability
|
||||||
|
|
||||||
|
- **New Features:**
|
||||||
|
- Added static helper methods for direct environment variable access:
|
||||||
|
- `AppData.valueAsBoolean()` - Convert env vars to boolean
|
||||||
|
- `AppData.valueAsJson()` - Parse env vars as JSON
|
||||||
|
- `AppData.valueAsBase64()` - Decode base64 env vars
|
||||||
|
- `AppData.valueAsString()` - Get env vars as string
|
||||||
|
- `AppData.valueAsNumber()` - Parse env vars as number
|
||||||
|
- Enhanced logging for AppData initialization and key processing:
|
||||||
|
- Shows which storage type is being used (custom, ephemeral, auto-selected)
|
||||||
|
- Logs each key being processed with its spec type
|
||||||
|
- Reports success/failure for each key with type information
|
||||||
|
- Provides summary statistics of processed keys
|
||||||
|
|
||||||
|
- **Architecture Improvements:**
|
||||||
|
- Replaced 100+ line switch statement with declarative pipeline architecture
|
||||||
|
- Introduced centralized type converters and transform registry
|
||||||
|
- Implemented composable transform pipeline: `parseMappingSpec()` → `resolveSource()` → `applyTransforms()`
|
||||||
|
- Added singleton Qenv provider to reduce allocations
|
||||||
|
- Reduced code complexity by ~70% while maintaining 100% backward compatibility
|
||||||
|
|
||||||
|
- **Bug Fixes:**
|
||||||
|
- Fixed boolean conversion to properly handle both string and boolean inputs
|
||||||
|
- Added `ephemeral` option (correctly spelled) while maintaining backward compatibility with deprecated `ephermal`
|
||||||
|
|
||||||
|
- **Performance:**
|
||||||
|
- Optimized environment variable resolution with shared Qenv instance
|
||||||
|
- Reduced object allocations in static helpers
|
||||||
|
|
||||||
|
## 2025-08-15 - 5.1.4 - fix(AppData, dev dependencies, settings)
|
||||||
|
Improve boolean conversion in AppData, update @types/node dependency, and add local settings file.
|
||||||
|
|
||||||
|
- Fixed env var boolean conversion to properly handle non-string values in AppData.
|
||||||
|
- Updated @types/node from ^20.14.5 to ^22 in package.json.
|
||||||
|
- Added .claude/settings.local.json to configure project permissions locally.
|
||||||
|
|
||||||
|
## 2025-08-15 - 5.1.3 - fix(appdata)
|
||||||
|
Fix iteration over overwriteObject in AppData and update configuration for dependency and path handling
|
||||||
|
|
||||||
|
- Replaced incorrect looping constructs in the AppData class to properly iterate over overwriteObject keys
|
||||||
|
- Improved environment variable mapping and file path resolution in multiple TS modules
|
||||||
|
- Updated dependency versions and adjusted git workflow configurations
|
||||||
|
- Enhanced project configuration including TS config and build script adjustments
|
||||||
|
|
||||||
|
## 2024-11-06 - 5.1.2 - fix(appdata)
|
||||||
|
|
||||||
|
Fix iteration over overwriteObject in AppData class
|
||||||
|
|
||||||
|
- Corrected the for loop from in to of inside the AppData class for iterating over overwriteObject keys.
|
||||||
|
|
||||||
|
## 2024-11-05 - 5.1.1 - fix(AppData)
|
||||||
|
|
||||||
|
Fix issue with overwrite object handling in AppData class
|
||||||
|
|
||||||
|
- Corrected logic to handle cases when overwriteObject is undefined.
|
||||||
|
|
||||||
|
## 2024-11-05 - 5.1.0 - feat(appdata)
|
||||||
|
|
||||||
|
Add support for overwriting keys using the overwriteObject option in AppData
|
||||||
|
|
||||||
|
- Introduced the overwriteObject option in IAppDataOptions to allow overwriting specific keys in the AppData class.
|
||||||
|
|
||||||
|
## 2024-06-19 - 5.0.17 - 5.0.23 - Core Updates
|
||||||
|
|
||||||
|
Routine maintenance and updates to the core components.
|
||||||
|
|
||||||
|
- Multiple core updates and fixes improving stability
|
||||||
|
|
||||||
|
## 2024-06-12 - 5.0.13 - 5.0.16 - Core Updates
|
||||||
|
|
||||||
|
Maintenance focus on core systems with enhancements and problem resolutions.
|
||||||
|
|
||||||
|
- Enhancements and updates in the core functionality
|
||||||
|
|
||||||
|
## 2024-05-29 - 5.0.13 - Documentation Update
|
||||||
|
|
||||||
|
Descriptive improvements aligned with current features.
|
||||||
|
|
||||||
|
- Updated core description for better clarity in documentation
|
||||||
|
|
||||||
|
## 2024-04-01 - 5.0.10 - Configuration Update
|
||||||
|
|
||||||
|
Improved configuration management for build processes.
|
||||||
|
|
||||||
|
- Updated `npmextra.json` to reflect changes in git repository management
|
||||||
|
|
||||||
|
## 2024-02-12 - 5.0.0 - 5.0.9 - Major Core Enhancements
|
||||||
|
|
||||||
|
A series of critical updates with resolved issues in the core components.
|
||||||
|
|
||||||
|
- Introduction of new core features
|
||||||
|
- Several core system updates
|
||||||
|
|
||||||
|
## 2024-02-12 - 4.0.16 - Major Version Transition
|
||||||
|
|
||||||
|
Migration to the new major version with impactful changes.
|
||||||
|
|
||||||
|
- BREAKING CHANGE: Significant updates requiring attention for smooth transition
|
||||||
|
|
||||||
|
## 2023-08-24 - 3.0.9 - 4.0.16 - Organization Updates
|
||||||
|
|
||||||
|
Formatted updates with attention to organizational standards and practice.
|
||||||
|
|
||||||
|
- SWITCH to a new organizational scheme
|
||||||
|
|
||||||
|
## 2023-07-11 - 3.0.9 - Organizational Enhancement
|
||||||
|
|
||||||
|
Shifts aligning with contemporary structuring and logistics.
|
||||||
|
|
||||||
|
- Strategic realignment with organizational principles
|
||||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"npmci": {
|
|
||||||
"globalNpmTools": [],
|
|
||||||
"npmAccessLevel": "public"
|
|
||||||
},
|
|
||||||
"npmts": {
|
|
||||||
"testConfig": {
|
|
||||||
"parallel": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gitzone": {
|
|
||||||
"projectType": "npm",
|
|
||||||
"module": {
|
|
||||||
"githost": "gitlab.com",
|
|
||||||
"gitscope": "push.rocks",
|
|
||||||
"gitrepo": "npmextra",
|
|
||||||
"description": "do more with npm",
|
|
||||||
"npmPackagename": "@push.rocks/npmextra",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
65
package.json
65
package.json
@@ -1,41 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/npmextra",
|
"name": "@push.rocks/smartconfig",
|
||||||
"version": "5.0.4",
|
"version": "6.1.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "do more with npm",
|
"description": "A comprehensive configuration management library providing key-value storage, environment variable mapping, and tool configuration.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --web)",
|
"test": "(tstest test/ --verbose --logfile --timeout 20)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)",
|
"build": "(tsbuild --web --allowimplicitany)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+ssh://git@gitlab.com/pushrocks/npmextra.git"
|
"url": "https://code.foss.global/push.rocks/smartconfig.git"
|
||||||
},
|
},
|
||||||
"author": "Lossless GmbH",
|
"author": "Lossless GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://gitlab.com/pushrocks/npmextra/issues"
|
"url": "https://code.foss.global/push.rocks/smartconfig/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://gitlab.com/pushrocks/npmextra#README",
|
"homepage": "https://code.foss.global/push.rocks/smartconfig#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/qenv": "^6.0.5",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartfile": "^11.0.4",
|
"@push.rocks/smartfile": "^11.2.5",
|
||||||
"@push.rocks/smartjson": "^5.0.10",
|
"@push.rocks/smartjson": "^5.0.20",
|
||||||
"@push.rocks/smartlog": "^3.0.2",
|
"@push.rocks/smartlog": "^3.1.8",
|
||||||
"@push.rocks/smartpath": "^5.0.11",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.0.2",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrx": "^3.0.7",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/taskbuffer": "^3.1.7"
|
"@push.rocks/taskbuffer": "^3.1.7",
|
||||||
|
"@tsclass/tsclass": "^9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.1.66",
|
"@git.zone/tsbuild": "^2.6.4",
|
||||||
"@git.zone/tsrun": "^1.2.44",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^1.0.77",
|
"@git.zone/tstest": "^2.3.2",
|
||||||
"@push.rocks/tapbundle": "^5.0.15",
|
"@types/node": "^22"
|
||||||
"@types/node": "^20.11.17"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
@@ -46,11 +46,32 @@
|
|||||||
"dist_ts_web/**/*",
|
"dist_ts_web/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"npmextra.json",
|
".smartconfig.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
],
|
],
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 1 chrome versions"
|
"last 1 chrome versions"
|
||||||
],
|
],
|
||||||
"type": "module"
|
"type": "module",
|
||||||
|
"keywords": [
|
||||||
|
"npm",
|
||||||
|
"configuration management",
|
||||||
|
"tool management",
|
||||||
|
"key-value store",
|
||||||
|
"project setup",
|
||||||
|
"typescript",
|
||||||
|
"environment setup",
|
||||||
|
"dependencies management",
|
||||||
|
"npm package enhancement",
|
||||||
|
"automation",
|
||||||
|
"async operations",
|
||||||
|
"app configuration",
|
||||||
|
"smart file handling",
|
||||||
|
"workflow improvement",
|
||||||
|
"persistent storage"
|
||||||
|
],
|
||||||
|
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11897
pnpm-lock.yaml
generated
11897
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- mongodb-memory-server
|
||||||
|
- puppeteer
|
||||||
0
readme.hints.md
Normal file
0
readme.hints.md
Normal file
342
readme.md
342
readme.md
@@ -1,70 +1,322 @@
|
|||||||
# @push.rocks/npmextra
|
# @push.rocks/smartconfig 🚀
|
||||||
do more with npm
|
|
||||||
|
|
||||||
## Availabililty and Links
|
A comprehensive TypeScript configuration management library providing centralized tool configs, persistent key-value storage, and powerful environment variable mapping with automatic type conversions.
|
||||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@push.rocks/npmextra)
|
|
||||||
* [gitlab.com (source)](https://gitlab.com/push.rocks/npmextra)
|
|
||||||
* [github.com (source mirror)](https://github.com/push.rocks/npmextra)
|
|
||||||
* [docs (typedoc)](https://push.rocks.gitlab.io/npmextra/)
|
|
||||||
|
|
||||||
## Status for master
|
## Issue Reporting and Security
|
||||||
|
|
||||||
Status Category | Status Badge
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
-- | --
|
|
||||||
GitLab Pipelines | [](https://lossless.cloud)
|
|
||||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
|
||||||
npm | [](https://lossless.cloud)
|
|
||||||
Snyk | [](https://lossless.cloud)
|
|
||||||
TypeScript Support | [](https://lossless.cloud)
|
|
||||||
node Support | [](https://nodejs.org/dist/latest-v10.x/docs/api/)
|
|
||||||
Code Style | [](https://lossless.cloud)
|
|
||||||
PackagePhobia (total standalone install weight) | [](https://lossless.cloud)
|
|
||||||
PackagePhobia (package size on registry) | [](https://lossless.cloud)
|
|
||||||
BundlePhobia (total size when bundled) | [](https://lossless.cloud)
|
|
||||||
|
|
||||||
## Usage
|
## Install 📦
|
||||||
|
|
||||||
Use TypeScript for best in class instellisense.
|
```bash
|
||||||
|
npm install @push.rocks/smartconfig --save
|
||||||
|
# or
|
||||||
|
pnpm add @push.rocks/smartconfig
|
||||||
|
```
|
||||||
|
|
||||||
## Using npmextra for managing toolconfigs
|
## Quick Start ⚡
|
||||||
|
|
||||||
The main purpose of npmextra is to configure different tools using one npmextra.json. This way complex setups avoid additional complexity caused by too many configuration files.
|
```typescript
|
||||||
|
import { Smartconfig, AppData, KeyValueStore } from '@push.rocks/smartconfig';
|
||||||
|
|
||||||
npmextra.json
|
// 1. Read tool config from .smartconfig.json
|
||||||
|
const sc = new Smartconfig();
|
||||||
|
const eslintOpts = sc.dataFor('eslint', { extends: 'standard' });
|
||||||
|
|
||||||
|
// 2. Map env vars to typed config (with auto-conversion)
|
||||||
|
const appData = await AppData.createAndInit<{ port: number; debug: boolean }>({
|
||||||
|
envMapping: {
|
||||||
|
port: 'PORT',
|
||||||
|
debug: 'boolean:DEBUG',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Persist data between runs
|
||||||
|
const kv = new KeyValueStore({ typeArg: 'userHomeDir', identityArg: 'myapp' });
|
||||||
|
await kv.writeKey('lastRun', Date.now());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Three Core Classes 🏗️
|
||||||
|
|
||||||
|
### 1. `Smartconfig` — Centralized Tool Configuration
|
||||||
|
|
||||||
|
Reads a `.smartconfig.json` file from a project directory and merges its contents with your defaults. One file, every tool.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Smartconfig } from '@push.rocks/smartconfig';
|
||||||
|
|
||||||
|
const sc = new Smartconfig(); // uses cwd
|
||||||
|
const sc2 = new Smartconfig('/my/project'); // or specify a path
|
||||||
|
|
||||||
|
const prettierConfig = sc.dataFor<PrettierConfig>('prettier', {
|
||||||
|
semi: false,
|
||||||
|
singleQuote: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**`.smartconfig.json`** example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sometool": {
|
"prettier": {
|
||||||
"defaultKey1": "awesomeValueFromConfig"
|
"semi": true,
|
||||||
|
"printWidth": 120
|
||||||
|
},
|
||||||
|
"eslint": {
|
||||||
|
"extends": "@company/eslint-config"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript
|
Values from the file override the defaults you pass in. Missing keys fall back to your defaults.
|
||||||
import { Npmextra } from 'npmextra';
|
|
||||||
|
|
||||||
let myNpmExtra = new Npmextra('my/path/to/cwd'); // cwd argument is optional
|
**Properties:**
|
||||||
mergedData = myNpmExtra.dataFor('sometool', {
|
- `smartconfigJsonExists: boolean` — whether `.smartconfig.json` was found
|
||||||
// gets merged with whatever is in the configfile
|
- `smartconfigJsonData: any` — the parsed JSON contents
|
||||||
defaultKey1: 'defaultValue1', // so this will get overwritten with "awesomeValueFromConfig"
|
|
||||||
defaultKey2: 'defaultValue2', // this one will pass through unaltered
|
**Methods:**
|
||||||
|
- `dataFor<T>(toolName: string, defaults: T): T` — returns merged config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `KeyValueStore<T>` — Persistent Data Storage
|
||||||
|
|
||||||
|
A generic, typed key-value store that persists JSON to disk (or stays in-memory for tests). Supports change detection via RxJS observables.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { KeyValueStore } from '@push.rocks/smartconfig';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
username: string;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in ~/.smartconfig/kv/
|
||||||
|
const kv = new KeyValueStore<Settings>({
|
||||||
|
typeArg: 'userHomeDir',
|
||||||
|
identityArg: 'myApp',
|
||||||
|
mandatoryKeys: ['username'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await kv.writeKey('username', 'jane');
|
||||||
|
await kv.writeKey('theme', 'dark');
|
||||||
|
|
||||||
|
const user = await kv.readKey('username'); // 'jane'
|
||||||
|
const all = await kv.readAll(); // { username: 'jane', theme: 'dark' }
|
||||||
|
|
||||||
|
// React to changes
|
||||||
|
kv.changeSubject.subscribe((data) => console.log('changed:', data));
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tools that already use the config feature of npmextra
|
**Storage types:**
|
||||||
|
|
||||||
- [npmts](https://www.npmjs.com/package/npmts)
|
| `typeArg` | Where it goes | Use case |
|
||||||
- [npmci](https://www.npmjs.com/package/npmci)
|
|-----------|--------------|----------|
|
||||||
- [npmdocker](https://www.npmjs.com/package/npmdocker)
|
| `'userHomeDir'` | `~/.smartconfig/kv/<identity>.json` | CLI tools, per-user state |
|
||||||
|
| `'custom'` | Your path (file or directory) | App data, project-local state |
|
||||||
|
| `'ephemeral'` | Memory only — nothing on disk | Tests |
|
||||||
|
|
||||||
## Using the KeyValueStore
|
**Methods:**
|
||||||
|
|
||||||
## Contribution
|
| Method | Description |
|
||||||
|
|--------|------------|
|
||||||
|
| `readKey(key)` | Read a single value |
|
||||||
|
| `writeKey(key, value)` | Write a single value |
|
||||||
|
| `readAll()` | Read everything |
|
||||||
|
| `writeAll(obj)` | Merge an object into the store |
|
||||||
|
| `deleteKey(key)` | Remove a key |
|
||||||
|
| `reset()` | Wipe all keys (synced to disk) |
|
||||||
|
| `wipe()` | Delete the backing file entirely |
|
||||||
|
| `getMissingMandatoryKeys()` | Returns keys declared mandatory but not yet set |
|
||||||
|
| `waitForKeysPresent(keys)` | Returns a Promise that resolves once all listed keys exist |
|
||||||
|
| `waitForAndGetKey(key)` | Waits for a key, then returns its value |
|
||||||
|
|
||||||
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
|
---
|
||||||
|
|
||||||
For further information read the linked docs at the top of this readme.
|
### 3. `AppData<T>` — Environment Variable Mapping 🌟
|
||||||
|
|
||||||
## Legal
|
The flagship class. Maps environment variables (or hardcoded values) into a typed config object with automatic type conversions, nested object support, and smart storage path selection.
|
||||||
> MIT licensed | **©** [Task Venture Capital GmbH](https://task.vc)
|
|
||||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
```typescript
|
||||||
|
import { AppData } from '@push.rocks/smartconfig';
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
features: {
|
||||||
|
analytics: boolean;
|
||||||
|
payment: boolean;
|
||||||
|
};
|
||||||
|
redis: {
|
||||||
|
host: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const appData = await AppData.createAndInit<Config>({
|
||||||
|
requiredKeys: ['apiKey'],
|
||||||
|
envMapping: {
|
||||||
|
apiUrl: 'API_URL', // plain env var
|
||||||
|
apiKey: 'hard:dev-key-123', // hardcoded fallback
|
||||||
|
features: {
|
||||||
|
analytics: 'boolean:ENABLE_ANALYTICS', // converts "true"/"false" → boolean
|
||||||
|
payment: 'hard_boolean:true', // hardcoded boolean
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: 'REDIS_HOST',
|
||||||
|
password: 'base64:REDIS_PASSWORD_B64', // base64-decode at load time
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overwriteObject: {
|
||||||
|
apiUrl: 'http://localhost:3000', // force override after env mapping
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await appData.getKvStore();
|
||||||
|
const url = await store.readKey('apiUrl');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mapping Prefixes
|
||||||
|
|
||||||
|
| Prefix | What it does | Example mapping | Result |
|
||||||
|
|--------|-------------|-----------------|--------|
|
||||||
|
| *(none)* | Raw env var as string | `'MY_VAR'` | `process.env.MY_VAR` |
|
||||||
|
| `hard:` | Hardcoded string | `'hard:hello'` | `"hello"` |
|
||||||
|
| `boolean:` | Env var → `true`/`false` | `'boolean:FLAG'` | `true` or `false` |
|
||||||
|
| `json:` | Env var → `JSON.parse()` | `'json:CONFIG'` | parsed object |
|
||||||
|
| `base64:` | Env var → base64 decode | `'base64:SECRET'` | decoded string |
|
||||||
|
| `hard_boolean:` | Hardcoded boolean | `'hard_boolean:false'` | `false` |
|
||||||
|
| `hard_json:` | Hardcoded JSON | `'hard_json:{"a":1}'` | `{ a: 1 }` |
|
||||||
|
| `hard_base64:` | Hardcoded base64 | `'hard_base64:SGVsbG8='` | `"Hello"` |
|
||||||
|
|
||||||
|
Suffix detection also works: a mapping ending in `_JSON` or `_BASE64` triggers the corresponding transform automatically.
|
||||||
|
|
||||||
|
#### Boolean Conversion Rules
|
||||||
|
|
||||||
|
The `boolean:` prefix (and `hard_boolean:`) recognizes:
|
||||||
|
|
||||||
|
- **true**: `"true"`, `"1"`, `"yes"`, `"y"`, `"on"` (case-insensitive)
|
||||||
|
- **false**: `"false"`, `"0"`, `"no"`, `"n"`, `"off"` (case-insensitive)
|
||||||
|
|
||||||
|
#### Nested Objects
|
||||||
|
|
||||||
|
Mapping values can be objects — they are resolved recursively:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
envMapping: {
|
||||||
|
database: {
|
||||||
|
host: 'DB_HOST',
|
||||||
|
port: 'hard:5432',
|
||||||
|
credentials: {
|
||||||
|
user: 'DB_USER',
|
||||||
|
password: 'base64:DB_PASS_B64',
|
||||||
|
ssl: 'boolean:DB_SSL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Smart Storage Path
|
||||||
|
|
||||||
|
When no `dirPath` is specified, AppData auto-selects:
|
||||||
|
|
||||||
|
1. `/app/data` — if it exists (containers)
|
||||||
|
2. `/data` — if it exists (alternate container path)
|
||||||
|
3. `.nogit/appdata` — local dev fallback
|
||||||
|
|
||||||
|
Or pass `ephemeral: true` for zero disk I/O (great for tests).
|
||||||
|
|
||||||
|
#### Static Helpers
|
||||||
|
|
||||||
|
Quick one-shot env var reads without creating an AppData instance:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isEnabled = await AppData.valueAsBoolean('FEATURE_FLAG');
|
||||||
|
const config = await AppData.valueAsJson<MyType>('CONFIG_JSON');
|
||||||
|
const secret = await AppData.valueAsBase64('ENCODED_SECRET');
|
||||||
|
const url = await AppData.valueAsString('API_URL');
|
||||||
|
const port = await AppData.valueAsNumber('PORT');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Instance Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|------------|
|
||||||
|
| `getKvStore()` | Returns the underlying `KeyValueStore<T>` |
|
||||||
|
| `logMissingKeys()` | Logs and returns any required keys that are missing |
|
||||||
|
| `waitForAndGetKey(key)` | Blocks until a key is present, then returns it |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security 🔐
|
||||||
|
|
||||||
|
AppData automatically redacts sensitive values in its console logs. Keys matching patterns like `secret`, `token`, `password`, `api`, `auth`, `jwt`, etc. are truncated. JWT tokens (starting with `eyJ`) are also detected and shortened. Your actual stored values are never modified — only log output is redacted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-World Example 🌍
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Smartconfig, AppData, KeyValueStore } from '@push.rocks/smartconfig';
|
||||||
|
|
||||||
|
interface CliConfig {
|
||||||
|
githubToken: string;
|
||||||
|
model: 'gpt-3' | 'gpt-4';
|
||||||
|
cache: { enabled: boolean; ttl: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool-level config from .smartconfig.json
|
||||||
|
const sc = new Smartconfig();
|
||||||
|
const toolDefaults = sc.dataFor('mycli', { defaultModel: 'gpt-3' });
|
||||||
|
|
||||||
|
// Env-mapped runtime config
|
||||||
|
const appData = await AppData.createAndInit<CliConfig>({
|
||||||
|
requiredKeys: ['githubToken'],
|
||||||
|
envMapping: {
|
||||||
|
githubToken: 'GITHUB_TOKEN',
|
||||||
|
model: 'hard:gpt-4',
|
||||||
|
cache: {
|
||||||
|
enabled: 'boolean:ENABLE_CACHE',
|
||||||
|
ttl: 'hard:3600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persistent user-level cache
|
||||||
|
const cache = new KeyValueStore({
|
||||||
|
typeArg: 'userHomeDir',
|
||||||
|
identityArg: 'mycli-cache',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check mandatory keys
|
||||||
|
const missing = await appData.logMissingKeys();
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error('Missing config — set these env vars and retry.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await appData.getKvStore();
|
||||||
|
const settings = await store.readAll();
|
||||||
|
console.log(`Model: ${settings.model}, Cache: ${settings.cache.enabled}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
|
|||||||
225
readme.plan.md
Normal file
225
readme.plan.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# AppData Refactoring Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Refactor the AppData class to improve elegance, maintainability, and extensibility while maintaining 100% backward compatibility.
|
||||||
|
|
||||||
|
## Current Issues
|
||||||
|
- 100+ lines of nested switch statements in processEnvMapping
|
||||||
|
- Static helpers recreate Qenv instances on every call
|
||||||
|
- Complex boolean conversion logic scattered across multiple places
|
||||||
|
- Typo: "ephermal" should be "ephemeral"
|
||||||
|
- Difficult to test and extend with new transformations
|
||||||
|
|
||||||
|
## Architecture Improvements
|
||||||
|
|
||||||
|
### 1. Singleton Qenv Provider
|
||||||
|
Create a shared Qenv instance to avoid repeated instantiation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Centralized Type Converters
|
||||||
|
Extract all conversion logic into pure utility functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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<T>(value: unknown): T | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64(value: unknown): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
return Buffer.from(String(value), 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Declarative Pipeline Architecture
|
||||||
|
|
||||||
|
Replace the giant switch statement with a composable pipeline:
|
||||||
|
|
||||||
|
#### Data Structures
|
||||||
|
```typescript
|
||||||
|
type MappingSpec = {
|
||||||
|
source:
|
||||||
|
| { type: 'env', key: string }
|
||||||
|
| { type: 'hard', value: string };
|
||||||
|
transforms: Transform[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transform = 'boolean' | 'json' | 'base64' | 'number';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pipeline Functions
|
||||||
|
```typescript
|
||||||
|
// Parse mapping string into spec
|
||||||
|
function parseMappingSpec(input: string): MappingSpec
|
||||||
|
|
||||||
|
// Resolve the source value
|
||||||
|
async function resolveSource(source: MappingSpec['source']): Promise<unknown>
|
||||||
|
|
||||||
|
// Apply transformations
|
||||||
|
function applyTransforms(value: unknown, transforms: Transform[]): unknown
|
||||||
|
|
||||||
|
// Complete pipeline
|
||||||
|
async function processMappingValue(mappingString: string): Promise<unknown>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Transform Registry
|
||||||
|
Enable easy extension with new transforms:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const transformRegistry: Record<string, (v: unknown) => unknown> = {
|
||||||
|
boolean: toBoolean,
|
||||||
|
json: toJson,
|
||||||
|
base64: fromBase64,
|
||||||
|
number: toNumber,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Simplified processEnvMapping
|
||||||
|
Build pure object tree first, then write to kvStore:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function evaluateMappingValue(mappingValue: any): Promise<any> {
|
||||||
|
if (typeof mappingValue === 'string') {
|
||||||
|
return processMappingValue(mappingValue);
|
||||||
|
}
|
||||||
|
if (mappingValue && typeof mappingValue === 'object') {
|
||||||
|
const out: any = {};
|
||||||
|
for (const [k, v] of Object.entries(mappingValue)) {
|
||||||
|
out[k] = await evaluateMappingValue(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main loop becomes:
|
||||||
|
for (const key in this.options.envMapping) {
|
||||||
|
const evaluated = await evaluateMappingValue(this.options.envMapping[key]);
|
||||||
|
if (evaluated !== undefined) {
|
||||||
|
await this.kvStore.writeKey(key as keyof T, evaluated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
### Supported Prefixes (Maintained)
|
||||||
|
- `hard:` - Hardcoded value
|
||||||
|
- `hard_boolean:` - Hardcoded boolean
|
||||||
|
- `hard_json:` - Hardcoded JSON
|
||||||
|
- `hard_base64:` - Hardcoded base64
|
||||||
|
- `boolean:` - Environment variable as boolean
|
||||||
|
- `json:` - Environment variable as JSON
|
||||||
|
- `base64:` - Environment variable as base64
|
||||||
|
|
||||||
|
### Supported Suffixes (Maintained)
|
||||||
|
- `_JSON` - Auto-parse as JSON
|
||||||
|
- `_BASE64` - Auto-decode from base64
|
||||||
|
|
||||||
|
### Typo Fix Strategy
|
||||||
|
- Add `ephemeral` option to interface
|
||||||
|
- Keep reading `ephermal` for backward compatibility
|
||||||
|
- Log deprecation warning when old spelling is used
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Add utility functions** at the top of the file
|
||||||
|
2. **Implement pipeline functions** (parseMappingSpec, resolveSource, applyTransforms)
|
||||||
|
3. **Refactor processEnvMapping** to use the pipeline
|
||||||
|
4. **Update static helpers** to use shared utilities
|
||||||
|
5. **Fix typo** with compatibility shim
|
||||||
|
6. **Add error boundaries** for better error reporting
|
||||||
|
7. **Test** to ensure backward compatibility
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- **70% reduction** in processEnvMapping complexity
|
||||||
|
- **Better separation** of concerns
|
||||||
|
- **Easier testing** - each function is pure and testable
|
||||||
|
- **Cleaner error handling** with boundaries
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Shared Qenv instance** reduces allocations
|
||||||
|
- **Optional parallelization** with Promise.all
|
||||||
|
- **Fewer repeated operations**
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
- **Extensible** - Easy to add new transforms
|
||||||
|
- **Readable** - Clear pipeline flow
|
||||||
|
- **Debuggable** - Each step can be logged
|
||||||
|
- **Type-safe** - Better TypeScript support
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit tests** for each utility function
|
||||||
|
2. **Integration tests** for the full pipeline
|
||||||
|
3. **Backward compatibility tests** for all existing prefixes/suffixes
|
||||||
|
4. **Edge case tests** for error conditions
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
With the transform registry, adding new features becomes trivial:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add YAML support
|
||||||
|
transformRegistry['yaml'] = (v) => YAML.parse(String(v));
|
||||||
|
|
||||||
|
// Add integer parsing
|
||||||
|
transformRegistry['int'] = (v) => parseInt(String(v), 10);
|
||||||
|
|
||||||
|
// Add custom transformers
|
||||||
|
transformRegistry['uppercase'] = (v) => String(v).toUpperCase();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. Implement new architecture alongside existing code
|
||||||
|
2. Gradually migrate internal usage
|
||||||
|
3. Mark old patterns as deprecated (with warnings)
|
||||||
|
4. Remove deprecated code in next major version
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- All existing tests pass
|
||||||
|
- No breaking changes for users
|
||||||
|
- Reduced code complexity (measurable via cyclomatic complexity)
|
||||||
|
- Improved test coverage
|
||||||
|
- Better performance (fewer allocations, optional parallelization)
|
||||||
5
test/.smartconfig.json
Normal file
5
test/.smartconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"testTool": {
|
||||||
|
"testValue": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"testTool":{
|
|
||||||
"testValue":2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
test/test.appdata.ts
Normal file
26
test/test.appdata.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
// module to test
|
||||||
|
import * as smartconfig from '../ts/index.js';
|
||||||
|
|
||||||
|
interface ITestOptions {
|
||||||
|
hi: string;
|
||||||
|
deep: {
|
||||||
|
deep1: string;
|
||||||
|
deep2: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let testAppdata: smartconfig.AppData<ITestOptions>;
|
||||||
|
|
||||||
|
tap.test('should create a valid AppData', async () => {
|
||||||
|
testAppdata = new smartconfig.AppData<ITestOptions>({
|
||||||
|
envMapping: {
|
||||||
|
deep: {
|
||||||
|
deep1: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
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 smartconfig 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 smartconfig.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 smartconfig.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 smartconfig.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 smartconfig.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();
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
import * as npmextra from '../ts/index.js';
|
import * as smartconfig from '../ts/index.js';
|
||||||
|
|
||||||
let myKeyValueStore: npmextra.KeyValueStore;
|
let myKeyValueStore: smartconfig.KeyValueStore<any>;
|
||||||
|
|
||||||
tap.test('should create a keyValueStore', async () => {
|
tap.test('should create a keyValueStore', async () => {
|
||||||
myKeyValueStore = new npmextra.KeyValueStore('custom', 'test', 'test/somekv.json');
|
myKeyValueStore = new smartconfig.KeyValueStore<any>({
|
||||||
expect(myKeyValueStore).toBeInstanceOf(npmextra.KeyValueStore);
|
typeArg: 'custom',
|
||||||
|
identityArg: 'test',
|
||||||
|
customPath: 'test/somekv.json',
|
||||||
|
});
|
||||||
|
expect(myKeyValueStore).toBeInstanceOf(smartconfig.KeyValueStore);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should reset the keyValueStore', async () => {
|
tap.test('should reset the keyValueStore', async () => {
|
||||||
@@ -25,4 +29,4 @@ tap.test('expect to add an object to the kv Store', async () => {
|
|||||||
await expect(await myKeyValueStore.readKey('myKey')).toEqual('myValue');
|
await expect(await myKeyValueStore.readKey('myKey')).toEqual('myValue');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.start();
|
export default tap.start();
|
||||||
|
|||||||
85
test/test.redaction.ts
Normal file
85
test/test.redaction.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as smartconfig from '../ts/index.js';
|
||||||
|
|
||||||
|
// Test that sensitive values are properly redacted in logs
|
||||||
|
tap.test('should redact sensitive values in console output', async () => {
|
||||||
|
// Capture console.log output
|
||||||
|
const originalLog = console.log;
|
||||||
|
const logOutput: string[] = [];
|
||||||
|
console.log = (...args: any[]) => {
|
||||||
|
logOutput.push(args.join(' '));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set up environment variables with sensitive data
|
||||||
|
process.env['API_KEY'] = 'super-secret-api-key-12345';
|
||||||
|
process.env['DATABASE_PASSWORD'] = 'myP@ssw0rd123';
|
||||||
|
process.env['AUTH_TOKEN'] = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ';
|
||||||
|
process.env['PUBLIC_URL'] = 'https://example.com';
|
||||||
|
process.env['DEBUG_MODE'] = 'true';
|
||||||
|
|
||||||
|
// Create AppData with sensitive environment mappings
|
||||||
|
const appData = await smartconfig.AppData.createAndInit({
|
||||||
|
ephemeral: true,
|
||||||
|
envMapping: {
|
||||||
|
apiKey: 'API_KEY',
|
||||||
|
dbPassword: 'DATABASE_PASSWORD',
|
||||||
|
authToken: 'AUTH_TOKEN',
|
||||||
|
publicUrl: 'PUBLIC_URL',
|
||||||
|
debugMode: 'boolean:DEBUG_MODE',
|
||||||
|
nestedConfig: {
|
||||||
|
secretKey: 'API_KEY',
|
||||||
|
nonSecret: 'PUBLIC_URL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore console.log
|
||||||
|
console.log = originalLog;
|
||||||
|
|
||||||
|
// Check that sensitive values were redacted in logs
|
||||||
|
const combinedOutput = logOutput.join('\n');
|
||||||
|
|
||||||
|
// API_KEY should be redacted
|
||||||
|
expect(combinedOutput).toContain('sup...[26 chars]');
|
||||||
|
expect(combinedOutput).not.toContain('super-secret-api-key-12345');
|
||||||
|
|
||||||
|
// DATABASE_PASSWORD should be redacted
|
||||||
|
expect(combinedOutput).toContain('myP...[13 chars]');
|
||||||
|
expect(combinedOutput).not.toContain('myP@ssw0rd123');
|
||||||
|
|
||||||
|
// AUTH_TOKEN should be redacted (JWT tokens starting with eyJ)
|
||||||
|
expect(combinedOutput).toContain('eyJ...[');
|
||||||
|
expect(combinedOutput).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
|
||||||
|
|
||||||
|
// PUBLIC_URL should not be redacted (not sensitive)
|
||||||
|
expect(combinedOutput).toContain('https://example.com');
|
||||||
|
|
||||||
|
// DEBUG_MODE should not be redacted (not sensitive)
|
||||||
|
expect(combinedOutput).toContain('true');
|
||||||
|
|
||||||
|
// Verify data is still stored correctly (not redacted in actual storage)
|
||||||
|
const kvStore = await appData.getKvStore();
|
||||||
|
const apiKey = await kvStore.readKey('apiKey');
|
||||||
|
const dbPassword = await kvStore.readKey('dbPassword');
|
||||||
|
const publicUrl = await kvStore.readKey('publicUrl');
|
||||||
|
|
||||||
|
// Actual values should be stored correctly
|
||||||
|
expect(apiKey).toEqual('super-secret-api-key-12345');
|
||||||
|
expect(dbPassword).toEqual('myP@ssw0rd123');
|
||||||
|
expect(publicUrl).toEqual('https://example.com');
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// Restore console.log in case of test failure
|
||||||
|
console.log = originalLog;
|
||||||
|
|
||||||
|
// Clean up environment variables
|
||||||
|
delete process.env['API_KEY'];
|
||||||
|
delete process.env['DATABASE_PASSWORD'];
|
||||||
|
delete process.env['AUTH_TOKEN'];
|
||||||
|
delete process.env['PUBLIC_URL'];
|
||||||
|
delete process.env['DEBUG_MODE'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
32
test/test.ts
32
test/test.ts
@@ -1,29 +1,31 @@
|
|||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
import path = require('path');
|
|
||||||
|
|
||||||
// module to test
|
// module to test
|
||||||
import * as npmextra from '../ts/index.js';
|
import * as smartconfig from '../ts/index.js';
|
||||||
|
|
||||||
let testNpmextra: npmextra.Npmextra;
|
let testSmartconfig: smartconfig.Smartconfig;
|
||||||
|
|
||||||
tap.test('should create a new Npmtextra instance', async () => {
|
tap.test('should create a new Smartconfig instance', async () => {
|
||||||
testNpmextra = new npmextra.Npmextra('./test/');
|
testSmartconfig = new smartconfig.Smartconfig('./test/');
|
||||||
expect(testNpmextra).toBeInstanceOf(npmextra.Npmextra);
|
expect(testSmartconfig).toBeInstanceOf(smartconfig.Smartconfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should state wether a npmextra.json exists', async () => {
|
tap.test('should state wether a smartconfig.json exists', async () => {
|
||||||
// tslint:disable-next-line:no-unused-expression
|
// tslint:disable-next-line:no-unused-expression
|
||||||
expect(testNpmextra.npmextraJsonExists).toBeTrue();
|
expect(testSmartconfig.smartconfigJsonExists).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should pass through default value, if not overriden by config from file', async () => {
|
tap.test(
|
||||||
let testData = testNpmextra.dataFor('testTool', { someKey2: 'someValue2' });
|
'should pass through default value, if not overriden by config from file',
|
||||||
console.log(testData);
|
async () => {
|
||||||
expect(testData).toHaveProperty('someKey2');
|
let testData = testSmartconfig.dataFor('testTool', { someKey2: 'someValue2' });
|
||||||
});
|
console.log(testData);
|
||||||
|
expect(testData).toHaveProperty('someKey2');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
tap.test('should read a config file', async () => {
|
tap.test('should read a config file', async () => {
|
||||||
let testData = testNpmextra.dataFor<any>('testTool', {
|
let testData = testSmartconfig.dataFor<any>('testTool', {
|
||||||
someKey2: 'someValue2',
|
someKey2: 'someValue2',
|
||||||
});
|
});
|
||||||
expect(testData).toHaveProperty('someKey2');
|
expect(testData).toHaveProperty('someKey2');
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* autocreated commitinfo by @pushrocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/npmextra',
|
name: '@push.rocks/smartconfig',
|
||||||
version: '5.0.4',
|
version: '6.1.0',
|
||||||
description: 'do more with npm'
|
description: 'A comprehensive configuration management library providing key-value storage, environment variable mapping, and tool configuration.'
|
||||||
}
|
}
|
||||||
|
|||||||
548
ts/classes.appdata.ts
Normal file
548
ts/classes.appdata.ts
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { KeyValueStore } from './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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security - Redaction for sensitive data
|
||||||
|
// ============================================================================
|
||||||
|
/**
|
||||||
|
* Redacts sensitive values in logs to prevent exposure of secrets
|
||||||
|
*/
|
||||||
|
function redactSensitiveValue(key: string, value: unknown): string {
|
||||||
|
// List of patterns that indicate sensitive data
|
||||||
|
const sensitivePatterns = [
|
||||||
|
/secret/i, /token/i, /key/i, /password/i, /pass/i,
|
||||||
|
/api/i, /credential/i, /auth/i, /private/i, /jwt/i,
|
||||||
|
/cert/i, /signature/i, /bearer/i
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if key contains sensitive pattern
|
||||||
|
const isSensitive = sensitivePatterns.some(pattern => pattern.test(key));
|
||||||
|
|
||||||
|
if (isSensitive) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Show first 3 chars and length for debugging
|
||||||
|
return value.length > 3
|
||||||
|
? `${value.substring(0, 3)}...[${value.length} chars]`
|
||||||
|
: '[redacted]';
|
||||||
|
}
|
||||||
|
return '[redacted]';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if value looks like a JWT token or base64 secret
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// JWT tokens start with eyJ
|
||||||
|
if (value.startsWith('eyJ')) {
|
||||||
|
return `eyJ...[${value.length} chars]`;
|
||||||
|
}
|
||||||
|
// Very long strings might be encoded secrets
|
||||||
|
if (value.length > 100) {
|
||||||
|
return `${value.substring(0, 50)}...[${value.length} chars total]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Converters - Centralized conversion logic
|
||||||
|
// ============================================================================
|
||||||
|
function toBoolean(value: unknown): boolean {
|
||||||
|
// 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 {
|
||||||
|
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<string, (v: unknown) => 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<unknown> {
|
||||||
|
if (source.type === 'hard') {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<unknown> {
|
||||||
|
const spec = parseMappingSpec(mappingString);
|
||||||
|
const keyName = spec.source.type === 'env' ? spec.source.key : 'hardcoded';
|
||||||
|
|
||||||
|
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: ${redactSensitiveValue(keyName, rawValue)} (type: ${typeof rawValue})`);
|
||||||
|
|
||||||
|
if (rawValue === undefined || rawValue === null) {
|
||||||
|
console.log(` ⚠️ Raw value is undefined/null, returning undefined`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = applyTransforms(rawValue, spec.transforms);
|
||||||
|
console.log(` Final value: ${redactSensitiveValue(keyName, 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)) {
|
||||||
|
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}" = ${redactSensitiveValue(key, evaluated)} (type: ${typeof evaluated})`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Nested key "${key}" evaluated to undefined, skipping`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For any other type (numbers, booleans, etc.), return as-is
|
||||||
|
// Note: We don't have key context here, so we'll just indicate the type
|
||||||
|
console.log(` 📎 Returning value as-is: [value] (type: ${typeof mappingValue})`);
|
||||||
|
return mappingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AppData Interface and Class
|
||||||
|
// ============================================================================
|
||||||
|
export interface IAppDataOptions<T = any> {
|
||||||
|
dirPath?: string;
|
||||||
|
requiredKeys?: Array<keyof T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>;
|
||||||
|
overwriteObject?: plugins.tsclass.typeFest.PartialDeep<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppData<T = any> {
|
||||||
|
/**
|
||||||
|
* creates appdata. If no pathArg is given, data will be stored here:
|
||||||
|
* ${PWD}/.nogit/appdata
|
||||||
|
* @param pathArg
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public static async createAndInit<T = any>(
|
||||||
|
optionsArg: IAppDataOptions<T> = {},
|
||||||
|
): Promise<AppData<T>> {
|
||||||
|
const appData = new AppData<T>(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<boolean> {
|
||||||
|
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<R = any>(envVarName: string): Promise<R | undefined> {
|
||||||
|
const value = await getQenv().getEnvVarOnDemand(envVarName);
|
||||||
|
return toJson<R>(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<string | undefined> {
|
||||||
|
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<string | undefined> {
|
||||||
|
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<number | undefined> {
|
||||||
|
const value = await getQenv().getEnvVarOnDemand(envVarName);
|
||||||
|
return toNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// instance
|
||||||
|
public readyDeferred = plugins.smartpromise.defer<void>();
|
||||||
|
public options: IAppDataOptions<T>;
|
||||||
|
private kvStore: KeyValueStore<T>;
|
||||||
|
|
||||||
|
constructor(optionsArg: IAppDataOptions<T> = {}) {
|
||||||
|
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<T>({
|
||||||
|
typeArg: isEphemeral ? 'ephemeral' : 'custom',
|
||||||
|
identityArg: 'appkv',
|
||||||
|
customPath: this.options.dirPath,
|
||||||
|
mandatoryKeys: this.options.requiredKeys as Array<keyof T>,
|
||||||
|
});
|
||||||
|
|
||||||
|
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];
|
||||||
|
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 = 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}`) :
|
||||||
|
redactSensitiveValue(key, evaluated);
|
||||||
|
console.log(` ✅ Successfully processed key "${key}" = ${valuePreview} (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<KeyValueStore<T>> {
|
||||||
|
await this.readyDeferred.promise;
|
||||||
|
return this.kvStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async logMissingKeys(): Promise<Array<keyof T>> {
|
||||||
|
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<K extends keyof T>(
|
||||||
|
keyArg: K,
|
||||||
|
): Promise<T[K] | undefined> {
|
||||||
|
await this.readyDeferred.promise;
|
||||||
|
await this.kvStore.waitForKeysPresent([keyArg]);
|
||||||
|
return this.kvStore.readKey(keyArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import * as plugins from './npmextra.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import * as paths from './npmextra.paths.js';
|
import * as paths from './paths.js';
|
||||||
|
|
||||||
import { Task } from '@push.rocks/taskbuffer';
|
import { Task } from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
export type TKeyValueStore = 'custom' | 'userHomeDir';
|
export type TKeyValueStore = 'custom' | 'userHomeDir' | 'ephemeral';
|
||||||
|
|
||||||
export interface IKvStoreConstructorOptions {
|
export interface IKvStoreConstructorOptions<T> {
|
||||||
typeArg: TKeyValueStore;
|
typeArg: TKeyValueStore;
|
||||||
identityArg: string;
|
identityArg: string;
|
||||||
customPath?: string;
|
customPath?: string;
|
||||||
mandatoryKeys?: string[];
|
mandatoryKeys?: Array<keyof T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,10 +17,10 @@ export interface IKvStoreConstructorOptions {
|
|||||||
*/
|
*/
|
||||||
export class KeyValueStore<T = any> {
|
export class KeyValueStore<T = any> {
|
||||||
private dataObject: Partial<T> = {};
|
private dataObject: Partial<T> = {};
|
||||||
private deletedObject: any = {};
|
private deletedObject: Partial<T> = {};
|
||||||
private mandatoryKeys: Set<string> = new Set();
|
private mandatoryKeys: Set<keyof T> = new Set();
|
||||||
public changeSubject = new plugins.smartrx.rxjs.Subject();
|
public changeSubject = new plugins.smartrx.rxjs.Subject<Partial<T>>();
|
||||||
|
|
||||||
private storedStateString: string = '';
|
private storedStateString: string = '';
|
||||||
public syncTask = new Task({
|
public syncTask = new Task({
|
||||||
name: 'syncTask',
|
name: 'syncTask',
|
||||||
@@ -28,19 +28,20 @@ export class KeyValueStore<T = any> {
|
|||||||
bufferMax: 1,
|
bufferMax: 1,
|
||||||
execDelay: 0,
|
execDelay: 0,
|
||||||
taskFunction: async () => {
|
taskFunction: async () => {
|
||||||
|
if (this.type !== 'ephemeral') {
|
||||||
this.dataObject = {
|
this.dataObject = {
|
||||||
...plugins.smartfile.fs.toObjectSync(this.filePath),
|
...plugins.smartfile.fs.toObjectSync(this.filePath),
|
||||||
...this.dataObject,
|
...this.dataObject,
|
||||||
};
|
};
|
||||||
for (const key of Object.keys(this.deletedObject)) {
|
for (const key of Object.keys(this.deletedObject) as Array<keyof T>) {
|
||||||
delete this.dataObject[key];
|
delete this.dataObject[key];
|
||||||
|
}
|
||||||
|
this.deletedObject = {};
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
plugins.smartjson.stringifyPretty(this.dataObject),
|
||||||
|
this.filePath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.deletedObject = {};
|
|
||||||
await plugins.smartfile.memory.toFs(
|
|
||||||
plugins.smartjson.stringifyPretty(this.dataObject),
|
|
||||||
this.filePath
|
|
||||||
);
|
|
||||||
const newStateString = plugins.smartjson.stringify(this.dataObject);
|
const newStateString = plugins.smartjson.stringify(this.dataObject);
|
||||||
|
|
||||||
// change detection
|
// change detection
|
||||||
@@ -55,12 +56,22 @@ export class KeyValueStore<T = any> {
|
|||||||
* computes the identity and filePath
|
* computes the identity and filePath
|
||||||
*/
|
*/
|
||||||
private initFilePath = () => {
|
private initFilePath = () => {
|
||||||
|
if (this.type === 'ephemeral') {
|
||||||
|
// No file path is needed for ephemeral type
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.customPath) {
|
if (this.customPath) {
|
||||||
// Use custom path if provided
|
// Use custom path if provided
|
||||||
const absolutePath = plugins.smartpath.transform.makeAbsolute(this.customPath, paths.cwd);
|
const absolutePath = plugins.smartpath.transform.makeAbsolute(
|
||||||
|
this.customPath,
|
||||||
|
paths.cwd,
|
||||||
|
);
|
||||||
this.filePath = absolutePath;
|
this.filePath = absolutePath;
|
||||||
if (plugins.smartfile.fs.isDirectorySync(this.filePath)) {
|
if (plugins.smartfile.fs.isDirectorySync(this.filePath)) {
|
||||||
this.filePath = plugins.path.join(this.filePath, this.identity + '.json');
|
this.filePath = plugins.path.join(
|
||||||
|
this.filePath,
|
||||||
|
this.identity + '.json',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
plugins.smartfile.fs.ensureFileSync(this.filePath, '{}');
|
plugins.smartfile.fs.ensureFileSync(this.filePath, '{}');
|
||||||
return;
|
return;
|
||||||
@@ -80,7 +91,7 @@ export class KeyValueStore<T = any> {
|
|||||||
// if no custom path is provided, try to store at home directory
|
// if no custom path is provided, try to store at home directory
|
||||||
public type: TKeyValueStore;
|
public type: TKeyValueStore;
|
||||||
public identity: string;
|
public identity: string;
|
||||||
public filePath: string;
|
public filePath?: string;
|
||||||
private customPath?: string; // Optionally allow custom path
|
private customPath?: string; // Optionally allow custom path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,7 +100,7 @@ export class KeyValueStore<T = any> {
|
|||||||
* @param identityArg
|
* @param identityArg
|
||||||
* @param customPath Optional custom path for the keyValue store
|
* @param customPath Optional custom path for the keyValue store
|
||||||
*/
|
*/
|
||||||
constructor(optionsArg: IKvStoreConstructorOptions) {
|
constructor(optionsArg: IKvStoreConstructorOptions<T>) {
|
||||||
if (optionsArg.customPath && optionsArg.typeArg !== 'custom') {
|
if (optionsArg.customPath && optionsArg.typeArg !== 'custom') {
|
||||||
throw new Error('customPath can only be provided if typeArg is custom');
|
throw new Error('customPath can only be provided if typeArg is custom');
|
||||||
}
|
}
|
||||||
@@ -108,7 +119,7 @@ export class KeyValueStore<T = any> {
|
|||||||
/**
|
/**
|
||||||
* reads all keyValue pairs at once and returns them
|
* reads all keyValue pairs at once and returns them
|
||||||
*/
|
*/
|
||||||
public async readAll() {
|
public async readAll(): Promise<Partial<T>> {
|
||||||
await this.syncTask.trigger();
|
await this.syncTask.trigger();
|
||||||
return this.dataObject;
|
return this.dataObject;
|
||||||
}
|
}
|
||||||
@@ -116,21 +127,24 @@ export class KeyValueStore<T = any> {
|
|||||||
/**
|
/**
|
||||||
* reads a keyValueFile from disk
|
* reads a keyValueFile from disk
|
||||||
*/
|
*/
|
||||||
public async readKey(keyArg: string) {
|
public async readKey<K extends keyof T>(keyArg: K): Promise<T[K]> {
|
||||||
await this.syncTask.trigger();
|
await this.syncTask.trigger();
|
||||||
return this.dataObject[keyArg];
|
return this.dataObject[keyArg] as T[K];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* writes a specific key to the keyValueStore
|
* writes a specific key to the keyValueStore
|
||||||
*/
|
*/
|
||||||
public async writeKey(keyArg: string, valueArg: any) {
|
public async writeKey<K extends keyof T>(
|
||||||
|
keyArg: K,
|
||||||
|
valueArg: T[K],
|
||||||
|
): Promise<void> {
|
||||||
await this.writeAll({
|
await this.writeAll({
|
||||||
[keyArg]: valueArg,
|
[keyArg]: valueArg,
|
||||||
});
|
} as unknown as Partial<T>);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteKey(keyArg: string) {
|
public async deleteKey<K extends keyof T>(keyArg: K): Promise<void> {
|
||||||
this.deletedObject[keyArg] = this.dataObject[keyArg];
|
this.deletedObject[keyArg] = this.dataObject[keyArg];
|
||||||
await this.syncTask.trigger();
|
await this.syncTask.trigger();
|
||||||
}
|
}
|
||||||
@@ -138,7 +152,7 @@ export class KeyValueStore<T = any> {
|
|||||||
/**
|
/**
|
||||||
* writes all keyValue pairs in the object argument
|
* writes all keyValue pairs in the object argument
|
||||||
*/
|
*/
|
||||||
public async writeAll(keyValueObject: { [key: string]: any }) {
|
public async writeAll(keyValueObject: Partial<T>): Promise<void> {
|
||||||
this.dataObject = { ...this.dataObject, ...keyValueObject };
|
this.dataObject = { ...this.dataObject, ...keyValueObject };
|
||||||
await this.syncTask.trigger();
|
await this.syncTask.trigger();
|
||||||
}
|
}
|
||||||
@@ -146,19 +160,21 @@ export class KeyValueStore<T = any> {
|
|||||||
/**
|
/**
|
||||||
* wipes a key value store from disk
|
* wipes a key value store from disk
|
||||||
*/
|
*/
|
||||||
public async wipe() {
|
public async wipe(): Promise<void> {
|
||||||
this.dataObject = {};
|
this.dataObject = {};
|
||||||
await plugins.smartfile.fs.remove(this.filePath);
|
if (this.type !== 'ephemeral') {
|
||||||
|
await plugins.smartfile.fs.remove(this.filePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* resets the KeyValueStore to the initial state by syncing first, deleting all keys, and then triggering a sync again
|
* resets the KeyValueStore to the initial state by syncing first, deleting all keys, and then triggering a sync again
|
||||||
*/
|
*/
|
||||||
public async reset() {
|
public async reset(): Promise<void> {
|
||||||
await this.syncTask.trigger(); // Sync to get the latest state
|
await this.syncTask.trigger(); // Sync to get the latest state
|
||||||
|
|
||||||
// Delete all keys from the dataObject and add them to deletedObject
|
// Delete all keys from the dataObject and add them to deletedObject
|
||||||
for (const key of Object.keys(this.dataObject)) {
|
for (const key of Object.keys(this.dataObject) as Array<keyof T>) {
|
||||||
this.deletedObject[key] = this.dataObject[key];
|
this.deletedObject[key] = this.dataObject[key];
|
||||||
delete this.dataObject[key];
|
delete this.dataObject[key];
|
||||||
}
|
}
|
||||||
@@ -166,22 +182,29 @@ export class KeyValueStore<T = any> {
|
|||||||
await this.syncTask.trigger(); // Sync again to reflect the deletion
|
await this.syncTask.trigger(); // Sync again to reflect the deletion
|
||||||
}
|
}
|
||||||
|
|
||||||
private setMandatoryKeys(keys: string[]) {
|
private setMandatoryKeys(keys: Array<keyof T>) {
|
||||||
keys.forEach(key => this.mandatoryKeys.add(key));
|
keys.forEach((key) => this.mandatoryKeys.add(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMissingMandatoryKeys(): string[] {
|
public async getMissingMandatoryKeys(): Promise<Array<keyof T>> {
|
||||||
return Array.from(this.mandatoryKeys).filter(key => !(key in this.dataObject));
|
await this.readAll();
|
||||||
|
return Array.from(this.mandatoryKeys).filter(
|
||||||
|
(key) => !(key in this.dataObject),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async waitForKeysPresent(keysArg: string[]): Promise<void> {
|
public async waitForKeysPresent<K extends keyof T>(
|
||||||
const missingKeys = keysArg.filter(keyArg => !this.dataObject[keyArg]);
|
keysArg: K[],
|
||||||
|
): Promise<void> {
|
||||||
|
const missingKeys = keysArg.filter((keyArg) => !this.dataObject[keyArg]);
|
||||||
if (missingKeys.length === 0) {
|
if (missingKeys.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const subscription = this.changeSubject.subscribe(() => {
|
const subscription = this.changeSubject.subscribe(() => {
|
||||||
const missingKeys = keysArg.filter(keyArg => !this.dataObject[keyArg]);
|
const missingKeys = keysArg.filter(
|
||||||
|
(keyArg) => !this.dataObject[keyArg],
|
||||||
|
);
|
||||||
if (missingKeys.length === 0) {
|
if (missingKeys.length === 0) {
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
resolve();
|
resolve();
|
||||||
@@ -189,4 +212,11 @@ export class KeyValueStore<T = any> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async waitForAndGetKey<K extends keyof T>(
|
||||||
|
keyArg: K,
|
||||||
|
): Promise<T[K] | undefined> {
|
||||||
|
await this.waitForKeysPresent([keyArg]);
|
||||||
|
return this.readKey(keyArg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
79
ts/classes.smartconfig.ts
Normal file
79
ts/classes.smartconfig.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as paths from './paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smartconfig class allows easy configuration of tools
|
||||||
|
*/
|
||||||
|
export class Smartconfig {
|
||||||
|
cwd: string;
|
||||||
|
lookupPath: string;
|
||||||
|
smartconfigJsonExists: boolean;
|
||||||
|
smartconfigJsonData: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* creates instance of Smartconfig
|
||||||
|
*/
|
||||||
|
constructor(cwdArg?: string) {
|
||||||
|
if (cwdArg) {
|
||||||
|
this.cwd = cwdArg;
|
||||||
|
} else {
|
||||||
|
this.cwd = paths.cwd;
|
||||||
|
}
|
||||||
|
this.checkLookupPath();
|
||||||
|
this.checkSmartconfigJsonExists();
|
||||||
|
this.checkSmartconfigJsonData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* merges the supplied options with the ones from smartconfig.json
|
||||||
|
*/
|
||||||
|
dataFor<IToolConfig>(
|
||||||
|
toolnameArg: string,
|
||||||
|
defaultOptionsArg: any,
|
||||||
|
): IToolConfig {
|
||||||
|
let smartconfigToolOptions;
|
||||||
|
if (this.smartconfigJsonData[toolnameArg]) {
|
||||||
|
smartconfigToolOptions = this.smartconfigJsonData[toolnameArg];
|
||||||
|
} else {
|
||||||
|
smartconfigToolOptions = {};
|
||||||
|
}
|
||||||
|
let mergedOptions = {
|
||||||
|
...defaultOptionsArg,
|
||||||
|
...smartconfigToolOptions,
|
||||||
|
};
|
||||||
|
return mergedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks if the JSON exists
|
||||||
|
*/
|
||||||
|
private checkSmartconfigJsonExists() {
|
||||||
|
this.smartconfigJsonExists = plugins.smartfile.fs.fileExistsSync(
|
||||||
|
this.lookupPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets lookupPath
|
||||||
|
*/
|
||||||
|
private checkLookupPath() {
|
||||||
|
if (this.cwd) {
|
||||||
|
this.lookupPath = plugins.path.join(this.cwd, '.smartconfig.json');
|
||||||
|
} else {
|
||||||
|
this.lookupPath = paths.configFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get smartconfigJsonData
|
||||||
|
*/
|
||||||
|
private checkSmartconfigJsonData() {
|
||||||
|
if (this.smartconfigJsonExists) {
|
||||||
|
this.smartconfigJsonData = plugins.smartfile.fs.toObjectSync(
|
||||||
|
this.lookupPath,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.smartconfigJsonData = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './npmextra.classes.appdata.js';
|
export * from './classes.appdata.js';
|
||||||
export * from './npmextra.classes.keyvaluestore.js';
|
export * from './classes.keyvaluestore.js';
|
||||||
export * from './npmextra.classes.npmextra.js';
|
export * from './classes.smartconfig.js';
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import * as plugins from './npmextra.plugins.js';
|
|
||||||
import * as paths from './npmextra.paths.js';
|
|
||||||
import { KeyValueStore } from './npmextra.classes.keyvaluestore.js';
|
|
||||||
import { env } from 'process';
|
|
||||||
|
|
||||||
export interface IAppDataOptions {
|
|
||||||
dirPath?: string;
|
|
||||||
requiredKeys?: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* kvStoreKey: 'MY_ENV_VAR'
|
|
||||||
*/
|
|
||||||
envMapping?: {
|
|
||||||
[key: string]: string | object;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AppData<T = any> {
|
|
||||||
/**
|
|
||||||
* creates appdata. If no pathArg is given, data will be stored here:
|
|
||||||
* ${PWD}/.nogit/appdata
|
|
||||||
* @param pathArg
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public static async createAndInit<T = any>(optionsArg: IAppDataOptions = {}): Promise<AppData<T>> {
|
|
||||||
const appData = new AppData<T>(optionsArg);
|
|
||||||
await appData.readyDeferred.promise;
|
|
||||||
return appData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// instance
|
|
||||||
public readyDeferred = plugins.smartpromise.defer();
|
|
||||||
public options: IAppDataOptions;
|
|
||||||
private kvStore: KeyValueStore<T>;
|
|
||||||
constructor(optionsArg: IAppDataOptions = {}) {
|
|
||||||
this.options = optionsArg;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* inits app data
|
|
||||||
* @param pathArg
|
|
||||||
*/
|
|
||||||
private async init(pathArg?: string) {
|
|
||||||
if (this.options.dirPath) {
|
|
||||||
// ok, nothing to do here;
|
|
||||||
} 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;
|
|
||||||
} else if (dataExists) {
|
|
||||||
this.options.dirPath = dataDir;
|
|
||||||
} else {
|
|
||||||
await plugins.smartfile.fs.ensureDir(nogitAppData);
|
|
||||||
this.options.dirPath = nogitAppData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.kvStore = new KeyValueStore({
|
|
||||||
typeArg: 'custom',
|
|
||||||
identityArg: 'appkv',
|
|
||||||
customPath: this.options.dirPath,
|
|
||||||
mandatoryKeys: this.options.requiredKeys
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.options.envMapping) {
|
|
||||||
const qenvInstance = new plugins.qenv.Qenv();
|
|
||||||
|
|
||||||
// Recursive function to handle nested objects, now includes key parameter
|
|
||||||
const processEnvMapping = async (key: string, mappingValue: any, parentKey: string = ''): Promise<any> => {
|
|
||||||
if (typeof mappingValue === 'string') {
|
|
||||||
let envValue = await qenvInstance.getEnvVarOnDemand(mappingValue);
|
|
||||||
if (envValue) {
|
|
||||||
if (mappingValue.endsWith('_JSON')) {
|
|
||||||
envValue = JSON.parse(envValue);
|
|
||||||
}
|
|
||||||
// Determine the correct key to use (top-level or nested)
|
|
||||||
const effectiveKey = parentKey || key;
|
|
||||||
this.kvStore.writeKey(effectiveKey, envValue);
|
|
||||||
}
|
|
||||||
} else if (typeof mappingValue === 'object' && mappingValue !== null) {
|
|
||||||
const resultObject = {};
|
|
||||||
for (const innerKey in mappingValue) {
|
|
||||||
const nestedValue = mappingValue[innerKey];
|
|
||||||
// For nested objects, call recursively but do not immediately write to kvStore
|
|
||||||
const nestedResult = await processEnvMapping(innerKey, nestedValue, key);
|
|
||||||
resultObject[innerKey] = nestedResult;
|
|
||||||
}
|
|
||||||
if (parentKey === '') {
|
|
||||||
// Only write to kvStore if at the top level
|
|
||||||
this.kvStore.writeKey(key, resultObject);
|
|
||||||
} else {
|
|
||||||
// For nested objects, return the constructed object instead of writing to kvStore
|
|
||||||
return resultObject;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key in this.options.envMapping) {
|
|
||||||
await processEnvMapping(key, this.options.envMapping[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.readyDeferred.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns a kvtore that resides in appdata
|
|
||||||
*/
|
|
||||||
public async getKvStore() {
|
|
||||||
await this.readyDeferred.promise;
|
|
||||||
return this.kvStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async logMissingKeys() {
|
|
||||||
const kvStore = await this.getKvStore();
|
|
||||||
const missingMandatoryKeys = 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async waitForAndGetKey(keyArg: string) {
|
|
||||||
await this.readyDeferred.promise;
|
|
||||||
await this.kvStore.waitForKeysPresent([keyArg]);
|
|
||||||
return this.kvStore.readKey[keyArg];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import * as plugins from './npmextra.plugins.js';
|
|
||||||
import * as paths from './npmextra.paths.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Npmextra class allows easy configuration of tools
|
|
||||||
*/
|
|
||||||
export class Npmextra {
|
|
||||||
cwd: string;
|
|
||||||
lookupPath: string;
|
|
||||||
npmextraJsonExists: boolean;
|
|
||||||
npmextraJsonData: any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* creates instance of Npmextra
|
|
||||||
*/
|
|
||||||
constructor(cwdArg?: string) {
|
|
||||||
if (cwdArg) {
|
|
||||||
this.cwd = cwdArg;
|
|
||||||
} else {
|
|
||||||
this.cwd = paths.cwd;
|
|
||||||
}
|
|
||||||
this.checkLookupPath();
|
|
||||||
this.checkNpmextraJsonExists();
|
|
||||||
this.checkNpmextraJsonData();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* merges the supplied options with the ones from npmextra.json
|
|
||||||
*/
|
|
||||||
dataFor<IToolConfig>(toolnameArg: string, defaultOptionsArg: any): IToolConfig {
|
|
||||||
let npmextraToolOptions;
|
|
||||||
if (this.npmextraJsonData[toolnameArg]) {
|
|
||||||
npmextraToolOptions = this.npmextraJsonData[toolnameArg];
|
|
||||||
} else {
|
|
||||||
npmextraToolOptions = {};
|
|
||||||
}
|
|
||||||
let mergedOptions = {
|
|
||||||
...defaultOptionsArg,
|
|
||||||
...npmextraToolOptions,
|
|
||||||
};
|
|
||||||
return mergedOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* checks if the JSON exists
|
|
||||||
*/
|
|
||||||
private checkNpmextraJsonExists() {
|
|
||||||
this.npmextraJsonExists = plugins.smartfile.fs.fileExistsSync(this.lookupPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* gets lookupPath
|
|
||||||
*/
|
|
||||||
private checkLookupPath() {
|
|
||||||
if (this.cwd) {
|
|
||||||
this.lookupPath = plugins.path.join(this.cwd, 'npmextra.json');
|
|
||||||
} else {
|
|
||||||
this.lookupPath = paths.configFile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get npmextraJsonData
|
|
||||||
*/
|
|
||||||
private checkNpmextraJsonData() {
|
|
||||||
if (this.npmextraJsonExists) {
|
|
||||||
this.npmextraJsonData = plugins.smartfile.fs.toObjectSync(this.lookupPath);
|
|
||||||
} else {
|
|
||||||
this.npmextraJsonData = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as plugins from './npmextra.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
// directories
|
// directories
|
||||||
export let cwd = process.cwd();
|
export let cwd = process.cwd();
|
||||||
export let packageDir = plugins.path.join(
|
export let packageDir = plugins.path.join(
|
||||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
'../'
|
'../',
|
||||||
);
|
);
|
||||||
|
|
||||||
// ----------------------
|
// ----------------------
|
||||||
@@ -16,7 +16,7 @@ export let home = plugins.smartpath.get.home();
|
|||||||
/**
|
/**
|
||||||
* keyValue base path
|
* keyValue base path
|
||||||
*/
|
*/
|
||||||
export let kvUserHomeDirBase = plugins.path.join(home, '.npmextra/kv');
|
export let kvUserHomeDirBase = plugins.path.join(home, '.smartconfig/kv');
|
||||||
|
|
||||||
// files
|
// files
|
||||||
export let configFile = plugins.path.join(cwd, 'npmextra.json');
|
export let configFile = plugins.path.join(cwd, '.smartconfig.json');
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
|
||||||
|
export { tsclass };
|
||||||
|
|
||||||
import * as qenv from '@push.rocks/qenv';
|
import * as qenv from '@push.rocks/qenv';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -8,4 +12,14 @@ import * as smartpromise from '@push.rocks/smartpromise';
|
|||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
|
||||||
export { qenv, smartlog, path, smartfile, smartjson, smartpath, smartpromise, smartrx, taskbuffer };
|
export {
|
||||||
|
qenv,
|
||||||
|
smartlog,
|
||||||
|
path,
|
||||||
|
smartfile,
|
||||||
|
smartjson,
|
||||||
|
smartpath,
|
||||||
|
smartpromise,
|
||||||
|
smartrx,
|
||||||
|
taskbuffer,
|
||||||
|
};
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["dist_*/**/*.d.ts"]
|
||||||
"dist_*/**/*.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user