Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
@@ -6,8 +6,8 @@ on:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
|
||||
- name: Run npm prepare
|
||||
run: npmci npm prepare
|
||||
|
@@ -6,8 +6,8 @@ on:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Release
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
pnpm install -g @ship.zone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Code quality
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,7 +3,6 @@
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
@@ -17,4 +16,4 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# custom
|
||||
#------# custom
|
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl
vendored
Normal file
Binary file not shown.
32
.serena/memories/coding_standards.md
Normal file
32
.serena/memories/coding_standards.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Coding Standards for npmextra
|
||||
|
||||
## Naming Conventions
|
||||
- **Interfaces**: Prefix with `I` (e.g., `IAppDataOptions`, `ITestOptions`)
|
||||
- **Types**: Prefix with `T` (e.g., `TKeyValueStore`)
|
||||
- **Filenames**: Always lowercase (e.g., `npmextra.classes.appdata.ts`)
|
||||
- **Module structure**: `npmextra.<type>.<name>.ts` pattern
|
||||
|
||||
## Import/Export Patterns
|
||||
- Use ES module syntax (`import`/`export`)
|
||||
- Import all dependencies through `npmextra.plugins.ts`
|
||||
- Reference with full path: `plugins.moduleName.className()`
|
||||
- Export all public APIs through `index.ts`
|
||||
|
||||
## TypeScript Patterns
|
||||
- Use generic types for flexibility (`<T = any>`)
|
||||
- Leverage TypeScript utility types from `@tsclass/tsclass`
|
||||
- Use async/await for asynchronous operations
|
||||
- Use Promises with smartpromise utilities
|
||||
|
||||
## Testing Standards
|
||||
- Import expect from `@git.zone/tstest/tapbundle`
|
||||
- Test files end with `export default tap.start()`
|
||||
- Use descriptive test names with `tap.test()`
|
||||
- Test file naming: `test.*.ts` pattern
|
||||
|
||||
## Code Quality
|
||||
- Make focused, goal-oriented changes
|
||||
- Preserve necessary complexity
|
||||
- Remove redundancy carefully
|
||||
- Keep async patterns where they add value
|
||||
- No comments unless explicitly requested
|
30
.serena/memories/project_overview.md
Normal file
30
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# npmextra Project Overview
|
||||
|
||||
## Purpose
|
||||
npmextra is a utility library that enhances npm with additional configuration and tool management capabilities. It provides a key-value store for project setups and centralized configuration management through npmextra.json files.
|
||||
|
||||
## Tech Stack
|
||||
- TypeScript (ES modules)
|
||||
- Node.js
|
||||
- Dependencies:
|
||||
- @push.rocks/qenv - Environment variable management
|
||||
- @push.rocks/smartfile - File system operations
|
||||
- @push.rocks/smartjson - JSON handling
|
||||
- @push.rocks/smartlog - Logging
|
||||
- @push.rocks/smartpath - Path utilities
|
||||
- @push.rocks/smartpromise - Promise utilities
|
||||
- @push.rocks/smartrx - Reactive programming
|
||||
- @push.rocks/taskbuffer - Task management
|
||||
- @tsclass/tsclass - TypeScript utilities
|
||||
|
||||
## Main Components
|
||||
1. **Npmextra** - Main class for managing npmextra.json configurations
|
||||
2. **KeyValueStore** - Persistent key-value storage system
|
||||
3. **AppData** - Advanced data management with environment variable mapping
|
||||
|
||||
## Project Structure
|
||||
- `ts/` - TypeScript source files
|
||||
- `test/` - Test files using @git.zone/tstest
|
||||
- `dist_ts/` - Compiled JavaScript output
|
||||
- `npmextra.json` - Project configuration
|
||||
- `package.json` - Node.js package configuration
|
31
.serena/memories/suggested_commands.md
Normal file
31
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Suggested Commands for npmextra Development
|
||||
|
||||
## Build Commands
|
||||
- `pnpm run build` - Build the TypeScript project (uses tsbuild)
|
||||
- `pnpm test` - Run tests using tstest
|
||||
|
||||
## Development Commands
|
||||
- `pnpm install` - Install dependencies
|
||||
- `pnpm install --save-dev <package>` - Add development dependencies
|
||||
- `tsx <script>` - Run TypeScript files directly (tsx is globally available)
|
||||
|
||||
## Git Commands
|
||||
- `git status` - Check current changes
|
||||
- `git add .` - Stage changes
|
||||
- `git commit -m "message"` - Commit changes (only when explicitly requested)
|
||||
- `git mv` - Move/rename files to preserve history
|
||||
|
||||
## Testing
|
||||
- `pnpm test` - Run all tests
|
||||
- `tstest test/test.some.ts --verbose` - Run specific test file
|
||||
- Tests use @git.zone/tstest framework with tap-based structure
|
||||
|
||||
## Type Checking
|
||||
- `pnpm run build` - Type check and build the project
|
||||
- `tsbuild check test/**/* --skiplibcheck` - Type check test files only
|
||||
|
||||
## Directory Structure
|
||||
- Source code in `ts/` directory
|
||||
- Tests in `test/` directory
|
||||
- Built output in `dist_ts/` directory
|
||||
- Temporary/debug files in `.nogit/` directory
|
68
.serena/project.yml
Normal file
68
.serena/project.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "npmextra"
|
132
changelog.md
Normal file
132
changelog.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-08-15 - 5.3.1 - fix(AppData/Conversion)
|
||||
Improve boolean conversion and mapping evaluation in AppData, ensuring falsy values (like false, 0, and empty strings) are correctly handled and logged. Also, reduce test timeout and add local permissions settings for development.
|
||||
|
||||
- Enhanced toBoolean and evaluateMappingValue functions to properly preserve and log falsy values.
|
||||
- Added detailed logging for mapping spec processing and nested key evaluations.
|
||||
- Reduced test timeout in package.json for faster CI feedback.
|
||||
- Introduced .claude/settings.local.json with updated permissions for local development.
|
||||
|
||||
## 2025-08-15 - 5.3.0 - feat(AppData)
|
||||
Refactor AppData class for declarative env mapping and enhanced static helpers
|
||||
|
||||
- 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
|
@@ -30,7 +30,9 @@
|
||||
"automation",
|
||||
"async operations",
|
||||
"app configuration",
|
||||
"smart file handling"
|
||||
"smart file handling",
|
||||
"workflow improvement",
|
||||
"persistent storage"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
42
package.json
42
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@push.rocks/npmextra",
|
||||
"version": "5.0.16",
|
||||
"version": "5.3.1",
|
||||
"private": false,
|
||||
"description": "A utility to enhance npm with additional configuration, tool management capabilities, and a key-value store for project setups.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --web)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 20)",
|
||||
"build": "(tsbuild --web --allowimplicitany)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
@@ -17,25 +17,25 @@
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/pushrocks/npmextra/issues"
|
||||
"url": "https://code.foss.global/push.rocks/npmextra/issues"
|
||||
},
|
||||
"homepage": "https://code.foss.global/push.rocks/npmextra",
|
||||
"homepage": "https://code.foss.global/push.rocks/npmextra#readme",
|
||||
"dependencies": {
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@push.rocks/smartfile": "^11.0.20",
|
||||
"@push.rocks/qenv": "^6.1.2",
|
||||
"@push.rocks/smartfile": "^11.2.5",
|
||||
"@push.rocks/smartjson": "^5.0.20",
|
||||
"@push.rocks/smartlog": "^3.0.7",
|
||||
"@push.rocks/smartpath": "^5.0.18",
|
||||
"@push.rocks/smartpromise": "^4.0.2",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/taskbuffer": "^3.1.7"
|
||||
"@push.rocks/smartlog": "^3.1.8",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/taskbuffer": "^3.1.7",
|
||||
"@tsclass/tsclass": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.80",
|
||||
"@git.zone/tsrun": "^1.2.44",
|
||||
"@git.zone/tstest": "^1.0.90",
|
||||
"@push.rocks/tapbundle": "^5.0.23",
|
||||
"@types/node": "^20.14.2"
|
||||
"@git.zone/tsbuild": "^2.6.4",
|
||||
"@git.zone/tsrun": "^1.3.3",
|
||||
"@git.zone/tstest": "^2.3.2",
|
||||
"@types/node": "^22"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@@ -66,6 +66,12 @@
|
||||
"automation",
|
||||
"async operations",
|
||||
"app configuration",
|
||||
"smart file handling"
|
||||
]
|
||||
"smart file handling",
|
||||
"workflow improvement",
|
||||
"persistent storage"
|
||||
],
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
|
||||
"pnpm": {
|
||||
"overrides": {}
|
||||
}
|
||||
}
|
||||
|
7744
pnpm-lock.yaml
generated
7744
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
|
624
readme.md
624
readme.md
@@ -1,295 +1,501 @@
|
||||
# @push.rocks/npmextra
|
||||
Enhances npm with additional configuration and tool management capabilities, including a key-value store for project setups.
|
||||
# @push.rocks/npmextra 🚀
|
||||
|
||||
## Install
|
||||
To install `@push.rocks/npmextra`, use the following npm command:
|
||||
**Supercharge your npm projects with powerful configuration management, tool orchestration, and persistent key-value storage.**
|
||||
|
||||
## Install 📦
|
||||
|
||||
```bash
|
||||
# Using npm
|
||||
npm install @push.rocks/npmextra --save
|
||||
|
||||
# Using pnpm (recommended)
|
||||
pnpm add @push.rocks/npmextra
|
||||
```
|
||||
|
||||
This package is available on [npm](https://www.npmjs.com/package/@push.rocks/npmextra) and can be installed into your project as a dependency to enhance npm with additional configuration and tool management capabilities.
|
||||
## Overview 🎯
|
||||
|
||||
## Usage
|
||||
`@push.rocks/npmextra` is designed to supplement npm functionalities with enhanced configuration and tool management. It facilitates the management of project configurations and tool setups in a consolidated manner, enabling a smoother workflow and maintenance process. Below are detailed use cases and examples implemented with ESM syntax and TypeScript.
|
||||
`@push.rocks/npmextra` is your Swiss Army knife for npm project configuration. It eliminates configuration sprawl by centralizing tool settings, providing intelligent key-value storage, and offering powerful environment variable mapping with automatic type conversions.
|
||||
|
||||
### Initial Setup and Configuration
|
||||
To start using `npmextra` in your project, first include it with an import statement:
|
||||
### Why npmextra?
|
||||
|
||||
- **🎛️ Centralized Configuration**: Manage all your tool configs in one `npmextra.json` file
|
||||
- **💾 Persistent Storage**: Smart key-value store with multiple storage strategies
|
||||
- **🔐 Environment Mapping**: Sophisticated env var handling with automatic type conversion
|
||||
- **🏗️ TypeScript First**: Full type safety and IntelliSense support
|
||||
- **⚡ Zero Config**: Works out of the box with sensible defaults
|
||||
- **🔄 Reactive**: Built-in change detection and observables
|
||||
|
||||
## Core Concepts 🏗️
|
||||
|
||||
### 1. Npmextra Configuration Management
|
||||
|
||||
Stop scattering configuration across dozens of files. Centralize everything in `npmextra.json`:
|
||||
|
||||
```typescript
|
||||
import { Npmextra } from '@push.rocks/npmextra';
|
||||
|
||||
// Initialize with current directory
|
||||
const npmextra = new Npmextra();
|
||||
|
||||
// Or specify a custom path
|
||||
const npmextra = new Npmextra('/path/to/project');
|
||||
|
||||
// Get merged configuration for any tool
|
||||
const eslintConfig = npmextra.dataFor<EslintConfig>('eslint', {
|
||||
// Default values if not in npmextra.json
|
||||
extends: 'standard',
|
||||
rules: {}
|
||||
});
|
||||
```
|
||||
|
||||
Instantiate the `Npmextra` class optionally with a custom path to your project's working directory. If no path is provided, the current working directory (`process.cwd()`) is used.
|
||||
|
||||
```typescript
|
||||
const npmExtraInstance = new Npmextra('/path/to/your/project');
|
||||
```
|
||||
|
||||
### Managing Tool Configurations with `npmextra.json`
|
||||
`@push.rocks/npmextra` excels in unifying tool configurations through a single `npmextra.json` file. Instead of scattering configurations across multiple files, `npmextra` enables you to define tool-specific settings within this centralized configuration file, which can then be accessed programmatically.
|
||||
|
||||
#### Creating and Utilizing `npmextra.json`
|
||||
|
||||
Create a `npmextra.json` in your project root with the following structure:
|
||||
|
||||
**npmextra.json example:**
|
||||
```json
|
||||
{
|
||||
"toolname": {
|
||||
"setting1": "value1",
|
||||
"setting2": "value2"
|
||||
"eslint": {
|
||||
"extends": "@company/eslint-config",
|
||||
"rules": {
|
||||
"no-console": "warn"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For example, to configure a hypothetical tool named `toolname`, define its settings as shown above.
|
||||
### 2. KeyValueStore - Persistent Data Storage
|
||||
|
||||
#### Accessing Configuration in Your Project
|
||||
|
||||
With the configuration defined, you can easily access these settings in your TypeScript code as follows:
|
||||
|
||||
```typescript
|
||||
// Import the npmextra module
|
||||
import { Npmextra } from '@push.rocks/npmextra';
|
||||
|
||||
// Create an instance pointing at the current directory
|
||||
const npmExtraInstance = new Npmextra();
|
||||
|
||||
// Retrieve the configuration for 'toolname', merging defaults with any found in npmextra.json
|
||||
const toolConfig = npmExtraInstance.dataFor<{ setting1: string, setting2: string }>('toolname', {
|
||||
setting1: 'defaultValue1',
|
||||
setting2: 'defaultValue2'
|
||||
});
|
||||
|
||||
// toolConfig now contains the merged settings from npmextra.json and provided defaults
|
||||
console.log(toolConfig);
|
||||
```
|
||||
|
||||
### Key-Value Store Management
|
||||
`@push.rocks/npmextra` also includes a Key-Value Store (KeyValueStore) functionality enabling persistent storage of key-value pairs between script executions.
|
||||
|
||||
#### Setting Up KeyValueStore
|
||||
|
||||
To utilize the KeyValueStore, create an instance specifying its scope (e.g., 'userHomeDir') and a unique identity for your application or tool:
|
||||
A flexible key-value store that persists data between script executions:
|
||||
|
||||
```typescript
|
||||
import { KeyValueStore } from '@push.rocks/npmextra';
|
||||
|
||||
const kvStore = new KeyValueStore<'userHomeDir'>({
|
||||
typeArg: 'userHomeDir',
|
||||
identityArg: 'myUniqueAppName'
|
||||
interface UserSettings {
|
||||
username: string;
|
||||
apiKey: string;
|
||||
preferences: {
|
||||
theme: 'light' | 'dark';
|
||||
notifications: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Different storage strategies
|
||||
const kvStore = new KeyValueStore<UserSettings>({
|
||||
typeArg: 'userHomeDir', // Store in user's home directory
|
||||
identityArg: 'myApp',
|
||||
mandatoryKeys: ['username', 'apiKey']
|
||||
});
|
||||
```
|
||||
|
||||
You can then use the `writeKey`, `readKey`, `writeAll`, and `readAll` methods to manage your store respectively.
|
||||
// Write operations
|
||||
await kvStore.writeKey('username', 'john_doe');
|
||||
await kvStore.writeKey('preferences', {
|
||||
theme: 'dark',
|
||||
notifications: true
|
||||
});
|
||||
|
||||
#### Example: Storing and Retrieving Data
|
||||
|
||||
```typescript
|
||||
// Write a single key-value pair
|
||||
await kvStore.writeKey('username', 'johnDoe');
|
||||
|
||||
// Read a single key
|
||||
// Read operations
|
||||
const username = await kvStore.readKey('username');
|
||||
console.log(username); // Outputs: johnDoe
|
||||
|
||||
// Write multiple key-value pairs
|
||||
await kvStore.writeAll({
|
||||
email: 'john@example.com',
|
||||
isAdmin: true
|
||||
});
|
||||
|
||||
// Read all key-value pairs
|
||||
const allData = await kvStore.readAll();
|
||||
console.log(allData); // Outputs the entire store's contents
|
||||
```
|
||||
|
||||
### Advanced Key-Value Store Management
|
||||
|
||||
In addition to basic read/write operations, `npmextra`’s `KeyValueStore` supports advanced scenarios like mandatory keys and custom file paths.
|
||||
|
||||
#### Example: Mandatory Keys and Custom Paths
|
||||
|
||||
Consider a scenario where your application requires specific keys to be present in the KeyValueStore. You can define mandatory keys and use a custom path for your store like this:
|
||||
|
||||
```typescript
|
||||
import { KeyValueStore } from '@push.rocks/npmextra';
|
||||
|
||||
interface CustomData {
|
||||
key1: string;
|
||||
key2: number;
|
||||
key3?: boolean;
|
||||
}
|
||||
|
||||
const kvStore = new KeyValueStore<CustomData>({
|
||||
typeArg: 'custom',
|
||||
identityArg: 'customApp',
|
||||
customPath: '/custom/path/to/store.json',
|
||||
mandatoryKeys: ['key1', 'key2']
|
||||
});
|
||||
|
||||
// Ensure all mandatory keys are present
|
||||
// Check for missing mandatory keys
|
||||
const missingKeys = await kvStore.getMissingMandatoryKeys();
|
||||
if (missingKeys.length) {
|
||||
console.log(`Missing mandatory keys: ${missingKeys.join(', ')}`);
|
||||
if (missingKeys.length > 0) {
|
||||
console.log('Missing required configuration:', missingKeys);
|
||||
}
|
||||
|
||||
// Use the KeyValueStore
|
||||
await kvStore.writeKey('key1', 'value1');
|
||||
await kvStore.writeKey('key2', 123);
|
||||
|
||||
const key1Value = await kvStore.readKey('key1');
|
||||
const allData = await kvStore.readAll();
|
||||
|
||||
console.log(key1Value); // Outputs: value1
|
||||
console.log(allData); // Outputs: { key1: 'value1', key2: 123 }
|
||||
// Wait for keys to be present
|
||||
await kvStore.waitForKeysPresent(['apiKey']);
|
||||
```
|
||||
|
||||
### Combining AppData and KeyValueStore
|
||||
**Storage Types:**
|
||||
- `userHomeDir`: Store in user's home directory
|
||||
- `custom`: Specify your own path
|
||||
- `ephemeral`: In-memory only (perfect for testing)
|
||||
|
||||
The `AppData` class extends the functionality of `KeyValueStore` by integrating environmental variables, specifying additional configurations, and providing a more structured approach to data management.
|
||||
### 3. AppData - Advanced Environment Management 🌟
|
||||
|
||||
#### Example: AppData Usage
|
||||
The crown jewel of npmextra - sophisticated environment variable mapping with automatic type conversion:
|
||||
|
||||
```typescript
|
||||
import { AppData } from '@push.rocks/npmextra';
|
||||
|
||||
interface AppSettings {
|
||||
settingA: string;
|
||||
settingB: number;
|
||||
nestedSetting: {
|
||||
innerSetting: boolean;
|
||||
}
|
||||
interface AppConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
port: number;
|
||||
features: {
|
||||
analytics: boolean;
|
||||
payment: boolean;
|
||||
};
|
||||
cache: {
|
||||
ttl: number;
|
||||
redis: {
|
||||
host: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const appDataInstance = await AppData.createAndInit<AppSettings>({
|
||||
dirPath: '/custom/path/to/appdata',
|
||||
requiredKeys: ['settingA', 'settingB'],
|
||||
const appData = await AppData.createAndInit<AppConfig>({
|
||||
dirPath: '/app/config', // Optional: defaults to smart path selection
|
||||
requiredKeys: ['apiKey', 'apiUrl'],
|
||||
envMapping: {
|
||||
settingA: 'MY_ENV_A',
|
||||
settingB: 'hard:42',
|
||||
nestedSetting: {
|
||||
innerSetting: 'MY_ENV_INNER'
|
||||
apiUrl: 'API_URL', // Simple mapping
|
||||
apiKey: 'hard:development-key-123', // Hardcoded value
|
||||
port: 'hard:3000', // Hardcoded number
|
||||
features: {
|
||||
analytics: 'boolean:ENABLE_ANALYTICS', // Force boolean conversion
|
||||
payment: 'hard_boolean:true' // Hardcoded boolean
|
||||
},
|
||||
cache: {
|
||||
ttl: 'json:CACHE_CONFIG', // Parse JSON from env var
|
||||
redis: {
|
||||
host: 'REDIS_HOST',
|
||||
password: 'base64:REDIS_PASSWORD_B64' // Decode base64
|
||||
}
|
||||
}
|
||||
},
|
||||
overwriteObject: {
|
||||
// Force these values regardless of env vars
|
||||
port: 8080
|
||||
}
|
||||
});
|
||||
|
||||
const appDataKvStore = await appDataInstance.getKvStore();
|
||||
|
||||
// Writing values
|
||||
await appDataKvStore.writeKey('settingA', 'exampleValue');
|
||||
await appDataKvStore.writeKey('settingB', 100);
|
||||
await appDataKvStore.writeKey('nestedSetting', { innerSetting: true });
|
||||
|
||||
// Reading values
|
||||
const settingA = await appDataKvStore.readKey('settingA');
|
||||
const allSettings = await appDataKvStore.readAll();
|
||||
|
||||
console.log(settingA); // Outputs: 'exampleValue'
|
||||
console.log(allSettings); // Outputs: { settingA: 'exampleValue', settingB: 100, nestedSetting: { innerSetting: true } }
|
||||
const store = await appData.getKvStore();
|
||||
const apiUrl = await store.readKey('apiUrl');
|
||||
```
|
||||
|
||||
### Error Handling and Debugging
|
||||
## AppData Special Cases & Conversions 🎯
|
||||
|
||||
Proper error handling ensures your integrations with `npmextra` are robust and stable. Below are some strategies for error handling and debugging potential issues.
|
||||
### Environment Variable Prefixes
|
||||
|
||||
#### Example: Error Handling in KeyValueStore
|
||||
AppData supports sophisticated type conversion through prefixes:
|
||||
|
||||
| Prefix | Description | Example | Result |
|
||||
|--------|-------------|---------|--------|
|
||||
| `hard:` | Hardcoded value | `hard:myvalue` | `"myvalue"` |
|
||||
| `hard_boolean:` | Hardcoded boolean | `hard_boolean:true` | `true` |
|
||||
| `hard_json:` | Hardcoded JSON | `hard_json:{"key":"value"}` | `{key: "value"}` |
|
||||
| `hard_base64:` | Hardcoded base64 | `hard_base64:SGVsbG8=` | `"Hello"` |
|
||||
| `boolean:` | Env var as boolean | `boolean:FEATURE_FLAG` | `true/false` |
|
||||
| `json:` | Parse env var as JSON | `json:CONFIG_JSON` | Parsed object |
|
||||
| `base64:` | Decode env var from base64 | `base64:SECRET_B64` | Decoded string |
|
||||
|
||||
### Automatic Suffix Detection
|
||||
|
||||
Variables ending with certain suffixes get automatic conversion:
|
||||
|
||||
```typescript
|
||||
import { KeyValueStore } from '@push.rocks/npmextra';
|
||||
{
|
||||
envMapping: {
|
||||
// Automatically parsed as JSON if MY_CONFIG_JSON="{"enabled":true}"
|
||||
config: 'MY_CONFIG_JSON',
|
||||
|
||||
const kvStore = new KeyValueStore('userHomeDir', 'errorHandlingApp');
|
||||
|
||||
try {
|
||||
await kvStore.writeKey('importantKey', 'importantValue');
|
||||
const value = await kvStore.readKey('importantKey');
|
||||
console.log(value); // Outputs: importantValue
|
||||
} catch (error) {
|
||||
console.error('Error managing key-value store:', error);
|
||||
// Automatically decoded from base64 if SECRET_KEY_BASE64="SGVsbG8="
|
||||
secret: 'SECRET_KEY_BASE64'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Debugging Configuration Issues in `npmextra.json`
|
||||
|
||||
To debug configuration issues, you can utilize conditional logging and checks:
|
||||
### Complex Examples
|
||||
|
||||
```typescript
|
||||
import { Npmextra } from '@push.rocks/npmextra';
|
||||
const appData = await AppData.createAndInit({
|
||||
envMapping: {
|
||||
// Simple environment variable
|
||||
apiUrl: 'API_URL',
|
||||
|
||||
const npmExtraInstance = new Npmextra();
|
||||
const toolConfig = npmExtraInstance.dataFor('toolname', {
|
||||
configKey1: 'defaultValue1',
|
||||
configKey2: 'defaultValue2'
|
||||
// Hardcoded values with type conversion
|
||||
debugMode: 'hard_boolean:false',
|
||||
maxRetries: 'hard:5',
|
||||
defaultConfig: 'hard_json:{"timeout":30,"retries":3}',
|
||||
|
||||
// Environment variables with conversion
|
||||
features: 'json:FEATURE_FLAGS', // Expects: {"feature1":true,"feature2":false}
|
||||
isProduction: 'boolean:IS_PROD', // Expects: "true" or "false"
|
||||
apiSecret: 'base64:API_SECRET', // Expects: base64 encoded string
|
||||
|
||||
// Nested structures
|
||||
database: {
|
||||
host: 'DB_HOST',
|
||||
port: 'hard:5432',
|
||||
credentials: {
|
||||
user: 'DB_USER',
|
||||
password: 'base64:DB_PASSWORD_ENCODED',
|
||||
ssl: 'boolean:DB_USE_SSL'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Override any env mappings
|
||||
overwriteObject: {
|
||||
debugMode: true, // Force debug mode regardless of env
|
||||
database: {
|
||||
host: 'localhost' // Force localhost for development
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!toolConfig.configKey1) {
|
||||
console.error('configKey1 is missing in npmextra.json');
|
||||
}
|
||||
|
||||
console.log(toolConfig);
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
### Boolean Conversion Rules
|
||||
|
||||
Writing tests ensures that your integration with `npmextra` works as expected. Below are examples of integration tests for both `Npmextra` and `KeyValueStore`.
|
||||
AppData intelligently handles boolean conversions:
|
||||
|
||||
#### Example: Testing `Npmextra` Class
|
||||
1. **String "true"/"false"**: Converted to boolean
|
||||
2. **With `boolean:` prefix**: Any env var value is converted (`"true"` → `true`, anything else → `false`)
|
||||
3. **With `hard_boolean:` prefix**: Hardcoded boolean value
|
||||
4. **Regular env vars**: Strings remain strings unless prefixed
|
||||
|
||||
```typescript
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { Npmextra } from '@push.rocks/npmextra';
|
||||
// Environment: FEATURE_A="true", FEATURE_B="yes", FEATURE_C="1"
|
||||
{
|
||||
envMapping: {
|
||||
featureA: 'FEATURE_A', // Result: "true" (string)
|
||||
featureB: 'boolean:FEATURE_B', // Result: false (only "true" → true)
|
||||
featureC: 'boolean:FEATURE_C', // Result: false (only "true" → true)
|
||||
featureD: 'hard_boolean:true' // Result: true (hardcoded)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
let npmExtraInstance: Npmextra;
|
||||
### Static Helper Functions
|
||||
|
||||
tap.test('should create an instance of Npmextra', async () => {
|
||||
npmExtraInstance = new Npmextra();
|
||||
expect(npmExtraInstance).toBeInstanceOf(Npmextra);
|
||||
AppData provides convenient static methods for directly accessing and converting environment variables without creating an instance:
|
||||
|
||||
```typescript
|
||||
import { AppData } from '@push.rocks/npmextra';
|
||||
|
||||
// Get environment variable as boolean
|
||||
const isEnabled = await AppData.valueAsBoolean('FEATURE_ENABLED');
|
||||
// Returns: true if "true", false otherwise
|
||||
|
||||
// Get environment variable as parsed JSON
|
||||
interface Config {
|
||||
timeout: number;
|
||||
retries: number;
|
||||
}
|
||||
const config = await AppData.valueAsJson<Config>('SERVICE_CONFIG');
|
||||
// Returns: Parsed object or undefined
|
||||
|
||||
// Get environment variable as base64 decoded string
|
||||
const secret = await AppData.valueAsBase64('ENCODED_SECRET');
|
||||
// Returns: Decoded string or undefined
|
||||
|
||||
// Get environment variable as string
|
||||
const apiUrl = await AppData.valueAsString('API_URL');
|
||||
// Returns: String value or undefined
|
||||
|
||||
// Get environment variable as number
|
||||
const port = await AppData.valueAsNumber('PORT');
|
||||
// Returns: Number value or undefined
|
||||
```
|
||||
|
||||
These static methods are perfect for:
|
||||
- Quick environment variable access without setup
|
||||
- Simple type conversions in utility functions
|
||||
- One-off configuration checks
|
||||
- Scenarios where you don't need the full AppData instance
|
||||
|
||||
## Advanced Patterns 🎨
|
||||
|
||||
### Reactive Configuration
|
||||
|
||||
Subscribe to configuration changes:
|
||||
|
||||
```typescript
|
||||
const kvStore = new KeyValueStore<Config>({
|
||||
typeArg: 'custom',
|
||||
identityArg: 'myApp'
|
||||
});
|
||||
|
||||
tap.test('should load configuration from npmextra.json', async () => {
|
||||
const config = npmExtraInstance.dataFor('toolname', {
|
||||
defaultKey1: 'defaultValue1',
|
||||
// Subscribe to changes
|
||||
kvStore.changeSubject.subscribe((newData) => {
|
||||
console.log('Configuration changed:', newData);
|
||||
});
|
||||
|
||||
// Changes trigger notifications
|
||||
await kvStore.writeKey('theme', 'dark');
|
||||
```
|
||||
|
||||
### Testing with Ephemeral Storage
|
||||
|
||||
Perfect for unit tests - no file system pollution:
|
||||
|
||||
```typescript
|
||||
const testStore = new KeyValueStore<TestData>({
|
||||
typeArg: 'ephemeral',
|
||||
identityArg: 'test'
|
||||
});
|
||||
|
||||
// All operations work normally, but nothing persists to disk
|
||||
await testStore.writeKey('testKey', 'testValue');
|
||||
```
|
||||
|
||||
### Smart Path Resolution
|
||||
|
||||
AppData automatically selects the best storage location:
|
||||
|
||||
1. Checks for `/app/data` (containerized environments)
|
||||
2. Falls back to `/data` (alternate container path)
|
||||
3. Uses `.nogit/appdata` (local development)
|
||||
|
||||
```typescript
|
||||
// Automatic path selection
|
||||
const appData = await AppData.createAndInit({
|
||||
// No dirPath specified - smart detection
|
||||
requiredKeys: ['apiKey']
|
||||
});
|
||||
|
||||
// Or force ephemeral for testing
|
||||
const testData = await AppData.createAndInit({
|
||||
ephemeral: true, // No disk persistence
|
||||
requiredKeys: ['testKey']
|
||||
});
|
||||
```
|
||||
|
||||
### Waiting for Configuration
|
||||
|
||||
Block until required configuration is available:
|
||||
|
||||
```typescript
|
||||
const appData = await AppData.createAndInit<Config>({
|
||||
requiredKeys: ['apiKey', 'apiUrl']
|
||||
});
|
||||
|
||||
// Wait for specific key
|
||||
const apiKey = await appData.waitForAndGetKey('apiKey');
|
||||
|
||||
// Check missing keys
|
||||
const missingKeys = await appData.logMissingKeys();
|
||||
// Logs: "The following mandatory keys are missing in the appdata:
|
||||
// -> apiKey,
|
||||
// -> apiUrl"
|
||||
```
|
||||
|
||||
## Real-World Example 🌍
|
||||
|
||||
Here's a complete example of a CLI tool using npmextra:
|
||||
|
||||
```typescript
|
||||
import { Npmextra, AppData, KeyValueStore } from '@push.rocks/npmextra';
|
||||
|
||||
interface CliConfig {
|
||||
githubToken: string;
|
||||
openaiKey: string;
|
||||
model: 'gpt-3' | 'gpt-4';
|
||||
cache: {
|
||||
enabled: boolean;
|
||||
ttl: number;
|
||||
};
|
||||
}
|
||||
|
||||
class MyCLI {
|
||||
private npmextra: Npmextra;
|
||||
private appData: AppData<CliConfig>;
|
||||
private cache: KeyValueStore<{[key: string]: any}>;
|
||||
|
||||
async initialize() {
|
||||
// Load tool configuration
|
||||
this.npmextra = new Npmextra();
|
||||
const config = this.npmextra.dataFor<any>('mycli', {
|
||||
defaultModel: 'gpt-3'
|
||||
});
|
||||
expect(config).toHaveProperty('defaultKey1');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
```
|
||||
// Setup app data with env mapping
|
||||
this.appData = await AppData.createAndInit<CliConfig>({
|
||||
requiredKeys: ['githubToken', 'openaiKey'],
|
||||
envMapping: {
|
||||
githubToken: 'GITHUB_TOKEN',
|
||||
openaiKey: 'base64:OPENAI_KEY_ENCODED',
|
||||
model: 'hard:gpt-4',
|
||||
cache: {
|
||||
enabled: 'boolean:ENABLE_CACHE',
|
||||
ttl: 'hard:3600'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
#### Example: Testing `KeyValueStore` Class
|
||||
|
||||
```typescript
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { KeyValueStore } from '@push.rocks/npmextra';
|
||||
|
||||
let kvStore: KeyValueStore<{ key1: string, key2: number }>;
|
||||
|
||||
tap.test('should create a KeyValueStore instance', async () => {
|
||||
kvStore = new KeyValueStore({
|
||||
// Initialize cache
|
||||
this.cache = new KeyValueStore({
|
||||
typeArg: 'userHomeDir',
|
||||
identityArg: 'testApp'
|
||||
identityArg: 'mycli-cache'
|
||||
});
|
||||
expect(kvStore).toBeInstanceOf(KeyValueStore);
|
||||
});
|
||||
|
||||
tap.test('should write and read back a value', async () => {
|
||||
await kvStore.writeKey('key1', 'value1');
|
||||
const result = await kvStore.readKey('key1');
|
||||
expect(result).toEqual('value1');
|
||||
});
|
||||
// Check for missing configuration
|
||||
const missingKeys = await this.appData.logMissingKeys();
|
||||
if (missingKeys.length > 0) {
|
||||
console.error('Please configure the missing keys');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should write and read back multiple values', async () => {
|
||||
await kvStore.writeAll({ key1: 'updatedValue1', key2: 2 });
|
||||
const result = await kvStore.readAll();
|
||||
expect(result).toEqual({ key1: 'updatedValue1', key2: 2 });
|
||||
});
|
||||
async run() {
|
||||
await this.initialize();
|
||||
|
||||
tap.start();
|
||||
const config = await this.appData.getKvStore();
|
||||
const settings = await config.readAll();
|
||||
|
||||
console.log(`Using model: ${settings.model}`);
|
||||
console.log(`Cache enabled: ${settings.cache.enabled}`);
|
||||
|
||||
// Use the configuration...
|
||||
}
|
||||
}
|
||||
|
||||
// Run the CLI
|
||||
const cli = new MyCLI();
|
||||
cli.run();
|
||||
```
|
||||
|
||||
### Summary
|
||||
## API Reference 📚
|
||||
|
||||
By centralizing configuration management and offering a versatile key-value store, `@push.rocks/npmextra` significantly simplifies the setup and management of tools and settings in modern JavaScript and TypeScript projects. Whether you're managing project-wide configurations or need persistent storage for key-value pairs, `npmextra` provides an efficient and streamlined solution. Leveraging these robust features will ensure your project is well-configured and maintainable.
|
||||
### Npmextra Class
|
||||
|
||||
```typescript
|
||||
new Npmextra(cwdArg?: string)
|
||||
```
|
||||
- `cwdArg`: Optional working directory path
|
||||
|
||||
**Methods:**
|
||||
- `dataFor<T>(toolName: string, defaultOptions: T): T` - Get merged configuration
|
||||
|
||||
### KeyValueStore Class
|
||||
|
||||
```typescript
|
||||
new KeyValueStore<T>(options: {
|
||||
typeArg: 'custom' | 'userHomeDir' | 'ephemeral';
|
||||
identityArg: string;
|
||||
customPath?: string;
|
||||
mandatoryKeys?: Array<keyof T>;
|
||||
})
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
- `readKey(key: string): Promise<T>` - Read single value
|
||||
- `writeKey(key: string, value: T): Promise<void>` - Write single value
|
||||
- `readAll(): Promise<T>` - Read all values
|
||||
- `writeAll(data: T): Promise<void>` - Write all values
|
||||
- `deleteKey(key: string): Promise<void>` - Delete a key
|
||||
- `getMissingMandatoryKeys(): Promise<string[]>` - Check missing required keys
|
||||
- `waitForKeysPresent(keys: string[]): Promise<void>` - Wait for keys
|
||||
|
||||
### AppData Class
|
||||
|
||||
```typescript
|
||||
await AppData.createAndInit<T>(options: {
|
||||
dirPath?: string;
|
||||
requiredKeys?: Array<keyof T>;
|
||||
ephemeral?: boolean;
|
||||
envMapping?: PartialDeep<T>;
|
||||
overwriteObject?: PartialDeep<T>;
|
||||
})
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
- `getKvStore(): Promise<KeyValueStore<T>>` - Get underlying store
|
||||
- `logMissingKeys(): Promise<Array<keyof T>>` - Log and return missing keys
|
||||
- `waitForAndGetKey<K>(key: K): Promise<T[K]>` - Wait for and retrieve key
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
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)
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"testTool":{
|
||||
"testValue":2
|
||||
"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 npmextra from '../ts/index.js';
|
||||
|
||||
interface ITestOptions {
|
||||
hi: string;
|
||||
deep: {
|
||||
deep1: string;
|
||||
deep2: string;
|
||||
};
|
||||
}
|
||||
|
||||
let testAppdata: npmextra.AppData<ITestOptions>;
|
||||
|
||||
tap.test('should create a valid AppData', async () => {
|
||||
testAppdata = new npmextra.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 npmextra from '../ts/index.js';
|
||||
|
||||
// Test environment variable with boolean false value
|
||||
tap.test('should handle boolean false values in nested objects correctly', async () => {
|
||||
// Set up test environment variable
|
||||
process.env['S3_USESSL'] = 'false';
|
||||
process.env['S3_ENDPOINT'] = 'https://s3.example.com';
|
||||
process.env['S3_REGION'] = 'us-east-1';
|
||||
process.env['S3_ACCESSKEY'] = 'test-key';
|
||||
process.env['S3_ACCESSSECRET'] = 'test-secret';
|
||||
|
||||
// Create AppData with nested object structure similar to CloudlyConfig
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true, // Use in-memory storage for testing
|
||||
envMapping: {
|
||||
s3Descriptor: {
|
||||
endpoint: 'S3_ENDPOINT',
|
||||
region: 'S3_REGION',
|
||||
accessKey: 'S3_ACCESSKEY',
|
||||
accessSecret: 'S3_ACCESSSECRET',
|
||||
useSsl: 'boolean:S3_USESSL'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get the kvStore and read the configuration
|
||||
const kvStore = await appData.getKvStore();
|
||||
const s3Descriptor = await kvStore.readKey('s3Descriptor');
|
||||
|
||||
console.log('\n=== Test Results ===');
|
||||
console.log('S3 Descriptor:', JSON.stringify(s3Descriptor, null, 2));
|
||||
console.log('useSsl value:', s3Descriptor?.useSsl);
|
||||
console.log('useSsl type:', typeof s3Descriptor?.useSsl);
|
||||
console.log('useSsl === false:', (s3Descriptor?.useSsl as any) === false);
|
||||
|
||||
// Verify the values
|
||||
expect(s3Descriptor).toBeTruthy();
|
||||
expect(s3Descriptor.endpoint).toEqual('https://s3.example.com');
|
||||
expect(s3Descriptor.region).toEqual('us-east-1');
|
||||
expect(s3Descriptor.accessKey).toEqual('test-key');
|
||||
expect(s3Descriptor.accessSecret).toEqual('test-secret');
|
||||
|
||||
// Critical test: useSsl should be false, not undefined
|
||||
expect(s3Descriptor.useSsl).toEqual(false);
|
||||
expect(typeof s3Descriptor.useSsl).toEqual('boolean');
|
||||
expect(s3Descriptor.useSsl).not.toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should handle various boolean representations correctly', async () => {
|
||||
// Test different boolean representations
|
||||
const testCases = [
|
||||
{ env: 'false', expected: false },
|
||||
{ env: 'FALSE', expected: false },
|
||||
{ env: '0', expected: false },
|
||||
{ env: 'no', expected: false },
|
||||
{ env: 'NO', expected: false },
|
||||
{ env: 'n', expected: false },
|
||||
{ env: 'off', expected: false },
|
||||
{ env: 'true', expected: true },
|
||||
{ env: 'TRUE', expected: true },
|
||||
{ env: '1', expected: true },
|
||||
{ env: 'yes', expected: true },
|
||||
{ env: 'YES', expected: true },
|
||||
{ env: 'y', expected: true },
|
||||
{ env: 'on', expected: true },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
process.env['TEST_BOOL'] = testCase.env;
|
||||
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true,
|
||||
envMapping: {
|
||||
testBool: 'boolean:TEST_BOOL'
|
||||
}
|
||||
});
|
||||
|
||||
const kvStore = await appData.getKvStore();
|
||||
const testBool = await kvStore.readKey('testBool');
|
||||
|
||||
console.log(`Input "${testCase.env}" => ${testBool} (expected: ${testCase.expected})`);
|
||||
expect(testBool).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle hardcoded boolean false values', async () => {
|
||||
// Test with hardcoded boolean false value
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true,
|
||||
envMapping: {
|
||||
boolValue: 'hard_boolean:false'
|
||||
}
|
||||
});
|
||||
|
||||
const kvStore = await appData.getKvStore();
|
||||
const boolValue = await kvStore.readKey('boolValue');
|
||||
|
||||
console.log('\n=== Hardcoded Boolean Test ===');
|
||||
console.log('boolValue:', boolValue);
|
||||
console.log('type:', typeof boolValue);
|
||||
console.log('is false:', (boolValue as any) === false);
|
||||
|
||||
expect(boolValue).toEqual(false);
|
||||
expect(typeof boolValue).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('should not filter out other falsy values', async () => {
|
||||
process.env['ZERO_VALUE'] = '0';
|
||||
process.env['EMPTY_STRING'] = ''; // This should be preserved as empty string
|
||||
|
||||
const appData = await npmextra.AppData.createAndInit({
|
||||
ephemeral: true,
|
||||
envMapping: {
|
||||
nested: {
|
||||
zeroAsNumber: 'ZERO_VALUE', // Should preserve "0" as string
|
||||
zeroAsBoolean: 'boolean:ZERO_VALUE', // Should convert to false
|
||||
emptyString: 'EMPTY_STRING', // Should preserve empty string
|
||||
hardcodedFalse: 'hard_boolean:false', // Should be false
|
||||
hardcodedZero: 'hard:0', // Should be "0" string
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const kvStore = await appData.getKvStore();
|
||||
const nested = await kvStore.readKey('nested');
|
||||
|
||||
console.log('\n=== Falsy Values Test ===');
|
||||
console.log('nested:', JSON.stringify(nested, null, 2));
|
||||
|
||||
expect(nested).toBeTruthy();
|
||||
expect(nested.zeroAsNumber).toEqual('0');
|
||||
expect(nested.zeroAsBoolean).toEqual(false);
|
||||
expect(nested.emptyString).toEqual('');
|
||||
expect(nested.hardcodedFalse).toEqual(false);
|
||||
expect(nested.hardcodedZero).toEqual('0');
|
||||
});
|
||||
|
||||
export default tap.start();
|
@@ -1,4 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as npmextra from '../ts/index.js';
|
||||
|
||||
@@ -29,4 +29,4 @@ tap.test('expect to add an object to the kv Store', async () => {
|
||||
await expect(await myKeyValueStore.readKey('myKey')).toEqual('myValue');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
||||
|
10
test/test.ts
10
test/test.ts
@@ -1,5 +1,4 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import path = require('path');
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
// module to test
|
||||
import * as npmextra from '../ts/index.js';
|
||||
@@ -16,11 +15,14 @@ tap.test('should state wether a npmextra.json exists', async () => {
|
||||
expect(testNpmextra.npmextraJsonExists).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should pass through default value, if not overriden by config from file', async () => {
|
||||
tap.test(
|
||||
'should pass through default value, if not overriden by config from file',
|
||||
async () => {
|
||||
let testData = testNpmextra.dataFor('testTool', { someKey2: 'someValue2' });
|
||||
console.log(testData);
|
||||
expect(testData).toHaveProperty('someKey2');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
tap.test('should read a config file', async () => {
|
||||
let testData = testNpmextra.dataFor<any>('testTool', {
|
||||
|
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/npmextra',
|
||||
version: '5.0.16',
|
||||
version: '5.3.1',
|
||||
description: 'A utility to enhance npm with additional configuration, tool management capabilities, and a key-value store for project setups.'
|
||||
}
|
||||
|
@@ -1,18 +1,293 @@
|
||||
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';
|
||||
|
||||
// ============================================================================
|
||||
// Singleton Qenv Provider
|
||||
// ============================================================================
|
||||
let sharedQenv: plugins.qenv.Qenv | undefined;
|
||||
|
||||
function getQenv(): plugins.qenv.Qenv {
|
||||
if (!sharedQenv) {
|
||||
sharedQenv = new plugins.qenv.Qenv(
|
||||
process.cwd(),
|
||||
plugins.path.join(process.cwd(), '.nogit')
|
||||
);
|
||||
}
|
||||
return sharedQenv;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Converters - Centralized conversion logic
|
||||
// ============================================================================
|
||||
function toBoolean(value: unknown): boolean {
|
||||
// If 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);
|
||||
console.log(` 🔍 Processing mapping: "${mappingString}"`);
|
||||
console.log(` Source: ${spec.source.type === 'env' ? `env:${spec.source.key}` : `hard:${spec.source.value}`}`);
|
||||
console.log(` Transforms: ${spec.transforms.length > 0 ? spec.transforms.join(', ') : 'none'}`);
|
||||
|
||||
const rawValue = await resolveSource(spec.source);
|
||||
console.log(` Raw value: ${JSON.stringify(rawValue)} (type: ${typeof rawValue})`);
|
||||
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
console.log(` ⚠️ Raw value is undefined/null, returning undefined`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = applyTransforms(rawValue, spec.transforms);
|
||||
console.log(` Final value: ${JSON.stringify(result)} (type: ${typeof result})`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively evaluate mapping values (strings or nested objects)
|
||||
*/
|
||||
async function evaluateMappingValue(mappingValue: any): Promise<any> {
|
||||
// Handle null explicitly - it should return null, not be treated as object
|
||||
if (mappingValue === null) {
|
||||
console.log(` 📌 Value is null, returning null`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle strings (mapping specs)
|
||||
if (typeof mappingValue === 'string') {
|
||||
return processMappingValue(mappingValue);
|
||||
}
|
||||
|
||||
// Handle objects (but not arrays or null)
|
||||
if (mappingValue && typeof mappingValue === 'object' && !Array.isArray(mappingValue)) {
|
||||
console.log(` 📂 Processing nested object with ${Object.keys(mappingValue).length} keys`);
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(mappingValue)) {
|
||||
console.log(` → Processing nested key "${key}"`);
|
||||
const evaluated = await evaluateMappingValue(value);
|
||||
// Important: Don't filter out false or other falsy values!
|
||||
// Only skip if explicitly undefined
|
||||
if (evaluated !== undefined) {
|
||||
result[key] = evaluated;
|
||||
console.log(` ✓ Nested key "${key}" = ${JSON.stringify(evaluated)} (type: ${typeof evaluated})`);
|
||||
} else {
|
||||
console.log(` ⚠️ Nested key "${key}" evaluated to undefined, skipping`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For any other type (numbers, booleans, etc.), return as-is
|
||||
console.log(` 📎 Returning value as-is: ${JSON.stringify(mappingValue)} (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?: {
|
||||
[key in keyof T]?: string | object;
|
||||
};
|
||||
envMapping?: plugins.tsclass.typeFest.PartialDeep<T>;
|
||||
overwriteObject?: plugins.tsclass.typeFest.PartialDeep<T>;
|
||||
}
|
||||
|
||||
export class AppData<T = any> {
|
||||
@@ -22,12 +297,64 @@ export class AppData<T = any> {
|
||||
* @param pathArg
|
||||
* @returns
|
||||
*/
|
||||
public static async createAndInit<T = any>(optionsArg: IAppDataOptions<T> = {}): Promise<AppData<T>> {
|
||||
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>;
|
||||
@@ -40,11 +367,20 @@ export class AppData<T = any> {
|
||||
|
||||
/**
|
||||
* inits app data
|
||||
* @param pathArg
|
||||
*/
|
||||
private async init(pathArg?: string) {
|
||||
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) {
|
||||
// ok, nothing to do here;
|
||||
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';
|
||||
@@ -53,69 +389,85 @@ export class AppData<T = any> {
|
||||
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: 'custom',
|
||||
typeArg: isEphemeral ? 'ephemeral' : 'custom',
|
||||
identityArg: 'appkv',
|
||||
customPath: this.options.dirPath,
|
||||
mandatoryKeys: this.options.requiredKeys as Array<keyof T>
|
||||
mandatoryKeys: this.options.requiredKeys as Array<keyof T>,
|
||||
});
|
||||
|
||||
if (this.options.envMapping) {
|
||||
const qenvInstance = new plugins.qenv.Qenv(process.cwd(), plugins.path.join(process.cwd(), '.nogit'));
|
||||
|
||||
// Recursive function to handle nested objects, now includes key parameter
|
||||
const processEnvMapping = async (key: keyof T, mappingValue: any, parentKey: keyof T | '' = ''): Promise<any> => {
|
||||
if (typeof mappingValue === 'string') {
|
||||
let envValue: string | T[keyof T];
|
||||
if (mappingValue.startsWith('hard:')) {
|
||||
envValue = mappingValue.replace('hard:', '') as T[keyof T];
|
||||
} else {
|
||||
envValue = await qenvInstance.getEnvVarOnDemand(mappingValue) as T[keyof T];
|
||||
}
|
||||
if (envValue) {
|
||||
if (typeof envValue === 'string' && mappingValue.endsWith('_JSON')) {
|
||||
envValue = JSON.parse(envValue) as T[keyof T];
|
||||
}
|
||||
if (!parentKey) {
|
||||
await this.kvStore.writeKey(key, envValue);
|
||||
} else {
|
||||
return envValue;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else if (typeof mappingValue === 'object' && mappingValue !== null) {
|
||||
const resultObject: Partial<T> = {};
|
||||
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 as keyof T, nestedValue, key);
|
||||
resultObject[innerKey as keyof T] = nestedResult;
|
||||
}
|
||||
if (parentKey === '') {
|
||||
// Only write to kvStore if at the top level
|
||||
await this.kvStore.writeKey(key, resultObject as T[keyof T]);
|
||||
} else {
|
||||
// For nested objects, return the constructed object instead of writing to kvStore
|
||||
return resultObject;
|
||||
}
|
||||
}
|
||||
};
|
||||
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) {
|
||||
await processEnvMapping(key as keyof T, this.options.envMapping[key]);
|
||||
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}`) :
|
||||
JSON.stringify(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!');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,8 +484,8 @@ export class AppData<T = any> {
|
||||
if (missingMandatoryKeys.length > 0) {
|
||||
console.log(
|
||||
`The following mandatory keys are missing in the appdata:\n -> ${missingMandatoryKeys.join(
|
||||
',\n -> '
|
||||
)}`
|
||||
',\n -> ',
|
||||
)}`,
|
||||
);
|
||||
} else {
|
||||
console.log('All mandatory keys are present in the appdata');
|
||||
@@ -141,7 +493,9 @@ export class AppData<T = any> {
|
||||
return missingMandatoryKeys;
|
||||
}
|
||||
|
||||
public async waitForAndGetKey<K extends keyof T>(keyArg: K): Promise<T[K] | undefined> {
|
||||
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);
|
||||
|
@@ -3,7 +3,7 @@ import * as paths from './npmextra.paths.js';
|
||||
|
||||
import { Task } from '@push.rocks/taskbuffer';
|
||||
|
||||
export type TKeyValueStore = 'custom' | 'userHomeDir';
|
||||
export type TKeyValueStore = 'custom' | 'userHomeDir' | 'ephemeral';
|
||||
|
||||
export interface IKvStoreConstructorOptions<T> {
|
||||
typeArg: TKeyValueStore;
|
||||
@@ -28,6 +28,7 @@ export class KeyValueStore<T = any> {
|
||||
bufferMax: 1,
|
||||
execDelay: 0,
|
||||
taskFunction: async () => {
|
||||
if (this.type !== 'ephemeral') {
|
||||
this.dataObject = {
|
||||
...plugins.smartfile.fs.toObjectSync(this.filePath),
|
||||
...this.dataObject,
|
||||
@@ -38,8 +39,9 @@ export class KeyValueStore<T = any> {
|
||||
this.deletedObject = {};
|
||||
await plugins.smartfile.memory.toFs(
|
||||
plugins.smartjson.stringifyPretty(this.dataObject),
|
||||
this.filePath
|
||||
this.filePath,
|
||||
);
|
||||
}
|
||||
const newStateString = plugins.smartjson.stringify(this.dataObject);
|
||||
|
||||
// change detection
|
||||
@@ -54,12 +56,22 @@ export class KeyValueStore<T = any> {
|
||||
* computes the identity and filePath
|
||||
*/
|
||||
private initFilePath = () => {
|
||||
if (this.type === 'ephemeral') {
|
||||
// No file path is needed for ephemeral type
|
||||
return;
|
||||
}
|
||||
if (this.customPath) {
|
||||
// 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;
|
||||
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, '{}');
|
||||
return;
|
||||
@@ -79,7 +91,7 @@ export class KeyValueStore<T = any> {
|
||||
// if no custom path is provided, try to store at home directory
|
||||
public type: TKeyValueStore;
|
||||
public identity: string;
|
||||
public filePath: string;
|
||||
public filePath?: string;
|
||||
private customPath?: string; // Optionally allow custom path
|
||||
|
||||
/**
|
||||
@@ -123,7 +135,10 @@ export class KeyValueStore<T = any> {
|
||||
/**
|
||||
* writes a specific key to the keyValueStore
|
||||
*/
|
||||
public async writeKey<K extends keyof T>(keyArg: K, valueArg: T[K]): Promise<void> {
|
||||
public async writeKey<K extends keyof T>(
|
||||
keyArg: K,
|
||||
valueArg: T[K],
|
||||
): Promise<void> {
|
||||
await this.writeAll({
|
||||
[keyArg]: valueArg,
|
||||
} as unknown as Partial<T>);
|
||||
@@ -147,8 +162,10 @@ export class KeyValueStore<T = any> {
|
||||
*/
|
||||
public async wipe(): Promise<void> {
|
||||
this.dataObject = {};
|
||||
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
|
||||
@@ -166,22 +183,28 @@ export class KeyValueStore<T = any> {
|
||||
}
|
||||
|
||||
private setMandatoryKeys(keys: Array<keyof T>) {
|
||||
keys.forEach(key => this.mandatoryKeys.add(key));
|
||||
keys.forEach((key) => this.mandatoryKeys.add(key));
|
||||
}
|
||||
|
||||
public async getMissingMandatoryKeys(): Promise<Array<keyof T>> {
|
||||
await this.readAll();
|
||||
return Array.from(this.mandatoryKeys).filter(key => !(key in this.dataObject));
|
||||
return Array.from(this.mandatoryKeys).filter(
|
||||
(key) => !(key in this.dataObject),
|
||||
);
|
||||
}
|
||||
|
||||
public async waitForKeysPresent<K extends keyof T>(keysArg: K[]): Promise<void> {
|
||||
const missingKeys = keysArg.filter(keyArg => !this.dataObject[keyArg]);
|
||||
public async waitForKeysPresent<K extends keyof T>(
|
||||
keysArg: K[],
|
||||
): Promise<void> {
|
||||
const missingKeys = keysArg.filter((keyArg) => !this.dataObject[keyArg]);
|
||||
if (missingKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
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) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
@@ -190,7 +213,9 @@ export class KeyValueStore<T = any> {
|
||||
});
|
||||
}
|
||||
|
||||
public async waitForAndGetKey<K extends keyof T>(keyArg: K): Promise<T[K] | undefined> {
|
||||
public async waitForAndGetKey<K extends keyof T>(
|
||||
keyArg: K,
|
||||
): Promise<T[K] | undefined> {
|
||||
await this.waitForKeysPresent([keyArg]);
|
||||
return this.readKey(keyArg);
|
||||
}
|
||||
|
@@ -27,7 +27,10 @@ export class Npmextra {
|
||||
/**
|
||||
* merges the supplied options with the ones from npmextra.json
|
||||
*/
|
||||
dataFor<IToolConfig>(toolnameArg: string, defaultOptionsArg: any): IToolConfig {
|
||||
dataFor<IToolConfig>(
|
||||
toolnameArg: string,
|
||||
defaultOptionsArg: any,
|
||||
): IToolConfig {
|
||||
let npmextraToolOptions;
|
||||
if (this.npmextraJsonData[toolnameArg]) {
|
||||
npmextraToolOptions = this.npmextraJsonData[toolnameArg];
|
||||
@@ -45,7 +48,9 @@ export class Npmextra {
|
||||
* checks if the JSON exists
|
||||
*/
|
||||
private checkNpmextraJsonExists() {
|
||||
this.npmextraJsonExists = plugins.smartfile.fs.fileExistsSync(this.lookupPath);
|
||||
this.npmextraJsonExists = plugins.smartfile.fs.fileExistsSync(
|
||||
this.lookupPath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +69,9 @@ export class Npmextra {
|
||||
*/
|
||||
private checkNpmextraJsonData() {
|
||||
if (this.npmextraJsonExists) {
|
||||
this.npmextraJsonData = plugins.smartfile.fs.toObjectSync(this.lookupPath);
|
||||
this.npmextraJsonData = plugins.smartfile.fs.toObjectSync(
|
||||
this.lookupPath,
|
||||
);
|
||||
} else {
|
||||
this.npmextraJsonData = {};
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ import * as plugins from './npmextra.plugins.js';
|
||||
export let cwd = process.cwd();
|
||||
export let packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
'../',
|
||||
);
|
||||
|
||||
// ----------------------
|
||||
|
@@ -1,3 +1,7 @@
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
export { tsclass };
|
||||
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
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 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": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
"exclude": ["dist_*/**/*.d.ts"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user