You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@openserverless.apache.org by dc...@apache.org on 2026/05/30 12:20:51 UTC
(openserverless-task) 01/01: feat: new interactive configuration tool #10
This is an automated email from the ASF dual-hosted git repository.
dcremonini pushed a commit to branch feat/add-sections-for-applications
in repository https://gitbox.apache.org/repos/asf/openserverless-task.git
commit 38833f4e26451003b3f325c414bc18e6f1b548c1
Author: Daniele Cremonini <da...@gmail.com>
AuthorDate: Sat Feb 28 12:29:12 2026 +0100
feat: new interactive configuration tool #10
---
.agent/links/resources.md | 0
.agent/plan/REQ-FUNC-001-V1-plan.md | 220 +++++
.../2026-03-26-display-current-configuration.md | 164 ++++
.agent/spec/design.md | 0
.agent/spec/requirement.md | 0
.agent/spec/tasks.md | 1 +
.agent/wiki/architecture.md | 0
.agent/wiki/domain.md | 3 +
.agent/wiki/example-001-configuration-file.md | 135 +++
.agent/wiki/how-to-configure-ops-via-cli.md | 54 ++
.agent/wiki/ops-managed-components.md | 12 +
AGENTS.md | 147 ++++
bun.lock | 46 ++
package.json | 7 +
pnpm-lock.yaml | 132 +++
util/config/README.md | 177 +++-
util/config/configurator.js | 906 ---------------------
.../config/configurator/all-config-parameters.toml | 248 ++++++
util/config/configurator/bun.lockb | Bin 4993 -> 4663 bytes
util/config/configurator/config-accessor.ts | 141 ++++
util/config/configurator/config-display.ts | 200 +++++
util/config/configurator/config-parser.ts | 179 ++++
util/config/configurator/config-validator.ts | 142 ++++
.../configurator/configurator-class-diagram.mmd | 146 ++++
util/config/configurator/configurator.ts | 233 +++---
util/config/configurator/edit-loop.ts | 280 +++++++
util/config/configurator/exit-handler.ts | 245 ++++++
util/config/configurator/package-lock.json | 123 +++
util/config/configurator/package.json | 4 +-
util/config/configurator/partial-config-manager.ts | 424 ++++++++++
.../configurator/tests/config-parser.test.ts | 323 ++++++++
...onfiguration-file-components-parameters.test.ts | 250 ++++++
.../tests/configuration-file-components.test.ts | 24 +
.../configurator/tests/configuration-file.test.ts | 140 ++++
.../tests/fixtures/commented-params.toml | 4 +
.../tests/fixtures/components-list.json | 59 ++
.../config/configurator/tests/fixtures/simple.toml | 8 +
.../configurator/tests/fixtures/tutorial.json | 5 +
util/config/configurator/tests/index.test.ts | 31 -
.../configurator/tests/partial-config.test.ts | 259 ++++++
util/config/configurator/tsconfig.json | 1 +
util/config/configurator/types-config-file.ts | 118 +++
util/config/configurator/types-core.ts | 375 +++++++++
util/config/configurator/types.ts | 19 +
44 files changed, 4936 insertions(+), 1049 deletions(-)
diff --git a/.agent/links/resources.md b/.agent/links/resources.md
new file mode 100644
index 0000000..e69de29
diff --git a/.agent/plan/REQ-FUNC-001-V1-plan.md b/.agent/plan/REQ-FUNC-001-V1-plan.md
new file mode 100644
index 0000000..eab64aa
--- /dev/null
+++ b/.agent/plan/REQ-FUNC-001-V1-plan.md
@@ -0,0 +1,220 @@
+# Implementation Plan: REQ-FUNC-001-V1
+
+## Requirement Summary
+**ID:** REQ-FUNC-001-V1
+**Title:** Read the ops configuration key-value tuples
+
+## Objective
+Implement functionality to read and parse ops configuration key-value tuples from a TOML file that drives the behavior of the TUI, using an Object-Oriented approach with classes and methods.
+
+## Architecture Overview
+
+### Class Hierarchy
+```
+OpsConfigFile (manages components)
+ └── ComponentConfig (manages parameters)
+ └── ConfigParameter (single parameter)
+```
+
+### Supporting Classes
+```
+ConfigParser (reads and parses TOML)
+ConfigValidator (validates configuration)
+ConfigAccessor (provides read/write access)
+```
+
+### Design Principles
+- **Encapsulation**: Each class manages its own state through public methods
+- **Single Responsibility**: Each class has one clear purpose
+- **Immutable Configuration**: ConfigParser creates immutable OpsConfigFile instances
+- **Validation Separation**: ConfigValidator is a separate concern from parsing
+
+## Current State Analysis
+
+### Existing Code
+- `configurator.ts:116-118` - Basic TOML import exists
+- `configurator.ts:131-139` - Component iteration logic present
+- `all-config-parameters.toml` - Example configuration file with component sections
+
+### Gaps
+1. No proper TypeScript interfaces for configuration structure
+2. Missing validation for TOML configuration format
+3. No support for key-value tuples with metadata (label, initialValue, userInputValue)
+4. Incomplete parsing of component configuration values
+5. Functional approach instead of Object-Oriented design
+
+## Implementation Tasks
+
+### Task 1: Define Configuration Classes and Types
+**File:** `types.ts` (new file)
+
+Create TypeScript classes and interfaces:
+- `ConfigParameter` class - encapsulates a single parameter with:
+ - Properties: `key: string`, `label: string`, `initialValue: string`, `userInputValue: string`
+ - Methods: `getValue(): string`, `setLabel(label: string): void`, `setValue(value: string): void`
+- `ComponentConfig` class - encapsulates configuration for a managed component:
+ - Properties: `name: string`, `parameters: Map<string, ConfigParameter>`
+ - Methods: `getParameter(key: string): ConfigParameter | undefined`, `addParameter(param: ConfigParameter): void`, `getAllParameters(): ConfigParameter[]`
+- `OpsConfigFile` class - encapsulates the entire TOML configuration:
+ - Properties: `components: Map<string, ComponentConfig>`
+ - Methods: `getComponent(name: string): ComponentConfig | undefined`, `addComponent(comp: ComponentConfig): void`, `getAllComponents(): ComponentConfig[]`
+
+**Acceptance Criteria:**
+- Classes properly encapsulate data and behavior
+- Support for both string values and object values with metadata
+- Strict TypeScript compliance
+- Clean public API through methods
+- Unit test are in place
+
+### Task 2: Implement ConfigParser Class
+**File:** `config-parser.ts` (new file)
+
+Create `ConfigParser` class:
+- Constructor: `constructor(filePath: string)`
+- Properties: `filePath: string`, `rawData: unknown`
+- Methods:
+ - `load(): Promise<Result<OpsConfigFile, string>>` - reads and parses the TOML file
+ - `parseComponentSection(sectionName: string, sectionData: unknown): ComponentConfig` - parses a single component
+ - `parseParameter(key: string, value: unknown): ConfigParameter` - parses a single parameter
+ - `extractComponents(): Map<string, ComponentConfig>` - extracts all components from raw data
+
+**Acceptance Criteria:**
+- Successfully parses `all-config-parameters.toml`
+- Correctly identifies component sections
+- Extracts all key-value pairs per component
+- Handles both simple string values and complex object values with metadata
+- Handles commented-out parameters (lines starting with #)
+
+### Task 3: Implement ConfigValidator Class
+**File:** `config-validator.ts` (new file)
+
+Create `ConfigValidator` class:
+- Constructor: `constructor(config: OpsConfigFile, managedComponents: string[])`
+- Properties: `config: OpsConfigFile`, `managedComponents: string[]`, `errors: string[]`
+- Methods:
+ - `validate(): ValidationResult` - runs all validations
+ - `validateComponentNames(): boolean` - validates component names against managed list
+ - `validateParameterKeys(component: ComponentConfig): boolean` - validates key naming conventions
+ - `validateParameterStructure(param: ConfigParameter): boolean` - validates parameter structure
+ - `getErrors(): string[]` - returns all validation errors
+
+**Acceptance Criteria:**
+- `validate()` returns a `ValidationResult` object with `success: boolean` and `errors: string[]`
+- Validates against `ops-managed-components.md` list
+- Verifies key naming conventions (uppercase with underscores)
+- Collects all errors before returning
+
+### Task 4: Implement ConfigAccessor Class
+**File:** `config-accessor.ts` (new file)
+
+Create `ConfigAccessor` class:
+- Constructor: `constructor(config: OpsConfigFile)`
+- Properties: `config: OpsConfigFile`
+- Methods:
+ - `getComponentNames(): string[]` - returns all component names
+ - `getComponent(name: string): ComponentConfig | undefined` - gets a specific component
+ - `getParameter(componentName: string, key: string): ConfigParameter | undefined` - gets a specific parameter
+ - `getParameterValue(componentName: string, key: string): string | undefined` - gets parameter value
+ - `setParameterValue(componentName: string, key: string, value: string): boolean` - sets parameter value
+
+**Acceptance Criteria:**
+- Clean API for accessing configuration data
+- Returns undefined for non-existent keys/components
+- Type-safe return values
+- Encapsulates all configuration access logic
+
+### Task 5: Update Main Entry Point
+**File:** `configurator.ts`
+
+Modify `main()` function to use OOP approach:
+- Create `ConfigParser` instance and call `load()` method
+- Create `ConfigValidator` instance and call `validate()` method
+- Create `ConfigAccessor` instance with parsed configuration
+- Use `ConfigAccessor` methods to retrieve data for UI components
+- Pass `ConfigAccessor` instance to UI rendering functions
+
+**Acceptance Criteria:**
+- Application starts successfully with valid TOML
+- Graceful error handling for invalid configuration
+- Configuration data available through accessor methods
+- No direct data manipulation, only method calls
+
+## Test Cases
+
+### Test 1: ConfigParser.load() - Parse Valid TOML
+- Input: `all-config-parameters.toml`
+- Expected: `ConfigParser.load()` returns `Result<OpsConfigFile, string>` with success
+- Verify: `result.success === true`, OpsConfigFile instance contains all components
+
+### Test 2: ConfigParser.load() - Handle Missing File
+- Input: Non-existent file path
+- Expected: `ConfigParser.load()` returns error result
+- Verify: `result.success === false`, error message descriptive
+
+### Test 3: ComponentConfig.getParameter() - Access Key-Value Tuples
+- Setup: Create ConfigParameter with `REDIS_URL= {label="Redis URL", initialValue="", userInputValue=""}`
+- Expected: `component.getParameter('REDIS_URL')` returns ConfigParameter instance
+- Verify: `param.getLabel() === "Redis URL"`, `param.getInitialValue() === ""`
+
+### Test 4: ConfigParser.parseParameter() - Parse Simple String Values
+- Input: `OPSTUTORIAL_SECRET_POSTGRES=""`
+- Expected: ConfigParameter instance with empty string value
+- Verify: `param.getValue() === ""`, no metadata properties set
+
+### Test 5: ConfigValidator.validate() - Validate Component Names
+- Input: TOML with unknown component
+- Expected: `ConfigValidator.validate()` returns failure with errors
+- Verify: `validationResult.success === false`, errors include unknown component name
+
+### Test 6: ConfigAccessor - Parameter Access and Modification
+- Setup: Create OpsConfigFile with redis component
+- Expected: `accessor.setParameterValue('redis', 'REDIS_URL', 'redis://localhost')` succeeds
+- Verify: `accessor.getParameterValue('redis', 'REDIS_URL') === 'redis://localhost'`
+
+### Test 7: OpsConfigFile - Component Management
+- Setup: Create empty OpsConfigFile instance
+- Expected: `config.addComponent(new ComponentConfig('test'))` succeeds
+- Verify: `config.getComponent('test')` returns the added component
+
+## Dependencies
+- Bun TOML import assertion (already available)
+- Existing `@clack/prompts` for UI
+- Configuration file: `all-config-parameters.toml`
+- Reference: `ops-managed-components.md`
+- Result type pattern for error handling
+
+## Verification Method
+- Unit tests for each class and method
+- Integration test with example TOML file
+- Test coverage for all public methods
+- Manual verification with `bun run start`
+
+## Code Style Requirements
+- TypeScript strict mode enabled
+- No comments unless explicitly requested
+- ES Module syntax
+- 2-space indentation
+- Single quotes for strings
+- Semicolons required
+
+## Estimated Effort
+
+| Task | Human Time | LLM Time |
+|------------------------------------------------|----------------|-------------|
+| Task 1: Define Configuration Classes and Types | 45 min | 8 min |
+| Task 2: Implement ConfigParser Class | 1.5 hours | 15 min |
+| Task 3: Implement ConfigValidator Class | 45 min | 10 min |
+| Task 4: Implement ConfigAccessor Class | 30 min | 7 min |
+| Task 5: Update Main Entry Point | 30 min | 5 min |
+| Testing | 1.5 hours | 20 min |
+| **Total** | **~5-6 hours** | **~65 min** |
+
+**Note:** LLM time estimates assume single-pass generation with minimal iteration. Actual LLM time may vary based on context window usage, error correction cycles, and review iterations.
+
+## Notes
+- Focus on reading configuration first (REQ-FUNC-001-V1)
+- Editing capability is REQ-FUNC-002-V1 (separate requirement)
+- Use Object-Oriented Programming with classes and methods
+- Each class encapsulates a single responsibility
+- Public methods provide clean API, private methods handle implementation details
+- Follow SOLID principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion
diff --git a/.agent/spec/2026-03-26-display-current-configuration.md b/.agent/spec/2026-03-26-display-current-configuration.md
new file mode 100644
index 0000000..659e91f
--- /dev/null
+++ b/.agent/spec/2026-03-26-display-current-configuration.md
@@ -0,0 +1,164 @@
+# Display current ops configuration
+
+## Table of Contents
+<!-- TOC -->
+* [1. Introduction](#1-introduction)
+ * [1.1 Document Purpose](#11-document-purpose)
+ * [1.2 Product Scope](#12-product-scope)
+ * [1.3 Definitions, Acronyms, and Abbreviations](#13-definitions-acronyms-and-abbreviations)
+ * [1.4 References](#14-references)
+ * [1.5 Document Overview](#15-document-overview)
+* [2. Product Overview](#2-product-overview)
+ * [2.1 Product Perspective](#21-product-perspective)
+ * [2.2 Product Functions](#22-product-functions)
+ * [2.3 Product Constraints](#23-product-constraints)
+ * [2.4 User Characteristics](#24-user-characteristics)
+ * [2.5 Assumptions and Dependencies](#25-assumptions-and-dependencies)
+ * [2.6 Apportioning of Requirements](#26-apportioning-of-requirements)
+* [3. Requirements](#3-requirements)
+ * [3.1 External Interfaces](#31-external-interfaces)
+ * [3.2 Functional](#32-functional)
+ * [3.3 Quality of Service](#33-quality-of-service)
+ * [3.4 Compliance](#34-compliance)
+ * [3.5 Design and Implementation](#35-design-and-implementation)
+ * [3.6 AI/ML](#36-aiml)
+* [4. Verification](#4-verification)
+* [5. Appendixes](#5-appendixes)
+<!-- TOC -->
+
+## 1. Introduction
+This document describes how the configurator tool must show the current configuration of Apache OpenServerless.
+
+### 1.1 Document Purpose
+
+- Apache OpenServerless configuration is done with the `ops` command in the terminal.
+
+### 1.2 Product Scope
+
+- The ops configurator is an aid for a human operator that wants to configure OpenServerless.
+- The ops configuration must communicate with the ops CLI as described here - [how-to-configure-ops-via-cli.md](../wiki/how-to-configure-ops-via-cli.md)
+
+### 1.3 Definitions, Acronyms, and Abbreviations
+
+| Term | Definition |
+|------|---------------------------------------------|
+| ops | a CLI client for the OpenServerless backend |
+
+### 1.4 References
+
+- [how-to-configure-ops-via-cli.md](../wiki/how-to-configure-ops-via-cli.md)
+
+### 1.5 Document Overview
+None
+
+## 2. Product Overview
+None
+
+### 2.1 Product Perspective
+None
+
+### 2.2 Product Functions
+
+
+### 2.3 Product Constraints
+
+- Code must be strongly typed.
+- Components must be reusable.
+- Code must be clearly commented.
+- Code should be object oriented.
+
+### 2.4 User Characteristics
+- The language of the human user is US English
+
+### 2.5 Assumptions and Dependencies
+None
+
+### 2.6 Apportioning of Requirements
+None
+
+## 3. Requirements
+
+### Format of the requirements
+
+Requirement ID schema and traceability:
+- ID format: REQ-[AREA]-[NNN]-[VER] (optional -[VER] if versioned), where AREA ∈ {FUNC, INT, PERF, SEC, REL, AVAIL, OBS, COMP, INST, BUILD, DIST, MAINT, REUSE, PORT, COST, DEAD, POC, CM, ML}.
+- Uniqueness: IDs must be unique and immutable; changes increment -[VER] and are recorded in Revision History.
+- Traceability: Each test artifact may reference the requirement ID.
+
+💡 Tips:
+- Make each requirement testable and unambiguous, using standard metrics and avoiding vague terms (e.g., “user-friendly,” “fast”).
+
+### ID: REQ-FUNC-XXX
+- Title: Short title, representative of the requirement...
+- Statement: The system shall...
+- Rationale: ...
+- Acceptance Criteria: ...
+- Verification Method: Test | Analysis | Inspection | Demonstration | Other
+- More Information: Additional context. Links to related artifacts.
+
+
+
+### ID: REQ-FUNC-001-V1
+- Title: Read the ops configuration key-value tuples
+- Statement:
+ - The base source file is `util/config/configurator/configurator.ts`, additional files can be added if needed.
+ 1. The system shall read the ops configuration key-value tuples.
+ 2. An initial input configuration is stored in a toml file. Example:[example-001-configuration-file.md](../wiki/example-001-configuration-file.md)
+ 3. OpenServerless manages the components listed in the file [ops-managed-components.md](../wiki/ops-managed-components.md)
+ 4. The application configuration format is similar to the one in the `util/config/configurator/all-config-parameters.toml file`.
+ - The section in bracket targets the managed component.
+ - For every managed component, one or more key/value pairs can exist.
+
+- Rationale: the toml file is a read file that drives the behaviour of the TUI (Terminal User Interface).
+- Acceptance Criteria: Read the ops configuration key-value tuples
+- Verification Method: Test | Analysis | Inspection | Demonstration | Other
+- More Information: Additional context. Links to related artifacts.
+
+### ID: REQ-FUNC-002-V1
+- Title: Edit the configuration of the managed components
+- Statement:
+ - The configurator needs an input file that drives the whole configuration process.
+ - The configurator exploits a a partial configuration file.
+ - The name of the partial configuration file is the same of the input configuration file but the extension is .tmp
+ - When the configurator is lunched, if a partial configuration file is present, the application has to ask the user if he wants to continue where he left or if he wants start from scratch.
+ - The partial configuration must be stored in a RAM data structure.
+ - This data structure will be a object oriented programming class with clean methods to read and write data in memory and to load from the disk, save to the disk. The name of the temporary file is an attribute of the class.
+ - This data structure will an abstraction layer to read and write the partial configuration file.
+ - This data structure will be subsequently used to show the user the current configuration. It will be used also to store the partial configuration in a file so that if the user exits the application, at the next run it will be able to continue where he left.
+ - Then the block below is iterated until the user chooses to exit.
+ - The list of all the managed components has to be shown.
+ - The user must be able to navigate the list using:
+ - the cursor up and down arrows to move from a managed component to an adjacent one
+ - the ENTER key to start the configuration of the current managed components.
+ - While editing values for the current managed component, the only acceptable control keys are:
+ - left arrow to go left
+ - right arrow to go right
+ - ENTER to confirm the user input value.
+ - ESC to discard the user input value. If the user discards a value, the value must be the latest input by the user. If the user never changed the value, the value must be the one initially read at the application launch.
+ - Home to go to the beginning of the field
+ - End to go to the end of the field
+ - Every modification has to be saved in RAM and from here to the partial configuration file.
+ - To exit the editing of the current managed component, the user has to hit ESC.
+ - After exiting the editing of any managed components, the list of the managed products is shown.
+ - If the user wants to exit the configuration editing, the application has to ask the use if they want to apply the configuration directly, create a configuration file that can be used later as a base, or discard all.
+
+ - Additional specifications #1:
+ - For every parameter the values below have to be stored:
+ - A label to explain to the user the meaning of the parameter
+ - The `initialvalue` read when the application was started.
+ - The `previousUserInputValue` that the user wrote to set the variable.
+ - The `currentUserInputValue` that the user wrote to set the variable.
+ - The format in the configuration file should be: KEY={label="<LABEL>", userInputValue="<USER-INPUT-VALUE>"}
+ - The configuration of the current product has to be fully shown in a table that for each KEY displays:
+ - The label
+ - The `initialvalue` if present, `<EMPTY>` otherwise
+ - The `previousUserInputValue` if input, `<EMPTY>` otherwise
+ - The `currentUserInputValue` if input, `<EMPTY>` otherwise
+
+
+
+
+
+
+
+
diff --git a/.agent/spec/design.md b/.agent/spec/design.md
new file mode 100644
index 0000000..e69de29
diff --git a/.agent/spec/requirement.md b/.agent/spec/requirement.md
new file mode 100644
index 0000000..e69de29
diff --git a/.agent/spec/tasks.md b/.agent/spec/tasks.md
new file mode 100644
index 0000000..297d411
--- /dev/null
+++ b/.agent/spec/tasks.md
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.agent/wiki/architecture.md b/.agent/wiki/architecture.md
new file mode 100644
index 0000000..e69de29
diff --git a/.agent/wiki/domain.md b/.agent/wiki/domain.md
new file mode 100644
index 0000000..b8ef2dd
--- /dev/null
+++ b/.agent/wiki/domain.md
@@ -0,0 +1,3 @@
+# Domain
+- Apache OpenServerless is an abstraction that allows running functions masking the complexity of Kubernetes and other systems.
+- Other systems such as PostgreSQL are present.
\ No newline at end of file
diff --git a/.agent/wiki/example-001-configuration-file.md b/.agent/wiki/example-001-configuration-file.md
new file mode 100644
index 0000000..a36e996
--- /dev/null
+++ b/.agent/wiki/example-001-configuration-file.md
@@ -0,0 +1,135 @@
+An example of configuration file follows:
+
+```toml
+# Content: for all the components that Openserverless needs in its configuration,
+# we create a suitable configuration
+# This is a TOML document
+# To obtain some of these arguments, use the command
+# ops -config -d | awk -F= '/=/{print $1}'
+
+[components.redis]
+REDIS_URL= {label="Redis URL", initialValue="", userInputValue=""}
+REDIS_SERVICE= {label="Redis Service", initialValue="", userInputValue=""}
+REDIS_PREFIX= {label="Redis Prefix", initialValue="", userInputValue=""}
+REDIS_PASSWORD= {label="Redis password", initialValue="", userInputValue=""}
+# SECRET_REDIS_DEFAULT
+# REDIS_PORT
+# OPSTUTORIAL_SECRET_REDIS
+# REDIS_PROVIDER
+# REDIS_ALT_URL
+# SECRET_REDIS_NUVOLARIS
+# OPERATOR_COMPONENT_REDIS
+# DEVEL_SECRET_REDIS
+
+[components.postgres]
+OPSTUTORIAL_SECRET_POSTGRES=""
+POSTGRES_PORT=""
+POSTGRES_DATABASE=""
+POSTGRES_USERNAME=""
+POSTGRES_PASSWORD=""
+SECRET_POSTGRES_REPLICA=""
+
+
+
+#OPERATOR_CONFIG_ALERTGMAIL
+#OPENWHISK_TIME_LIMIT_MAX
+#POSTGRES_PORT
+#OPERATOR_COMPONENT_CRON
+#REDIS_PORT
+#S3_BUCKET_STATIC
+#S3_PORT
+#OPERATOR_COMPONENT_INVOKER
+#OPERATOR_COMPONENT_REDIS
+#OPERATOR_CONFIG_TLSEMAIL
+#OPENWHISK_INVOKER_CONTAINER_POOL_MEMORY
+#DEVEL_SECRET_MILVUS
+#OPSTUTORIAL_SECRET_MONGODB
+#IMAGES_STREAMER
+#OPSTUTORIAL_SECRET_MILVUS
+#IMAGES_INVOKER
+#SECRET_NUVOLARIS_METADATA
+#OPERATOR_COMPONENT_ETCD
+#SECRET_MILVUS_ROOT
+#S3_ACCESS_KEY
+#OPSTUTORIAL_SECRET_COUCHDB
+#AUTH
+#SECRET_MONGODB_ADMIN
+#MILVUS_PORT
+#MILVUS_TOKEN
+#SECRET_REDIS_DEFAULT
+#OPERATOR_COMPONENT_KAFKA
+#OPERATOR_CONFIG_HOSTPROTOCOL
+#DEVEL_SECRET_REDIS
+#IMAGES_OPERATOR
+#SECRET_OPENWHISK_SYSTEM
+#MILVUS_HOST
+#S3_API_URL
+#OPERATOR_COMPONENT_TLS
+#DEVEL_SECRET_OPENWHISK
+#OPSDEV_APIHOST
+#OPERATOR_CONFIG_TOLERATIONS
+#MINIO_CONFIG_INGRESS_S3
+#SECRET_COUCHDB_INVOKER
+#OPERATOR_COMPONENT_ZOOKEEPER
+#SECRET_MINIO_NUVOLARIS
+#OPERATOR_COMPONENT_AM
+#CONFIGURED
+#DEVEL_SECRET_MINIO
+#HELLO
+#OPSTUTORIAL_SECRET_MINIO
+#STATUS_LAST
+#OPERATOR_COMPONENT_MILVUS
+#REDIS_PROVIDER
+#SECRET_MONGODB_NUVOLARIS
+#OPERATOR_COMPONENT_STATIC
+#SECRET_ETCD_ROOT
+#OPERATOR_CONFIG_APIHOST
+#OPS_COREUTILS
+#SECRET_COUCHDB_ADMIN
+#USER_V1_API_URL
+#OPSDEV_USERNAME
+#SECRET_POSTGRES_NUVOLARIS
+#S3_PROVIDER
+#ANOTHER
+#OPERATOR_CONFIG_AFFINITY
+#OPERATOR_COMPONENT_POSTGRES
+#S3_HOST
+#SECRET_MILVUS_S3
+#REDIS_URL
+#S3_SECRET_KEY
+#DEVEL_SECRET_POSTGRES
+#SECRET_MILVUS_NUVOLARIS
+#OPERATOR_COMPONENT_MINIO
+#OPERATOR_CONFIG_SLIM
+#POSTGRES_CONFIG_REPLICAS
+#REDIS_PREFIX
+#ETCD_CONFIG_REPLICAS
+#OPERATOR_CONFIG_ALERTSLACK
+#OPSTUTORIAL_SECRET_REDIS
+#STATIC_CONTENT_URL
+#IMAGES_CONTROLLER
+#USER_REST_API_URL
+#DEVEL_SECRET_MONGODB
+#MONGODB_URL
+#HELLO3
+#SECRET_POSTGRES_ADMIN
+#OPERATOR_COMPONENT_MONGODB
+#DEVEL_SECRET_COUCHDB
+#POSTGRES_HOST
+#OPERATOR_COMPONENT_PROMETHEUS
+#REDIS_ALT_URL
+#REDIS_PASSWORD
+#OPERATOR_COMPONENT_REGISTRY
+#OPSTUTORIAL_SECRET_OPENWHISK
+#REGISTRY_CONFIG_SECRET_PUSH_PULL
+#OPSDEV_HOST
+#SECRET_MINIO_ADMIN
+#SECRET_OPENWHISK_NUVOLARIS
+#SECRET_REDIS_NUVOLARIS
+#SECRET_COUCHDB_CONTROLLER
+#OPERATOR_COMPONENT_QUOTA
+#REDIS_SERVICE
+#S3_BUCKET_DATA
+#IMAGES_SYSTEMAPI
+#MILVUS_DB_NAME
+```
diff --git a/.agent/wiki/how-to-configure-ops-via-cli.md b/.agent/wiki/how-to-configure-ops-via-cli.md
new file mode 100644
index 0000000..a3c4131
--- /dev/null
+++ b/.agent/wiki/how-to-configure-ops-via-cli.md
@@ -0,0 +1,54 @@
+# How can yoy configure ops via the CLI?
+
+It's important to understand how the `ops` command works.
+
+From the command line, run the following command:
+
+```bash
+ops -config -d
+```
+
+it returns the configuration of `ops` command. The output is a text file containing a number of key-value tuples separated by an `=` sign and that looks like this:
+
+```terminaloutput
+<omitted>
+S3_API_URL=http://localhost:9000
+<omitted>
+POSTGRES_PORT=5432
+<omitted>
+OPERATOR_COMPONENT_MILVUS=true
+OPERATOR_COMPONENT_KAFKA=false
+<omitted>
+S3_PROVIDER=minio
+<omitted>
+```
+
+You can also set the value of a single parameter by running:
+
+```bash
+ops -config HELLO=123
+```
+
+and read the value of a single parameter from the output of the command:
+
+```bash
+ops -config HELLO
+```
+
+and remove the parameter by running:
+
+```bash
+ops -config -r HELLO
+```
+
+Finally, you can check if a parameter doesn’t exist anymore by running:
+
+```bash
+ops -config HELLO
+```
+
+which returns an error because you removed the parameter:
+
+```terminaloutput
+error: invalid key: 'HELLO' - key does not exist
+```
diff --git a/.agent/wiki/ops-managed-components.md b/.agent/wiki/ops-managed-components.md
new file mode 100644
index 0000000..774eb89
--- /dev/null
+++ b/.agent/wiki/ops-managed-components.md
@@ -0,0 +1,12 @@
+# OpenServerless managed components
+
+- redis
+- ferretdb
+- cron
+- prometheus
+- slack
+- mail
+- affinity
+- tolerations
+- quota
+- alert manager
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1ba5132
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,147 @@
+# Apache OpenServerless Task Agent Guidelines
+
+This file provides instructions for agentic coding agents working in this repository.
+
+
+## Setup commands
+
+- Install Dependencies: `bun install`
+- Start development server: ``
+- Build the configurator utility: `cd util/config/configurator && bun run build`
+
+## Testing instructions
+- Run tests: `bun test`
+
+- Run a single test: `bun test util/config/configurator/tests/index.test.ts`
+- Run tests with verbose output `bun test --reporter=verbose`
+
+## Running the application
+- Run the configurator utility: `cd util/config/configurator && bun run start`
+- Run the configurator utility with a specified configuration file: `bun run start -- <config-file>`
+- Run the configurator utility with a specified configuration file overriding existing values: `bun run start -- <config-file> [--override]`
+
+
+## Code style
+
+- TypeScript strict mode
+
+### Language & Version
+- Primary language: TypeScript
+- Runtime: Bun (Node.js compatible)
+- Target ES version: ES2022+
+- Module system: ES Modules (`"type": "module"` in package.json)
+
+### File Organization
+- TypeScript files: `.ts` extension
+- Test files: `*.test.ts` located in `__tests__` or `tests` directories
+- Configuration: TOML (`.toml`) or JSON (`.json`)
+- Operations definitions: YAML (`.yml`)
+
+### Imports
+1. **Order**:
+ - Built-in Node.js/Bun modules (e.g., `import { $ } from "bun";`)
+ - Third-party libraries (e.g., `import { select } from "@clack/prompts";`)
+ - Local application files (relative paths)
+
+2. **Syntax**:
+ - Use ES module syntax: `import { foo } from "bar";`
+ - Default imports: `import foo from "bar";`
+ - Avoid `require()` syntax
+
+3. **Specific Rules**:
+ - Import types separately when needed: `import type { TypeName } from "./types";`
+ - Group related imports together
+ - No unused imports allowed
+
+### Formatting
+- **Indentation**: 2 spaces (not tabs)
+- **Line length**: Maximum 100 characters (prefer 80-100)
+- **Semicolons**: Required (use semicolons to terminate statements)
+- **Quotes**:
+ - Single quotes (`'`) for strings
+ - Template literals (`` ` ``) for multi-line or interpolated strings
+ - Double quotes (`"`) only when required by JSON or external specifications
+- **Commas**: Trailing commas in multi-line objects/arrays
+- **Braces**:
+ - Opening brace on the same line as the statement
+ - Closing brace on its own line
+ - No braces for single-line conditionals when it improves readability
+
+### Types
+- **Type Annotations**:
+ - Always annotate function parameters
+ - Annotate return values for public functions
+ - Use type inference for local variables when obvious
+- **Interfaces vs Types**:
+ - Use `interface` for object shapes that may be extended
+ - Use `type` for unions, primitives, complex mapped types
+- **Strictness**: Enable strict TypeScript options (null checks, no implicit any, etc.)
+
+### Naming Conventions
+- **Files & Directories**: kebab-case (e.g., `configurator.ts`, `all-config-parameters.toml`)
+- **Variables & Functions**: camelCase (e.g., `readPositionalFile`, `isInputConfigValid`)
+- **Constants**: UPPER_SNAKE_CASE (e.g., `HelpMsg`, `AdditionalArgsMsg`)
+- **Types & Interfaces**: PascalCase (e.g., `OpsConfig`, `PromptData`)
+- **Classes**: PascalCase (though minimal class usage in this codebase)
+- **Boolean Variables**: Prefix with `is`, `has`, `should`, `can` (e.g., `isValid`, `hasError`)
+
+### Error Handling
+- **Early Returns**: Handle error conditions first with early returns
+- **Async Functions**: Use try/catch for async operations that can fail
+- **Bun Specific**:
+ - Use `process.exit(1)` for error exits
+ - Use `process.exit(0)` for successful exits
+ - Check `success` property on result objects from utils
+- **Messages**:
+ - Export constant error messages (as seen with `HelpMsg`, `NotValidJsonMsg`, etc.)
+ - Use descriptive, user-friendly error messages
+ - Log warnings with `console.warn()`, errors with `console.error()`
+
+### Specific Patterns from Codebase
+- **Configuration Validation**: Separate validation functions (e.g., `isInputConfigValid`)
+- **File Operations**:
+ - Read → Parse → Validate pattern
+ - Return objects with `{ success: boolean, message?: string, ...data }` shape
+- **CLI Argument Parsing**: Use `util.parseArgs` with strict mode and positionals
+- **Interactive Prompts**: Use `@clack/prompts` with proper cancellation handling
+- **Shell Commands**: Use Bun's `$` helper for shell commands with `.quiet()` to suppress output
+
+### Testing
+- **Framework**: Bun's built-in test framework (`bun:test`)
+- **File Naming**: `*.test.ts`
+- **Structure**:
+ - Use `describe()` for test suites
+ - Use `test()` for individual tests
+ - Use `expect()` for assertions
+- **Mocking**: Minimal mocking; prefer testing actual behavior
+- **Async Tests**: Return promises or use `async`/`await`
+
+### Comments
+- Use JSDoc-style comments for public APIs
+- Use `//` for inline comments explaining why (not what)
+- Keep comments up-to-date; delete outdated comments
+- TODO comments should include GitHub issue references when possible
+- When adding bash scripts in the documentation, always keep commands separate (one command per ```bash section)
+
+### Security
+- Never log secrets or credentials
+- Use `password` prompt type for sensitive inputs
+- Validate and sanitize all external inputs
+- Follow the principle of least privilege
+
+## Task Tracking
+
+For any task that involves more than one non-trivial step (multi-file changes, investigations with uncertain outcomes, features spanning multiple components), maintain a TODO list using the `TaskCreate` / `TaskUpdate` / `TaskList` tools:
+
+1. **Start**: call `TaskCreate` to create one task entry per step before writing any code.
+2. **Progress**: call `TaskUpdate` (status `in_progress`) when starting a step, `completed` when it is done.
+3. **Visibility**: after completing a task, call `TaskList` to confirm no steps are left open.
+
+Keep task titles short and action-oriented ("Add `type` field to ConfigParameter", "Update parseParameter", etc.).
+Do **not** create tasks for trivial single-file edits or quick answers.
+
+## Additional Notes
+- This repository uses Bun as its primary runtime/package manager
+- Configuration is driven by TOML files and environment variables
+- The `opsfile.yml` defines operational tasks but is not part of the main TypeScript codebase
+- When modifying the configurator utility, remember to rebuild after changes
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..db01606
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,46 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "dependencies": {
+ "@clack/prompts": "^1.0.1",
+ "js-toml": "^1.0.2",
+ "toml": "^3.0.0",
+ },
+ },
+ },
+ "packages": {
+ "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.0", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA=="],
+
+ "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.1", "", { "dependencies": { "@chevrotain/gast": "11.1.1", "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw=="],
+
+ "@chevrotain/gast": ["@chevrotain/gast@11.1.1", "", { "dependencies": { "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg=="],
+
+ "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.1", "", {}, "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg=="],
+
+ "@chevrotain/types": ["@chevrotain/types@11.1.1", "", {}, "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw=="],
+
+ "@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="],
+
+ "@clack/core": ["@clack/core@1.0.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g=="],
+
+ "@clack/prompts": ["@clack/prompts@1.0.1", "", { "dependencies": { "@clack/core": "1.0.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q=="],
+
+ "chevrotain": ["chevrotain@11.1.1", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", "@chevrotain/regexp-to-ast": "11.1.1", "@chevrotain/types": "11.1.1", "@chevrotain/utils": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ=="],
+
+ "core-js-pure": ["core-js-pure@3.48.0", "", {}, "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw=="],
+
+ "js-toml": ["js-toml@1.0.2", "", { "dependencies": { "chevrotain": "^11.0.3", "xregexp": "^5.1.1" } }, "sha512-/7IQ//bzn2a/5IDazPUNzlW7bsjxS51cxciYZDR+Z+3Le60yzT0YfI8KOWqTtBcZkXXVklhWd2OuGd8ZksB0wQ=="],
+
+ "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
+
+ "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="],
+
+ "xregexp": ["xregexp@5.1.2", "", { "dependencies": { "@babel/runtime-corejs3": "^7.26.9" } }, "sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw=="],
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7a76a42
--- /dev/null
+++ b/package.json
@@ -0,0 +1,7 @@
+{
+ "dependencies": {
+ "@clack/prompts": "^1.0.1",
+ "js-toml": "^1.0.2",
+ "toml": "^3.0.0"
+ }
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..d440153
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,132 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@clack/prompts':
+ specifier: ^1.0.1
+ version: 1.0.1
+ js-toml:
+ specifier: ^1.0.2
+ version: 1.0.2
+ toml:
+ specifier: ^3.0.0
+ version: 3.0.0
+
+packages:
+
+ '@babel/runtime-corejs3@7.29.0':
+ resolution: {integrity: sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==}
+ engines: {node: '>=6.9.0'}
+
+ '@chevrotain/cst-dts-gen@11.1.1':
+ resolution: {integrity: sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==}
+
+ '@chevrotain/gast@11.1.1':
+ resolution: {integrity: sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==}
+
+ '@chevrotain/regexp-to-ast@11.1.1':
+ resolution: {integrity: sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==}
+
+ '@chevrotain/types@11.1.1':
+ resolution: {integrity: sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==}
+
+ '@chevrotain/utils@11.1.1':
+ resolution: {integrity: sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==}
+
+ '@clack/core@1.0.1':
+ resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==}
+
+ '@clack/prompts@1.0.1':
+ resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==}
+
+ chevrotain@11.1.1:
+ resolution: {integrity: sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==}
+
+ core-js-pure@3.48.0:
+ resolution: {integrity: sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==}
+
+ js-toml@1.0.2:
+ resolution: {integrity: sha512-/7IQ//bzn2a/5IDazPUNzlW7bsjxS51cxciYZDR+Z+3Le60yzT0YfI8KOWqTtBcZkXXVklhWd2OuGd8ZksB0wQ==}
+
+ lodash-es@4.17.23:
+ resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
+ toml@3.0.0:
+ resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
+
+ xregexp@5.1.2:
+ resolution: {integrity: sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==}
+
+snapshots:
+
+ '@babel/runtime-corejs3@7.29.0':
+ dependencies:
+ core-js-pure: 3.48.0
+
+ '@chevrotain/cst-dts-gen@11.1.1':
+ dependencies:
+ '@chevrotain/gast': 11.1.1
+ '@chevrotain/types': 11.1.1
+ lodash-es: 4.17.23
+
+ '@chevrotain/gast@11.1.1':
+ dependencies:
+ '@chevrotain/types': 11.1.1
+ lodash-es: 4.17.23
+
+ '@chevrotain/regexp-to-ast@11.1.1': {}
+
+ '@chevrotain/types@11.1.1': {}
+
+ '@chevrotain/utils@11.1.1': {}
+
+ '@clack/core@1.0.1':
+ dependencies:
+ picocolors: 1.1.1
+ sisteransi: 1.0.5
+
+ '@clack/prompts@1.0.1':
+ dependencies:
+ '@clack/core': 1.0.1
+ picocolors: 1.1.1
+ sisteransi: 1.0.5
+
+ chevrotain@11.1.1:
+ dependencies:
+ '@chevrotain/cst-dts-gen': 11.1.1
+ '@chevrotain/gast': 11.1.1
+ '@chevrotain/regexp-to-ast': 11.1.1
+ '@chevrotain/types': 11.1.1
+ '@chevrotain/utils': 11.1.1
+ lodash-es: 4.17.23
+
+ core-js-pure@3.48.0: {}
+
+ js-toml@1.0.2:
+ dependencies:
+ chevrotain: 11.1.1
+ xregexp: 5.1.2
+
+ lodash-es@4.17.23: {}
+
+ picocolors@1.1.1: {}
+
+ sisteransi@1.0.5: {}
+
+ toml@3.0.0: {}
+
+ xregexp@5.1.2:
+ dependencies:
+ '@babel/runtime-corejs3': 7.29.0
diff --git a/util/config/README.md b/util/config/README.md
index 27988dd..a4faff5 100644
--- a/util/config/README.md
+++ b/util/config/README.md
@@ -8,7 +8,13 @@ The entrypoint is the `configurator/index.ts` file. It simply calls the configur
All the logic is inside the `configurator/configurator.ts` file. Of course, you can add more files
and change the structure as you see fit.
-Inside the `configurator` folder, first install dependencies:
+From the root of the project, go to the `util/config/configurator` folder:
+
+```bash
+cd util/config/configurator
+```
+
+First install the dependencies:
```bash
bun install
@@ -20,6 +26,161 @@ In the `package.json` file there are a couple of scripts. The `start` script wil
bun run start
```
+### How can the ops command be configured?
+
+Before understanding the configurator, let's see how you can manage the ops configuration.
+
+From the command line, run the following command:
+
+```bash
+ops -config -d
+```
+
+it returns the configuration of `ops` command. The output is a text file containing a number of key-value tuples separated by an `=` sign and that looks like this:
+
+```terminaloutput
+<omitted>
+S3_API_URL=http://localhost:9000
+<omitted>
+POSTGRES_PORT=5432
+<omitted>
+OPERATOR_COMPONENT_MILVUS=true
+OPERATOR_COMPONENT_KAFKA=false
+<omitted>
+S3_PROVIDER=minio
+<omitted>
+```
+
+You can also set the value of a single parameter by running:
+
+```bash
+ops -config HELLO=123
+```
+
+and read the value of a single parameter from the output of the command:
+
+```bash
+ops -config HELLO
+```
+
+and remove the parameter by running:
+
+```bash
+ops -config -r HELLO
+```
+
+Finally, you can check if a parameter doesn’t exist anymore by running:
+
+```bash
+ops -config HELLO
+```
+
+which returns an error because you removed the parameter:
+
+```terminaloutput
+error: invalid key: 'HELLO' - key does not exist
+```
+
+### How does the configurator work?
+
+Now that you know how you can manage the `ops` configuration, let's see how the configurator works.
+
+When you run the configurator with a valid configuration file,
+
+If you run the configurator without an argument to specify a configuration file, a help message is shown:
+
+```bash
+bun run start
+```
+
+otherwise, you can specify a configuration file:
+
+```bash
+bun run start your-configuraton-file.json
+```
+
+If the configuration file is valid, the configurator will run the command
+
+```bash
+ops -config -d
+```
+
+and will compare the output with the configuration file.
+
+So, if you store the following content in a json file called `hello-config.json`:
+
+```json
+{
+ "HELLO": {
+ "type": "string"
+ }
+}
+```
+
+the command
+
+```bash
+ops util config <PATH-TO-THE-CoNFIGURATION-FILE>/hello-config.json
+```
+
+launches an interactive configurator that sequentially reads the configuration file.
+For each item found, it prompts the user to assign a value, then proceeds to execute the `ops -config ` command.
+
+For example, the `HELLO` parameter is of type `string` and the configurator asks for a value that it will assign to it:
+
+```terminaloutput
+Enter value for HELLO (string)
+
+World
+```
+and then the configurator runs the command:
+
+```terminaloutput
+ops -config HELLO=World
+```
+
+This is how the configurator can interact with the `ops` command.
+
+## New functionality: show current configuration
+
+- Show the current configuration:
+ - show only the parameters needed for the components below:
+ - redis
+ - ferretdb
+ - cron
+ - prometheus
+ - slack
+ - mail
+ - affinity
+ - tolerations
+ - quota
+ - alert manager
+
+
+To do this, we need to know for each component what are its parameters.
+
+### redis
+$ ops -config -d grep -i REDIS
+
+To extract the names of the parameters,
+
+$ ops -config -d | awk -F= '/=/{print $1}'
+
+
+
+
+### Development with WebStorm:
+
+Install and configure WebStorm to use the bun as described here: https://www.jetbrains.com/help/webstorm/bun.html
+
+#### To run the configurator with a valid config file from the command line:
+
+- change directory to `/util/config/configurator`
+- run the following command:
+ ```bash
+ `ops util config `./tests/fixtures/valid.json
+ ```
+
### Tests:
There are unit tests inside the `configurator/test` folder. You can run them with the following command:
@@ -37,3 +198,17 @@ bun run build
```
It will generate the `configurator.js` file and move it in the parent, where it can be used by the opsfile task.
+
+
+
+
+**** TODO SISTEMARE DA QUI
+
+```bash
+cd /home/daniele/projects/github/apache/openserverless-task/util/config/configurator
+```
+
+
+```bash
+bun run start all-config-parameters.toml
+```
diff --git a/util/config/configurator.js b/util/config/configurator.js
deleted file mode 100644
index 5ba4c1e..0000000
--- a/util/config/configurator.js
+++ /dev/null
@@ -1,906 +0,0 @@
-// @bun
-var __create = Object.create;
-var __getProtoOf = Object.getPrototypeOf;
-var __defProp = Object.defineProperty;
-var __getOwnPropNames = Object.getOwnPropertyNames;
-var __hasOwnProp = Object.prototype.hasOwnProperty;
-var __toESM = (mod, isNodeMode, target) => {
- target = mod != null ? __create(__getProtoOf(mod)) : {};
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
- for (let key of __getOwnPropNames(mod))
- if (!__hasOwnProp.call(to, key))
- __defProp(to, key, {
- get: () => mod[key],
- enumerable: true
- });
- return to;
-};
-var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
-
-// node_modules/sisteransi/src/index.js
-var require_src = __commonJS((exports, module) => {
- var ESC = "\x1B";
- var CSI = `${ESC}[`;
- var beep = "\x07";
- var cursor = {
- to(x, y) {
- if (!y)
- return `${CSI}${x + 1}G`;
- return `${CSI}${y + 1};${x + 1}H`;
- },
- move(x, y) {
- let ret = "";
- if (x < 0)
- ret += `${CSI}${-x}D`;
- else if (x > 0)
- ret += `${CSI}${x}C`;
- if (y < 0)
- ret += `${CSI}${-y}A`;
- else if (y > 0)
- ret += `${CSI}${y}B`;
- return ret;
- },
- up: (count = 1) => `${CSI}${count}A`,
- down: (count = 1) => `${CSI}${count}B`,
- forward: (count = 1) => `${CSI}${count}C`,
- backward: (count = 1) => `${CSI}${count}D`,
- nextLine: (count = 1) => `${CSI}E`.repeat(count),
- prevLine: (count = 1) => `${CSI}F`.repeat(count),
- left: `${CSI}G`,
- hide: `${CSI}?25l`,
- show: `${CSI}?25h`,
- save: `${ESC}7`,
- restore: `${ESC}8`
- };
- var scroll = {
- up: (count = 1) => `${CSI}S`.repeat(count),
- down: (count = 1) => `${CSI}T`.repeat(count)
- };
- var erase = {
- screen: `${CSI}2J`,
- up: (count = 1) => `${CSI}1J`.repeat(count),
- down: (count = 1) => `${CSI}J`.repeat(count),
- line: `${CSI}2K`,
- lineEnd: `${CSI}K`,
- lineStart: `${CSI}1K`,
- lines(count) {
- let clear = "";
- for (let i = 0;i < count; i++)
- clear += this.line + (i < count - 1 ? cursor.up() : "");
- if (count)
- clear += cursor.left;
- return clear;
- }
- };
- module.exports = { cursor, scroll, erase, beep };
-});
-
-// node_modules/picocolors/picocolors.js
-var require_picocolors = __commonJS((exports, module) => {
- var argv = process.argv || [];
- var env = process.env;
- var isColorSupported = !(("NO_COLOR" in env) || argv.includes("--no-color")) && (("FORCE_COLOR" in env) || argv.includes("--color") || process.platform === "win32" || import.meta.require != null && import.meta.require("tty").isatty(1) && env.TERM !== "dumb" || ("CI" in env));
- var formatter = (open, close, replace = open) => (input) => {
- let string = "" + input;
- let index = string.indexOf(close, open.length);
- return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
- };
- var replaceClose = (string, close, replace, index) => {
- let result = "";
- let cursor = 0;
- do {
- result += string.substring(cursor, index) + replace;
- cursor = index + close.length;
- index = string.indexOf(close, cursor);
- } while (~index);
- return result + string.substring(cursor);
- };
- var createColors = (enabled = isColorSupported) => {
- let init = enabled ? formatter : () => String;
- return {
- isColorSupported: enabled,
- reset: init("\x1B[0m", "\x1B[0m"),
- bold: init("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
- dim: init("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
- italic: init("\x1B[3m", "\x1B[23m"),
- underline: init("\x1B[4m", "\x1B[24m"),
- inverse: init("\x1B[7m", "\x1B[27m"),
- hidden: init("\x1B[8m", "\x1B[28m"),
- strikethrough: init("\x1B[9m", "\x1B[29m"),
- black: init("\x1B[30m", "\x1B[39m"),
- red: init("\x1B[31m", "\x1B[39m"),
- green: init("\x1B[32m", "\x1B[39m"),
- yellow: init("\x1B[33m", "\x1B[39m"),
- blue: init("\x1B[34m", "\x1B[39m"),
- magenta: init("\x1B[35m", "\x1B[39m"),
- cyan: init("\x1B[36m", "\x1B[39m"),
- white: init("\x1B[37m", "\x1B[39m"),
- gray: init("\x1B[90m", "\x1B[39m"),
- bgBlack: init("\x1B[40m", "\x1B[49m"),
- bgRed: init("\x1B[41m", "\x1B[49m"),
- bgGreen: init("\x1B[42m", "\x1B[49m"),
- bgYellow: init("\x1B[43m", "\x1B[49m"),
- bgBlue: init("\x1B[44m", "\x1B[49m"),
- bgMagenta: init("\x1B[45m", "\x1B[49m"),
- bgCyan: init("\x1B[46m", "\x1B[49m"),
- bgWhite: init("\x1B[47m", "\x1B[49m")
- };
- };
- module.exports = createColors();
- module.exports.createColors = createColors;
-});
-
-// configurator.ts
-var {$: $2 } = globalThis.Bun;
-
-// node_modules/@clack/core/dist/index.mjs
-var import_sisteransi = __toESM(require_src(), 1);
-import {stdin as $, stdout as k} from "process";
-var import_picocolors = __toESM(require_picocolors(), 1);
-import _ from "readline";
-import {WriteStream as U} from "tty";
-function q({ onlyFirst: t = false } = {}) {
- const u = ["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");
- return new RegExp(u, t ? undefined : "g");
-}
-function S(t) {
- if (typeof t != "string")
- throw new TypeError(`Expected a \`string\`, got \`${typeof t}\``);
- return t.replace(q(), "");
-}
-function j(t) {
- return t && t.__esModule && Object.prototype.hasOwnProperty.call(t, "default") ? t.default : t;
-}
-function A(t, u = {}) {
- if (typeof t != "string" || t.length === 0 || (u = { ambiguousIsNarrow: true, ...u }, t = S(t), t.length === 0))
- return 0;
- t = t.replace(DD(), " ");
- const F = u.ambiguousIsNarrow ? 1 : 2;
- let e = 0;
- for (const s of t) {
- const C = s.codePointAt(0);
- if (C <= 31 || C >= 127 && C <= 159 || C >= 768 && C <= 879)
- continue;
- switch (Q.eastAsianWidth(s)) {
- case "F":
- case "W":
- e += 2;
- break;
- case "A":
- e += F;
- break;
- default:
- e += 1;
- }
- }
- return e;
-}
-function tD() {
- const t = new Map;
- for (const [u, F] of Object.entries(r)) {
- for (const [e, s] of Object.entries(F))
- r[e] = { open: `\x1B[${s[0]}m`, close: `\x1B[${s[1]}m` }, F[e] = r[e], t.set(s[0], s[1]);
- Object.defineProperty(r, u, { value: F, enumerable: false });
- }
- return Object.defineProperty(r, "codes", { value: t, enumerable: false }), r.color.close = "\x1B[39m", r.bgColor.close = "\x1B[49m", r.color.ansi = T(), r.color.ansi256 = P(), r.color.ansi16m = W(), r.bgColor.ansi = T(m), r.bgColor.ansi256 = P(m), r.bgColor.ansi16m = W(m), Object.defineProperties(r, { rgbToAnsi256: { value: (u, F, e) => u === F && F === e ? u < 8 ? 16 : u > 248 ? 231 : Math.round((u - 8) / 247 * 24) + 232 : 16 + 36 * Math.round(u / 255 * 5) + 6 * Math.round(F / 255 * 5 [...]
- const F = /[a-f\d]{6}|[a-f\d]{3}/i.exec(u.toString(16));
- if (!F)
- return [0, 0, 0];
- let [e] = F;
- e.length === 3 && (e = [...e].map((C) => C + C).join(""));
- const s = Number.parseInt(e, 16);
- return [s >> 16 & 255, s >> 8 & 255, s & 255];
- }, enumerable: false }, hexToAnsi256: { value: (u) => r.rgbToAnsi256(...r.hexToRgb(u)), enumerable: false }, ansi256ToAnsi: { value: (u) => {
- if (u < 8)
- return 30 + u;
- if (u < 16)
- return 90 + (u - 8);
- let F, e, s;
- if (u >= 232)
- F = ((u - 232) * 10 + 8) / 255, e = F, s = F;
- else {
- u -= 16;
- const i = u % 36;
- F = Math.floor(u / 36) / 5, e = Math.floor(i / 6) / 5, s = i % 6 / 5;
- }
- const C = Math.max(F, e, s) * 2;
- if (C === 0)
- return 30;
- let D = 30 + (Math.round(s) << 2 | Math.round(e) << 1 | Math.round(F));
- return C === 2 && (D += 60), D;
- }, enumerable: false }, rgbToAnsi: { value: (u, F, e) => r.ansi256ToAnsi(r.rgbToAnsi256(u, F, e)), enumerable: false }, hexToAnsi: { value: (u) => r.ansi256ToAnsi(r.hexToAnsi256(u)), enumerable: false } }), r;
-}
-function R(t, u, F) {
- return String(t).normalize().replace(/\r\n/g, `
-`).split(`
-`).map((e) => ED(e, u, F)).join(`
-`);
-}
-function aD(t, u) {
- if (t === u)
- return;
- const F = t.split(`
-`), e = u.split(`
-`), s = [];
- for (let C = 0;C < Math.max(F.length, e.length); C++)
- F[C] !== e[C] && s.push(C);
- return s;
-}
-function hD(t) {
- return t === V;
-}
-function v(t, u) {
- t.isTTY && t.setRawMode(u);
-}
-var M = { exports: {} };
-(function(t) {
- var u = {};
- t.exports = u, u.eastAsianWidth = function(e) {
- var s = e.charCodeAt(0), C = e.length == 2 ? e.charCodeAt(1) : 0, D = s;
- return 55296 <= s && s <= 56319 && 56320 <= C && C <= 57343 && (s &= 1023, C &= 1023, D = s << 10 | C, D += 65536), D == 12288 || 65281 <= D && D <= 65376 || 65504 <= D && D <= 65510 ? "F" : D == 8361 || 65377 <= D && D <= 65470 || 65474 <= D && D <= 65479 || 65482 <= D && D <= 65487 || 65490 <= D && D <= 65495 || 65498 <= D && D <= 65500 || 65512 <= D && D <= 65518 ? "H" : 4352 <= D && D <= 4447 || 4515 <= D && D <= 4519 || 4602 <= D && D <= 4607 || 9001 <= D && D <= 9002 || 11904 < [...]
- }, u.characterLength = function(e) {
- var s = this.eastAsianWidth(e);
- return s == "F" || s == "W" || s == "A" ? 2 : 1;
- };
- function F(e) {
- return e.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[^\uD800-\uDFFF]/g) || [];
- }
- u.length = function(e) {
- for (var s = F(e), C = 0, D = 0;D < s.length; D++)
- C = C + this.characterLength(s[D]);
- return C;
- }, u.slice = function(e, s, C) {
- textLen = u.length(e), s = s || 0, C = C || 1, s < 0 && (s = textLen + s), C < 0 && (C = textLen + C);
- for (var D = "", i = 0, n = F(e), E = 0;E < n.length; E++) {
- var h = n[E], o = u.length(h);
- if (i >= s - (o == 2 ? 1 : 0))
- if (i + o <= C)
- D += h;
- else
- break;
- i += o;
- }
- return D;
- };
-})(M);
-var J = M.exports;
-var Q = j(J);
-var X = function() {
- return /\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67)\uDB40\uDC7F|(?:\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u20 [...]
-};
-var DD = j(X);
-var m = 10;
-var T = (t = 0) => (u) => `\x1B[${u + t}m`;
-var P = (t = 0) => (u) => `\x1B[${38 + t};5;${u}m`;
-var W = (t = 0) => (u, F, e) => `\x1B[${38 + t};2;${u};${F};${e}m`;
-var r = { modifier: { reset: [0, 0], bold: [1, 22], dim: [2, 22], italic: [3, 23], underline: [4, 24], overline: [53, 55], inverse: [7, 27], hidden: [8, 28], strikethrough: [9, 29] }, color: { black: [30, 39], red: [31, 39], green: [32, 39], yellow: [33, 39], blue: [34, 39], magenta: [35, 39], cyan: [36, 39], white: [37, 39], blackBright: [90, 39], gray: [90, 39], grey: [90, 39], redBright: [91, 39], greenBright: [92, 39], yellowBright: [93, 39], blueBright: [94, 39], magentaBright: [95, [...]
-Object.keys(r.modifier);
-var uD = Object.keys(r.color);
-var FD = Object.keys(r.bgColor);
-[...uD];
-var eD = tD();
-var g = new Set(["\x1B", "\x9B"]);
-var sD = 39;
-var b = "\x07";
-var O = "[";
-var CD = "]";
-var I = "m";
-var w = `${CD}8;;`;
-var N = (t) => `${g.values().next().value}${O}${t}${I}`;
-var L = (t) => `${g.values().next().value}${w}${t}${b}`;
-var iD = (t) => t.split(" ").map((u) => A(u));
-var y = (t, u, F) => {
- const e = [...u];
- let s = false, C = false, D = A(S(t[t.length - 1]));
- for (const [i, n] of e.entries()) {
- const E = A(n);
- if (D + E <= F ? t[t.length - 1] += n : (t.push(n), D = 0), g.has(n) && (s = true, C = e.slice(i + 1).join("").startsWith(w)), s) {
- C ? n === b && (s = false, C = false) : n === I && (s = false);
- continue;
- }
- D += E, D === F && i < e.length - 1 && (t.push(""), D = 0);
- }
- !D && t[t.length - 1].length > 0 && t.length > 1 && (t[t.length - 2] += t.pop());
-};
-var rD = (t) => {
- const u = t.split(" ");
- let F = u.length;
- for (;F > 0 && !(A(u[F - 1]) > 0); )
- F--;
- return F === u.length ? t : u.slice(0, F).join(" ") + u.slice(F).join("");
-};
-var ED = (t, u, F = {}) => {
- if (F.trim !== false && t.trim() === "")
- return "";
- let e = "", s, C;
- const D = iD(t);
- let i = [""];
- for (const [E, h] of t.split(" ").entries()) {
- F.trim !== false && (i[i.length - 1] = i[i.length - 1].trimStart());
- let o = A(i[i.length - 1]);
- if (E !== 0 && (o >= u && (F.wordWrap === false || F.trim === false) && (i.push(""), o = 0), (o > 0 || F.trim === false) && (i[i.length - 1] += " ", o++)), F.hard && D[E] > u) {
- const B = u - o, p = 1 + Math.floor((D[E] - B - 1) / u);
- Math.floor((D[E] - 1) / u) < p && i.push(""), y(i, h, u);
- continue;
- }
- if (o + D[E] > u && o > 0 && D[E] > 0) {
- if (F.wordWrap === false && o < u) {
- y(i, h, u);
- continue;
- }
- i.push("");
- }
- if (o + D[E] > u && F.wordWrap === false) {
- y(i, h, u);
- continue;
- }
- i[i.length - 1] += h;
- }
- F.trim !== false && (i = i.map((E) => rD(E)));
- const n = [...i.join(`
-`)];
- for (const [E, h] of n.entries()) {
- if (e += h, g.has(h)) {
- const { groups: B } = new RegExp(`(?:\\${O}(?<code>\\d+)m|\\${w}(?<uri>.*)${b})`).exec(n.slice(E).join("")) || { groups: {} };
- if (B.code !== undefined) {
- const p = Number.parseFloat(B.code);
- s = p === sD ? undefined : p;
- } else
- B.uri !== undefined && (C = B.uri.length === 0 ? undefined : B.uri);
- }
- const o = eD.codes.get(Number(s));
- n[E + 1] === `
-` ? (C && (e += L("")), s && o && (e += N(o))) : h === `
-` && (s && o && (e += N(s)), C && (e += L(C)));
- }
- return e;
-};
-var oD = Object.defineProperty;
-var nD = (t, u, F) => (u in t) ? oD(t, u, { enumerable: true, configurable: true, writable: true, value: F }) : t[u] = F;
-var a = (t, u, F) => (nD(t, typeof u != "symbol" ? u + "" : u, F), F);
-var V = Symbol("clack:cancel");
-var z = new Map([["k", "up"], ["j", "down"], ["h", "left"], ["l", "right"]]);
-var lD = new Set(["up", "down", "left", "right", "space", "enter"]);
-
-class x {
- constructor({ render: u, input: F = $, output: e = k, ...s }, C = true) {
- a(this, "input"), a(this, "output"), a(this, "rl"), a(this, "opts"), a(this, "_track", false), a(this, "_render"), a(this, "_cursor", 0), a(this, "state", "initial"), a(this, "value"), a(this, "error", ""), a(this, "subscribers", new Map), a(this, "_prevFrame", ""), this.opts = s, this.onKeypress = this.onKeypress.bind(this), this.close = this.close.bind(this), this.render = this.render.bind(this), this._render = u.bind(this), this._track = C, this.input = F, this.output = e;
- }
- prompt() {
- const u = new U(0);
- return u._write = (F, e, s) => {
- this._track && (this.value = this.rl.line.replace(/\t/g, ""), this._cursor = this.rl.cursor, this.emit("value", this.value)), s();
- }, this.input.pipe(u), this.rl = _.createInterface({ input: this.input, output: u, tabSize: 2, prompt: "", escapeCodeTimeout: 50 }), _.emitKeypressEvents(this.input, this.rl), this.rl.prompt(), this.opts.initialValue !== undefined && this._track && this.rl.write(this.opts.initialValue), this.input.on("keypress", this.onKeypress), v(this.input, true), this.output.on("resize", this.render), this.render(), new Promise((F, e) => {
- this.once("submit", () => {
- this.output.write(import_sisteransi.cursor.show), this.output.off("resize", this.render), v(this.input, false), F(this.value);
- }), this.once("cancel", () => {
- this.output.write(import_sisteransi.cursor.show), this.output.off("resize", this.render), v(this.input, false), F(V);
- });
- });
- }
- on(u, F) {
- const e = this.subscribers.get(u) ?? [];
- e.push({ cb: F }), this.subscribers.set(u, e);
- }
- once(u, F) {
- const e = this.subscribers.get(u) ?? [];
- e.push({ cb: F, once: true }), this.subscribers.set(u, e);
- }
- emit(u, ...F) {
- const e = this.subscribers.get(u) ?? [], s = [];
- for (const C of e)
- C.cb(...F), C.once && s.push(() => e.splice(e.indexOf(C), 1));
- for (const C of s)
- C();
- }
- unsubscribe() {
- this.subscribers.clear();
- }
- onKeypress(u, F) {
- if (this.state === "error" && (this.state = "active"), F?.name && !this._track && z.has(F.name) && this.emit("cursor", z.get(F.name)), F?.name && lD.has(F.name) && this.emit("cursor", F.name), u && (u.toLowerCase() === "y" || u.toLowerCase() === "n") && this.emit("confirm", u.toLowerCase() === "y"), u === " " && this.opts.placeholder && (this.value || (this.rl.write(this.opts.placeholder), this.emit("value", this.opts.placeholder))), u && this.emit("key", u.toLowerCase()), F?.name == [...]
- if (this.opts.validate) {
- const e = this.opts.validate(this.value);
- e && (this.error = e, this.state = "error", this.rl.write(this.value));
- }
- this.state !== "error" && (this.state = "submit");
- }
- u === "" && (this.state = "cancel"), (this.state === "submit" || this.state === "cancel") && this.emit("finalize"), this.render(), (this.state === "submit" || this.state === "cancel") && this.close();
- }
- close() {
- this.input.unpipe(), this.input.removeListener("keypress", this.onKeypress), this.output.write(`
-`), v(this.input, false), this.rl.close(), this.emit(`${this.state}`, this.value), this.unsubscribe();
- }
- restoreCursor() {
- const u = R(this._prevFrame, process.stdout.columns, { hard: true }).split(`
-`).length - 1;
- this.output.write(import_sisteransi.cursor.move(-999, u * -1));
- }
- render() {
- const u = R(this._render(this) ?? "", process.stdout.columns, { hard: true });
- if (u !== this._prevFrame) {
- if (this.state === "initial")
- this.output.write(import_sisteransi.cursor.hide);
- else {
- const F = aD(this._prevFrame, u);
- if (this.restoreCursor(), F && F?.length === 1) {
- const e = F[0];
- this.output.write(import_sisteransi.cursor.move(0, e)), this.output.write(import_sisteransi.erase.lines(1));
- const s = u.split(`
-`);
- this.output.write(s[e]), this._prevFrame = u, this.output.write(import_sisteransi.cursor.move(0, s.length - e - 1));
- return;
- } else if (F && F?.length > 1) {
- const e = F[0];
- this.output.write(import_sisteransi.cursor.move(0, e)), this.output.write(import_sisteransi.erase.down());
- const s = u.split(`
-`).slice(e);
- this.output.write(s.join(`
-`)), this._prevFrame = u;
- return;
- }
- this.output.write(import_sisteransi.erase.down());
- }
- this.output.write(u), this.state === "initial" && (this.state = "active"), this._prevFrame = u;
- }
- }
-}
-var vD = Object.defineProperty;
-var dD = (t, u, F) => (u in t) ? vD(t, u, { enumerable: true, configurable: true, writable: true, value: F }) : t[u] = F;
-var Y = (t, u, F) => (dD(t, typeof u != "symbol" ? u + "" : u, F), F);
-
-class mD extends x {
- constructor({ mask: u, ...F }) {
- super(F), Y(this, "valueWithCursor", ""), Y(this, "_mask", "\u2022"), this._mask = u ?? "\u2022", this.on("finalize", () => {
- this.valueWithCursor = this.masked;
- }), this.on("value", () => {
- if (this.cursor >= this.value.length)
- this.valueWithCursor = `${this.masked}${import_picocolors.default.inverse(import_picocolors.default.hidden("_"))}`;
- else {
- const e = this.masked.slice(0, this.cursor), s = this.masked.slice(this.cursor);
- this.valueWithCursor = `${e}${import_picocolors.default.inverse(s[0])}${s.slice(1)}`;
- }
- });
- }
- get cursor() {
- return this._cursor;
- }
- get masked() {
- return this.value.replaceAll(/./g, this._mask);
- }
-}
-var bD = Object.defineProperty;
-var wD = (t, u, F) => (u in t) ? bD(t, u, { enumerable: true, configurable: true, writable: true, value: F }) : t[u] = F;
-var Z = (t, u, F) => (wD(t, typeof u != "symbol" ? u + "" : u, F), F);
-var yD = class extends x {
- constructor(u) {
- super(u, false), Z(this, "options"), Z(this, "cursor", 0), this.options = u.options, this.cursor = this.options.findIndex(({ value: F }) => F === u.initialValue), this.cursor === -1 && (this.cursor = 0), this.changeValue(), this.on("cursor", (F) => {
- switch (F) {
- case "left":
- case "up":
- this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
- break;
- case "down":
- case "right":
- this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
- break;
- }
- this.changeValue();
- });
- }
- get _value() {
- return this.options[this.cursor];
- }
- changeValue() {
- this.value = this._value.value;
- }
-};
-var SD = Object.defineProperty;
-var jD = (t, u, F) => (u in t) ? SD(t, u, { enumerable: true, configurable: true, writable: true, value: F }) : t[u] = F;
-var MD = (t, u, F) => (jD(t, typeof u != "symbol" ? u + "" : u, F), F);
-
-class TD extends x {
- constructor(u) {
- super(u), MD(this, "valueWithCursor", ""), this.on("finalize", () => {
- this.value || (this.value = u.defaultValue), this.valueWithCursor = this.value;
- }), this.on("value", () => {
- if (this.cursor >= this.value.length)
- this.valueWithCursor = `${this.value}${import_picocolors.default.inverse(import_picocolors.default.hidden("_"))}`;
- else {
- const F = this.value.slice(0, this.cursor), e = this.value.slice(this.cursor);
- this.valueWithCursor = `${F}${import_picocolors.default.inverse(e[0])}${e.slice(1)}`;
- }
- });
- }
- get cursor() {
- return this._cursor;
- }
-}
-var PD = globalThis.process.platform.startsWith("win");
-
-// node_modules/@clack/prompts/dist/index.mjs
-var import_picocolors2 = __toESM(require_picocolors(), 1);
-var import_sisteransi2 = __toESM(require_src(), 1);
-import h from "process";
-function q2() {
- return h.platform !== "win32" ? h.env.TERM !== "linux" : Boolean(h.env.CI) || Boolean(h.env.WT_SESSION) || Boolean(h.env.TERMINUS_SUBLIME) || h.env.ConEmuTask === "{cmd::Cmder}" || h.env.TERM_PROGRAM === "Terminus-Sublime" || h.env.TERM_PROGRAM === "vscode" || h.env.TERM === "xterm-256color" || h.env.TERM === "alacritty" || h.env.TERMINAL_EMULATOR === "JetBrains-JediTerm";
-}
-var _2 = q2();
-var o = (r2, n) => _2 ? r2 : n;
-var H = o("\u25C6", "*");
-var I2 = o("\u25A0", "x");
-var x2 = o("\u25B2", "x");
-var S2 = o("\u25C7", "o");
-var K = o("\u250C", "T");
-var a2 = o("\u2502", "|");
-var d2 = o("\u2514", "\u2014");
-var b2 = o("\u25CF", ">");
-var E = o("\u25CB", " ");
-var C = o("\u25FB", "[\u2022]");
-var w2 = o("\u25FC", "[+]");
-var M2 = o("\u25FB", "[ ]");
-var U2 = o("\u25AA", "\u2022");
-var B = o("\u2500", "-");
-var Z2 = o("\u256E", "+");
-var z2 = o("\u251C", "+");
-var X2 = o("\u256F", "+");
-var J2 = o("\u25CF", "\u2022");
-var Y2 = o("\u25C6", "*");
-var Q2 = o("\u25B2", "!");
-var ee = o("\u25A0", "x");
-var y2 = (r2) => {
- switch (r2) {
- case "initial":
- case "active":
- return import_picocolors2.default.cyan(H);
- case "cancel":
- return import_picocolors2.default.red(I2);
- case "error":
- return import_picocolors2.default.yellow(x2);
- case "submit":
- return import_picocolors2.default.green(S2);
- }
-};
-var te = (r2) => new TD({ validate: r2.validate, placeholder: r2.placeholder, defaultValue: r2.defaultValue, initialValue: r2.initialValue, render() {
- const n = `${import_picocolors2.default.gray(a2)}
-${y2(this.state)} ${r2.message}
-`, i = r2.placeholder ? import_picocolors2.default.inverse(r2.placeholder[0]) + import_picocolors2.default.dim(r2.placeholder.slice(1)) : import_picocolors2.default.inverse(import_picocolors2.default.hidden("_")), t = this.value ? this.valueWithCursor : i;
- switch (this.state) {
- case "error":
- return `${n.trim()}
-${import_picocolors2.default.yellow(a2)} ${t}
-${import_picocolors2.default.yellow(d2)} ${import_picocolors2.default.yellow(this.error)}
-`;
- case "submit":
- return `${n}${import_picocolors2.default.gray(a2)} ${import_picocolors2.default.dim(this.value || r2.placeholder)}`;
- case "cancel":
- return `${n}${import_picocolors2.default.gray(a2)} ${import_picocolors2.default.strikethrough(import_picocolors2.default.dim(this.value ?? ""))}${this.value?.trim() ? `
-` + import_picocolors2.default.gray(a2) : ""}`;
- default:
- return `${n}${import_picocolors2.default.cyan(a2)} ${t}
-${import_picocolors2.default.cyan(d2)}
-`;
- }
-} }).prompt();
-var re = (r2) => new mD({ validate: r2.validate, mask: r2.mask ?? U2, render() {
- const n = `${import_picocolors2.default.gray(a2)}
-${y2(this.state)} ${r2.message}
-`, i = this.valueWithCursor, t = this.masked;
- switch (this.state) {
- case "error":
- return `${n.trim()}
-${import_picocolors2.default.yellow(a2)} ${t}
-${import_picocolors2.default.yellow(d2)} ${import_picocolors2.default.yellow(this.error)}
-`;
- case "submit":
- return `${n}${import_picocolors2.default.gray(a2)} ${import_picocolors2.default.dim(t)}`;
- case "cancel":
- return `${n}${import_picocolors2.default.gray(a2)} ${import_picocolors2.default.strikethrough(import_picocolors2.default.dim(t ?? ""))}${t ? `
-` + import_picocolors2.default.gray(a2) : ""}`;
- default:
- return `${n}${import_picocolors2.default.cyan(a2)} ${i}
-${import_picocolors2.default.cyan(d2)}
-`;
- }
-} }).prompt();
-var ie = (r2) => {
- const n = (t, s) => {
- const c2 = t.label ?? String(t.value);
- return s === "active" ? `${import_picocolors2.default.green(b2)} ${c2} ${t.hint ? import_picocolors2.default.dim(`(${t.hint})`) : ""}` : s === "selected" ? `${import_picocolors2.default.dim(c2)}` : s === "cancelled" ? `${import_picocolors2.default.strikethrough(import_picocolors2.default.dim(c2))}` : `${import_picocolors2.default.dim(E)} ${import_picocolors2.default.dim(c2)}`;
- };
- let i = 0;
- return new yD({ options: r2.options, initialValue: r2.initialValue, render() {
- const t = `${import_picocolors2.default.gray(a2)}
-${y2(this.state)} ${r2.message}
-`;
- switch (this.state) {
- case "submit":
- return `${t}${import_picocolors2.default.gray(a2)} ${n(this.options[this.cursor], "selected")}`;
- case "cancel":
- return `${t}${import_picocolors2.default.gray(a2)} ${n(this.options[this.cursor], "cancelled")}
-${import_picocolors2.default.gray(a2)}`;
- default: {
- const s = r2.maxItems === undefined ? 1 / 0 : Math.max(r2.maxItems, 5);
- this.cursor >= i + s - 3 ? i = Math.max(Math.min(this.cursor - s + 3, this.options.length - s), 0) : this.cursor < i + 2 && (i = Math.max(this.cursor - 2, 0));
- const c2 = s < this.options.length && i > 0, l2 = s < this.options.length && i + s < this.options.length;
- return `${t}${import_picocolors2.default.cyan(a2)} ${this.options.slice(i, i + s).map((u, m2, $2) => m2 === 0 && c2 ? import_picocolors2.default.dim("...") : m2 === $2.length - 1 && l2 ? import_picocolors2.default.dim("...") : n(u, m2 + i === this.cursor ? "active" : "inactive")).join(`
-${import_picocolors2.default.cyan(a2)} `)}
-${import_picocolors2.default.cyan(d2)}
-`;
- }
- }
- } }).prompt();
-};
-var ue = (r2 = "") => {
- process.stdout.write(`${import_picocolors2.default.gray(d2)} ${import_picocolors2.default.red(r2)}
-
-`);
-};
-var oe = (r2 = "") => {
- process.stdout.write(`${import_picocolors2.default.gray(K)} ${r2}
-`);
-};
-var $e = (r2 = "") => {
- process.stdout.write(`${import_picocolors2.default.gray(a2)}
-${import_picocolors2.default.gray(d2)} ${r2}
-
-`);
-};
-
-// configurator.ts
-var import_picocolors3 = __toESM(require_picocolors(), 1);
-import {parseArgs} from "util";
-async function askMissingData(missingData, override = false) {
- if (Object.keys(missingData).length === 0) {
- $e("Configuration set from ops");
- process.exit(0);
- }
- console.log();
- if (!override) {
- console.log("Configuration partially set from ops. Need a few more:");
- }
- for (const key in missingData) {
- let inputFromPrompt;
- const prompt = missingData[key];
- if (Array.isArray(prompt.type)) {
- const defaultValueOK = prompt.default && prompt.type.includes(prompt.default);
- if (prompt.default && !defaultValueOK) {
- console.log();
- console.warn(`The default value ${prompt.default} is not in the enum values.`);
- }
- const selected = await ie({
- message: prompt.label || `Pick a value for '${key}'`,
- options: prompt.type.map((v2) => ({ label: v2, value: v2 })),
- initialValue: defaultValueOK ? prompt.default : undefined
- });
- if (!selected || hD(selected)) {
- ue("Operation cancelled");
- process.exit(0);
- }
- inputFromPrompt = selected.toString();
- } else if (prompt.type === "bool") {
- const selected = await ie({
- initialValue: prompt.default === "true" || prompt.default === true ? "true" : "false",
- message: prompt.label || `Pick a true/false for '${key}'`,
- options: [
- { label: "true", value: "true" },
- { label: "false", value: "false" }
- ]
- });
- if (!selected || hD(selected)) {
- ue("Operation cancelled");
- process.exit(0);
- }
- inputFromPrompt = selected.toString();
- } else if (prompt.type === "password") {
- if (prompt.default) {
- console.log();
- console.warn("Default password value is not supported. Please enter the password manually.");
- }
- const input = await re({
- message: prompt.label || `Enter password value for ${key}`,
- validate: (value) => {
- if (!value) {
- return "Password cannot be empty";
- }
- }
- });
- if (hD(input)) {
- ue("Operation cancelled");
- process.exit(0);
- }
- inputFromPrompt = input;
- } else {
- const defaultMsgFragment = prompt.default ? `(default: ${prompt.default})` : "";
- const message = `Enter value for ${key} ${defaultMsgFragment} (${prompt.type})`;
- let input = await te({
- message: prompt.label || message,
- initialValue: prompt.default?.toString(),
- validate: (value) => {
- switch (prompt.type) {
- case "int":
- if (!Number.isInteger(Number(value))) {
- return `Value for ${key} must be an integer number`;
- }
- break;
- case "float":
- if (!Number(value)) {
- return `Value for ${key} must be a number`;
- }
- }
- return;
- }
- });
- if (hD(input)) {
- ue("Operation cancelled");
- process.exit(0);
- }
- inputFromPrompt = input;
- }
- console.log(`Setting ${key} to ${inputFromPrompt}`);
- const { exitCode, stderr } = await $2`${OPS} -config ${key}=${inputFromPrompt}`.nothrow();
- if (exitCode !== 0) {
- ue(stderr.toString());
- return process.exit(1);
- }
- }
-}
-function findMissingConfig(config, opsCurrentConfig) {
- let newConfig = {};
- let opsConfigKeys = opsCurrentConfig.split("\n").map((line) => line.split("=")[0]);
- for (const key in config) {
- if (opsConfigKeys.includes(key)) {
- continue;
- }
- newConfig[key] = config[key];
- }
- return newConfig;
-}
-function readPositionalFile(positionals) {
- if (positionals.length < 2) {
- console.error("This should not happen");
- return { success: false, message: "This should not happen" };
- }
- if (positionals.length === 2) {
- return { success: true, help: HelpMsg };
- }
- if (positionals.length > 3) {
- return {
- success: true,
- message: AdditionalArgsMsg,
- jsonFilePath: positionals[2]
- };
- }
- return { success: true, jsonFilePath: positionals[2] };
-}
-async function parsePositionalFile(path) {
- const file = Bun.file(Bun.pathToFileURL(path));
- if (!await file.exists()) {
- return { success: false, message: FileNotFoundJsonMsg + Bun.pathToFileURL(path) };
- }
- try {
- const contents = await file.json();
- return { success: true, body: contents };
- } catch (error) {
- return { success: false, message: NotValidJsonMsg };
- }
-}
-function isInputConfigValid(body) {
- if (Object.keys(body).length === 0) {
- return false;
- }
- for (const key in body) {
- const value = body[key];
- if (typeof value !== "object") {
- return false;
- }
- if (!value.type) {
- return false;
- }
- if (!["string", "int", "float", "bool", "password"].includes(value.type) && !Array.isArray(value.type)) {
- return false;
- }
- }
- for (const key in body) {
- if (!/^[A-Z][A-Z_]|[0-9]*[A-Z]|[0-9]$/.test(key)) {
- return false;
- }
- }
- return true;
-}
-var OPS = process.env.OPS || "ops";
-var AdditionalArgsMsg = "Additional arguments will be ignored.";
-var NotValidJsonMsg = "Not a valid JSON file";
-var FileNotFoundJsonMsg = "The JSON file was not found. Pathname: ";
-var BadConfigMsg = "Bad configuration file. Check the help message (-h) to see the expected format.";
-var HelpMsg = `
-Usage: config <configjson> [-o | --override]
-
-Description:
-Prompt the user for configuration data defined in the config.json file.
-The script will ignore the keys from the input config that are already set in ops
-and only prompt the user for the missing data, unless the override flag is set.
-Then they will be saved in the ops config.
-
-The config.json file must be a JSON file with the following structure:
-
- {
- "KEY": {
- "type": "string"
- },
- "OTHER_KEY": {
- "label": "An optional custom message",
- "type": "int"
- },
- ...
- }
-
-The keys must be uppercase words (separated by underscores).
-The value for the "type" key must be either string with the following values:
-- string
-- int
-- float
-- bool
-- password
-- an array of strings with specific values (an enum).
-`;
-async function main() {
- const options = {
- showhelp: {
- type: "boolean"
- },
- override: {
- type: "boolean"
- }
- };
- const { values, positionals } = parseArgs({
- args: Bun.argv,
- options,
- strict: true,
- allowPositionals: true
- });
- if (values.showhelp) {
- console.log(HelpMsg);
- return process.exit(0);
- }
- const override = values.override || false;
- const readPosRes = readPositionalFile(positionals);
- if (!readPosRes.success) {
- ue(readPosRes.message);
- return process.exit(1);
- }
- if (readPosRes.help) {
- console.log(readPosRes.help);
- return process.exit(0);
- }
- if (readPosRes.message) {
- console.warn(readPosRes.message);
- }
- const jsonRes = await parsePositionalFile(readPosRes.jsonFilePath);
- if (!jsonRes.success) {
- ue(jsonRes.message);
- return process.exit(1);
- }
- const config = jsonRes.body;
- if (!isInputConfigValid(config)) {
- ue(BadConfigMsg);
- return process.exit(1);
- }
- const { exitCode, stderr, stdout } = await $2`${OPS} -config -d`.quiet();
- if (exitCode !== 0) {
- ue(stderr.toString());
- return process.exit(1);
- }
- const opsCurrentConfig = stdout.toString();
- let missingData = config;
- if (!override) {
- missingData = findMissingConfig(config, opsCurrentConfig);
- }
- console.log();
- oe(import_picocolors3.default.inverse(" ops configurator "));
- await askMissingData(missingData, override);
- console.log();
- $e("You're all set!");
-}
-
-// index.ts
-main().catch(console.error);
diff --git a/util/config/configurator/all-config-parameters.toml b/util/config/configurator/all-config-parameters.toml
new file mode 100644
index 0000000..55a49ba
--- /dev/null
+++ b/util/config/configurator/all-config-parameters.toml
@@ -0,0 +1,248 @@
+# Content: for all the components that Openserverless needs in its configuration,
+# we create a suitable configuration
+# This is a TOML document
+# To obtain some of these arguments, use the command
+# ops -config -d | awk -F= '/=/{print $1}'
+
+
+
+[components.aws]
+
+# taskfile format:
+# VAR={ label="<MSG>", initialValue="<VAL>", userInputValue="<DEFAULT_VALUE>"}
+
+
+#
+# AWS configuration values
+#
+
+# Mandatory values
+AWS_ACCESS_KEY_ID= {label="AWS Access Id", initialValue="AAA", userInputValue="BBB"}
+AWS_SECRET_ACCESS_KEY= {label="AWS Secret Key", initialValue="", userInputValue="", type="password"}
+# Non-mandatory values
+AWS_DEFAULT_REGION= {label="AWS Region to use", initialValue="", userInputValue="us-east-1"}
+
+# Value defined in the aws task
+# Mandatory values
+# None
+
+# Non-mandatory values
+AWS_VM_IMAGE_ID={ label="AWS Image to use for VMs", initialValue="{{.__image}}", userInputValue="ami-052efd3df9dad4825"}
+AWS_VM_IMAGE_USER={ label="AWS Default user for image to use for VMs", initialValue="{{.__vmuser}}", userInputValue="ubuntu"}
+AWS_VM_INSTANCE_TYPE={ label="AWS Instance type to use for VMs", hint="The suggested instance type has 8GB and 2vcpus.\nTo get a list of valid values, use:\naws ec2 describe-instance-types --query 'InstanceTypes[].InstanceType' --output table", initialValue="{{.__vm}}", userInputValue="t3a.large"}
+AWS_VM_DISK_SIZE={ label="AWS Disk Size to use for VMs", initialValue="{{.__disk}}", userInputValue="100"}
+
+
+#
+# EKS configuration values
+#
+
+[components.eks]
+
+# Mandatory values
+# None
+
+# Non-mandatory values
+EKS_NAME={ label="EKS Name for Cluster and Node Group", initialValue="{{.__name}}", userInputValue="openserverless"}
+EKS_REGION={ label="EKS location", initialValue="{{.__region}}", userInputValue="us-east-2"}
+EKS_COUNT={ label="EKS number of worker nodes", initialValue="{{.__count}}", userInputValue="3"}
+EKS_VM={ label="EKS virtual machine type", initialValue="{{.__vm}}", userInputValue="m5.xlarge"}
+EKS_DISK={ label="EKS disk size in gigabyte", initialValue="{{.__disk}}", userInputValue="50"}
+EKS_KUBERNETES_VERSION={ label="EKS Kubernetes Version", initialValue="{{.__kubever}}", userInputValue="1.25"}
+
+
+#
+# GCLOUD configuration values
+#
+
+[components.gcloud]
+
+
+# Mandatory values
+GCLOUD_PROJECT={ label="GCloud Project Id", initialValue="{{.__project}}", userInputValue=""}
+
+# Non-mandatory values
+GCLOUD_REGION={ label="GCloud Zone", initialValue="{{.__region}}", userInputValue="us-east1"}
+GCLOUD_VM={ label="GCloud virtual machine type", initialValue="{{.__vm}}", userInputValue="n2-standard-4"}
+GCLOUD_DISK={ label="GCloud disk size in gigabyte", initialValue="{{.__disk}}", userInputValue="200"}
+GCLOUD_SSHKEY={ label="GCloud public SSH key", initialValue="{{.__key}}", userInputValue="~/.ssh/id_rsa.pub"}
+GCLOUD_IMAGE={ label="GCloud VM image", initialValue="{{.__image}}", userInputValue="ubuntu-minimal-2204-lts"}
+
+#
+# GKE configuration values
+#
+
+[components.gke]
+
+# Mandatory values
+GKE_PROJECT={ label="GCloud Project Id", initialValue="{{.__project}}", userInputValue=""}
+
+# Non-mandatory values
+GKE_NAME={ label="GCloud Cluster Name", initialValue="{{.__name}}", userInputValue="nuvolaris"}
+GKE_REGION={ label="GCloud Cluster Zone", initialValue="{{.__region}}", userInputValue="us-east1"}
+GKE_COUNT={ label="GCloud number of worker nodes", initialValue="{{.__count}}", userInputValue="3"}
+GKE_VM={ label="GKE virtual machine type", initialValue="{{.__vm}}", userInputValue="e2-standard-2"}
+GKE_DISK={ label="GKE disk size in gigabyte", initialValue="{{.__disk}}", userInputValue="50"}
+
+
+#
+# AZCLOUD configuration values
+#
+
+[components.azcloud]
+
+# Mandatory values
+AZCLOUD_PROJECT={ label="Azure Resource Group", initialValue="{{.__project}}", userInputValue=""}
+
+# Non-mandatory values
+AZCLOUD_REGION={ label="Azure Zone", initialValue="{{.__region}}", userInputValue="eastus"}
+AZCLOUD_VM={ label="Azure virtual machine type", initialValue="{{.__vm}}", userInputValue="Standard_B4s_v2"}
+AZCLOUD_DISK={ label="Azure vm disk size in gigabyte", initialValue="{{.__disk}}", userInputValue="100"}
+AZCLOUD_SSHKEY={ label="Azure Cloud public SSH key", initialValue="{{.__key}}", userInputValue="~/.ssh/id_rsa.pub"}
+AZCLOUD_IMAGE={ label="Azure Cloud VM image", initialValue="{{.__image}}", userInputValue="Ubuntu2204"}
+
+
+#
+# AKS configuration values
+#
+
+[components.aks]
+
+# Mandatory values
+AKS_PROJECT={ label="AKS Name for Resource Group", initialValue="{{.__project}}", userInputValue="openserverless"}
+
+# Non-mandatory values
+AKS_NAME={ label="AKS for Cluster", initialValue="{{.__name}}", userInputValue="openserverless"}
+AKS_COUNT={ label="AKS number of worker nodes", initialValue="{{.__count}}", userInputValue="3"}
+AKS_REGION={ label="AKS location", initialValue="{{.__region}}", userInputValue="eastus"}
+AKS_VM={ label="AKS virtual machine type", initialValue="{{.__vm}}", userInputValue="Standard_B4ms"}
+AKS_DISK={ label="AKS disk size in gigabyte", initialValue="{{.__disk}}", userInputValue="50"}
+AKS_SSHKEY={ label="AKS public SSH key in AWS", initialValue="{{.__key}}", userInputValue="~/.ssh/id_rsa.pub"}
+
+
+
+[components.redis]
+REDIS_URL= {label="Redis URL", initialValue="", userInputValue=""}
+REDIS_SERVICE= {label="Redis Service", initialValue="", userInputValue=""}
+REDIS_PREFIX= {label="Redis Prefix", initialValue="", userInputValue=""}
+REDIS_PASSWORD= {label="Redis password", initialValue="", userInputValue="", type="password"}
+# SECRET_REDIS_DEFAULT
+# REDIS_PORT
+# OPSTUTORIAL_SECRET_REDIS
+# REDIS_PROVIDER
+# REDIS_ALT_URL
+# SECRET_REDIS_NUVOLARIS
+# OPERATOR_COMPONENT_REDIS
+# DEVEL_SECRET_REDIS
+
+[components.postgres]
+POSTGRES_DATABASE={label="Database", initialValue="", userInputValue=""}
+POSTGRES_PORT={label="Port", initialValue="", userInputValue=""}
+POSTGRES_USERNAME={label="Username", initialValue="", userInputValue=""}
+POSTGRES_PASSWORD={label="Password", initialValue="", userInputValue="", type="password"}
+#SECRET_POSTGRES_REPLICA=""
+#OPSTUTORIAL_SECRET_POSTGRES=""
+
+
+
+#OPERATOR_CONFIG_ALERTGMAIL
+#OPENWHISK_TIME_LIMIT_MAX
+#POSTGRES_PORT
+#OPERATOR_COMPONENT_CRON
+#REDIS_PORT
+#S3_BUCKET_STATIC
+#S3_PORT
+#OPERATOR_COMPONENT_INVOKER
+#OPERATOR_COMPONENT_REDIS
+#OPERATOR_CONFIG_TLSEMAIL
+#OPENWHISK_INVOKER_CONTAINER_POOL_MEMORY
+#DEVEL_SECRET_MILVUS
+#OPSTUTORIAL_SECRET_MONGODB
+#IMAGES_STREAMER
+#OPSTUTORIAL_SECRET_MILVUS
+#IMAGES_INVOKER
+#SECRET_NUVOLARIS_METADATA
+#OPERATOR_COMPONENT_ETCD
+#SECRET_MILVUS_ROOT
+#S3_ACCESS_KEY
+#OPSTUTORIAL_SECRET_COUCHDB
+#AUTH
+#SECRET_MONGODB_ADMIN
+#MILVUS_PORT
+#MILVUS_TOKEN
+#SECRET_REDIS_DEFAULT
+#OPERATOR_COMPONENT_KAFKA
+#OPERATOR_CONFIG_HOSTPROTOCOL
+#DEVEL_SECRET_REDIS
+#IMAGES_OPERATOR
+#SECRET_OPENWHISK_SYSTEM
+#MILVUS_HOST
+#S3_API_URL
+#OPERATOR_COMPONENT_TLS
+#DEVEL_SECRET_OPENWHISK
+#OPSDEV_APIHOST
+#OPERATOR_CONFIG_TOLERATIONS
+#MINIO_CONFIG_INGRESS_S3
+#SECRET_COUCHDB_INVOKER
+#OPERATOR_COMPONENT_ZOOKEEPER
+#SECRET_MINIO_NUVOLARIS
+#OPERATOR_COMPONENT_AM
+#CONFIGURED
+#DEVEL_SECRET_MINIO
+#HELLO
+#OPSTUTORIAL_SECRET_MINIO
+#STATUS_LAST
+#OPERATOR_COMPONENT_MILVUS
+#REDIS_PROVIDER
+#SECRET_MONGODB_NUVOLARIS
+#OPERATOR_COMPONENT_STATIC
+#SECRET_ETCD_ROOT
+#OPERATOR_CONFIG_APIHOST
+#OPS_COREUTILS
+#SECRET_COUCHDB_ADMIN
+#USER_V1_API_URL
+#OPSDEV_USERNAME
+#SECRET_POSTGRES_NUVOLARIS
+#S3_PROVIDER
+#ANOTHER
+#OPERATOR_CONFIG_AFFINITY
+#OPERATOR_COMPONENT_POSTGRES
+#S3_HOST
+#SECRET_MILVUS_S3
+#REDIS_URL
+#S3_SECRET_KEY
+#DEVEL_SECRET_POSTGRES
+#SECRET_MILVUS_NUVOLARIS
+#OPERATOR_COMPONENT_MINIO
+#OPERATOR_CONFIG_SLIM
+#POSTGRES_CONFIG_REPLICAS
+#REDIS_PREFIX
+#ETCD_CONFIG_REPLICAS
+#OPERATOR_CONFIG_ALERTSLACK
+#OPSTUTORIAL_SECRET_REDIS
+#STATIC_CONTENT_URL
+#IMAGES_CONTROLLER
+#USER_REST_API_URL
+#DEVEL_SECRET_MONGODB
+#MONGODB_URL
+#HELLO3
+#SECRET_POSTGRES_ADMIN
+#OPERATOR_COMPONENT_MONGODB
+#DEVEL_SECRET_COUCHDB
+#POSTGRES_HOST
+#OPERATOR_COMPONENT_PROMETHEUS
+#REDIS_ALT_URL
+#REDIS_PASSWORD
+#OPERATOR_COMPONENT_REGISTRY
+#OPSTUTORIAL_SECRET_OPENWHISK
+#REGISTRY_CONFIG_SECRET_PUSH_PULL
+#OPSDEV_HOST
+#SECRET_MINIO_ADMIN
+#SECRET_OPENWHISK_NUVOLARIS
+#SECRET_REDIS_NUVOLARIS
+#SECRET_COUCHDB_CONTROLLER
+#OPERATOR_COMPONENT_QUOTA
+#REDIS_SERVICE
+#S3_BUCKET_DATA
+#IMAGES_SYSTEMAPI
+#MILVUS_DB_NAME
diff --git a/util/config/configurator/bun.lockb b/util/config/configurator/bun.lockb
old mode 100644
new mode 100755
index 0cae1e1..8b5b61e
Binary files a/util/config/configurator/bun.lockb and b/util/config/configurator/bun.lockb differ
diff --git a/util/config/configurator/config-accessor.ts b/util/config/configurator/config-accessor.ts
new file mode 100644
index 0000000..35c3550
--- /dev/null
+++ b/util/config/configurator/config-accessor.ts
@@ -0,0 +1,141 @@
+import {ComponentConfig, ConfigParameter} from "./types-core.ts";
+import {OpsConfigFile} from "./types-config-file.ts";
+
+/**
+ * Provides clean API for accessing and modifying configuration data.
+ *
+ * Acts as a facade over OpsConfigFile, simplifying access to nested
+ * configuration data and providing convenience methods for common operations.
+ */
+export class ConfigAccessor {
+ private _config: OpsConfigFile;
+
+ /**
+ * Creates a new ConfigAccessor instance.
+ *
+ * @param config - The OpsConfigFile to provide access to
+ */
+ constructor(config: OpsConfigFile) {
+ this._config = config;
+ }
+
+ /**
+ * Returns all component names in the configuration.
+ *
+ * @returns Array of component names
+ */
+ getComponentNames(): string[] {
+ return this._config.getComponentNames();
+ }
+
+ /**
+ * Retrieves a component by name.
+ *
+ * @param name - The component name to look up
+ * @returns The ComponentConfig if found, undefined otherwise
+ */
+ getComponent(name: string): ComponentConfig | undefined {
+ return this._config.getComponent(name);
+ }
+
+ /**
+ * Retrieves a parameter from a specific component.
+ *
+ * @param componentName - Name of the component containing the parameter
+ * @param key - The parameter key to look up
+ * @returns The ConfigParameter if found, undefined otherwise
+ */
+ getParameter(componentName: string, key: string): ConfigParameter | undefined {
+ const component = this._config.getComponent(componentName);
+ if (!component) {
+ return undefined;
+ }
+ return component.getParameter(key);
+ }
+
+ /**
+ * Retrieves the effective value of a parameter.
+ *
+ * Returns userInputValue if set, otherwise initialValue.
+ * This is the preferred method for getting parameter values.
+ *
+ * @param componentName - Name of the component containing the parameter
+ * @param key - The parameter key to look up
+ * @returns The effective value if found, undefined otherwise
+ */
+ getParameterValue(componentName: string, key: string): string | undefined {
+ const parameter = this.getParameter(componentName, key);
+ if (!parameter) {
+ return undefined;
+ }
+ return parameter.getValue();
+ }
+
+ /**
+ * Sets the user input value for a parameter.
+ *
+ * This modifies the configuration in memory. Changes can be persisted
+ * by saving the configuration to a file.
+ *
+ * @param componentName - Name of the component containing the parameter
+ * @param key - The parameter key to modify
+ * @param value - The new user input value
+ * @returns true if parameter was found and updated, false otherwise
+ */
+ setParameterValue(componentName: string, key: string, value: string): boolean {
+ const parameter = this.getParameter(componentName, key);
+ if (!parameter) {
+ return false;
+ }
+ parameter.setUserInputValue(value);
+ return true;
+ }
+
+ /**
+ * Checks if a component exists in the configuration.
+ *
+ * @param name - The component name to check
+ * @returns true if component exists, false otherwise
+ */
+ hasComponent(name: string): boolean {
+ return this._config.hasComponent(name);
+ }
+
+ /**
+ * Checks if a parameter exists in a specific component.
+ *
+ * @param componentName - Name of the component to check
+ * @param key - The parameter key to check
+ * @returns true if parameter exists, false otherwise
+ */
+ hasParameter(componentName: string, key: string): boolean {
+ const component = this._config.getComponent(componentName);
+ if (!component) {
+ return false;
+ }
+ return component.hasParameter(key);
+ }
+
+ /**
+ * Returns all components in the configuration.
+ *
+ * @returns Array of all ComponentConfig instances
+ */
+ getAllComponents(): ComponentConfig[] {
+ return this._config.getAllComponents();
+ }
+
+ /**
+ * Returns the number of parameters in a specific component.
+ *
+ * @param componentName - Name of the component to count parameters for
+ * @returns Number of parameters, or 0 if component not found
+ */
+ getParameterCount(componentName: string): number {
+ const component = this._config.getComponent(componentName);
+ if (!component) {
+ return 0;
+ }
+ return component.getParameterCount();
+ }
+}
diff --git a/util/config/configurator/config-display.ts b/util/config/configurator/config-display.ts
new file mode 100644
index 0000000..5c9be61
--- /dev/null
+++ b/util/config/configurator/config-display.ts
@@ -0,0 +1,200 @@
+import { note } from '@clack/prompts';
+import { styleText } from 'node:util';
+import {EditableConfigParameter} from "./types-core.ts";
+
+export class ConfigDisplay {
+ static displayParameterTable(
+ componentName: string,
+ parameters: EditableConfigParameter[]
+ ): void {
+ if (parameters.length === 0) {
+ note('No parameters configured for this component.', componentName);
+ return;
+ }
+
+ const mask = (param: EditableConfigParameter, v: string) =>
+ param.getType() === 'password' ? (v ? '***' : '<EMPTY>') : (v || '<EMPTY>');
+
+ const columnWidths = this.calculateColumnWidths(parameters);
+
+ const header = styleText('dim',
+ 'KEY'.padEnd(columnWidths.key) + ' ' +
+ 'Label'.padEnd(columnWidths.label) + ' ' +
+ 'Initial Value'.padEnd(columnWidths.initial) + ' ' +
+ 'Previous Value'.padEnd(columnWidths.previous) + ' ' +
+ 'Current Value'
+ );
+
+ const separator = styleText('dim', '─'.repeat(this.getTotalWidth(columnWidths)));
+
+ const rows = parameters.map(param =>
+ param.getKey().padEnd(columnWidths.key) + ' ' +
+ (param.getLabel() || '<EMPTY>').padEnd(columnWidths.label) + ' ' +
+ mask(param, param.getInitialValue()).padEnd(columnWidths.initial) + ' ' +
+ mask(param, param.getPreviousUserInputValue()).padEnd(columnWidths.previous) + ' ' +
+ mask(param, param.getValue())
+ );
+
+ note([header, separator, ...rows].join('\n'), componentName);
+ }
+
+ /**
+ * Displays a summary of all components and their parameter counts.
+ *
+ * @param components - Array of component data
+ */
+ static displayComponentSummary(
+ components: Array<{ name: string; parameterCount: number; modifiedCount: number }>
+ ): void {
+ console.log(`\n${'='.repeat(60)}`);
+ console.log('Configuration Summary');
+ console.log('='.repeat(60));
+
+ const header = this.formatSimpleRow('Component', 'Parameters', 'Modified');
+ console.log(header);
+ console.log('-'.repeat(60));
+
+ for (const comp of components) {
+ const row = this.formatSimpleRow(
+ comp.name,
+ comp.parameterCount.toString(),
+ comp.modifiedCount.toString()
+ );
+ console.log(row);
+ }
+
+ console.log('='.repeat(60) + '\n');
+ }
+
+ /**
+ * Displays a single parameter's details.
+ *
+ * @param parameter - The parameter to display
+ */
+ static displayParameterDetail(parameter: EditableConfigParameter): void {
+ console.log(`\nParameter: ${parameter.getKey()}`);
+ console.log('-'.repeat(40));
+ console.log(`Label: ${parameter.getLabel() || '<EMPTY>'}`);
+ console.log(`Initial Value: ${parameter.getInitialValue() || '<EMPTY>'}`);
+ console.log(`Previous Value: ${parameter.getPreviousUserInputValue() || '<EMPTY>'}`);
+ console.log(`Current Value: ${parameter.getValue() || '<EMPTY>'}`);
+ console.log(`Has User Input: ${parameter.hasUserInput()}`);
+ console.log(`Is Mandatory: ${parameter.isMandatory()}`);
+ console.log('-'.repeat(40) + '\n');
+ }
+
+ /**
+ * Calculates optimal column widths for the parameter table.
+ *
+ * @param parameters - Parameters to analyze
+ * @returns Object with column widths
+ */
+ private static calculateColumnWidths(
+ parameters: EditableConfigParameter[]
+ ): { key: number; label: number; initial: number; previous: number; current: number } {
+ const widths = {
+ key: 'KEY'.length,
+ label: 'Label'.length,
+ initial: 'Initial Value'.length,
+ previous: 'Previous Value'.length,
+ current: 'Current Value'.length,
+ };
+
+ for (const param of parameters) {
+ const mask = (v: string) => param.getType() === 'password' ? (v ? '***' : '<EMPTY>') : (v || '<EMPTY>');
+ widths.key = Math.max(widths.key, param.getKey().length);
+ widths.label = Math.max(widths.label, (param.getLabel() || '<EMPTY>').length);
+ widths.initial = Math.max(widths.initial, mask(param.getInitialValue()).length);
+ widths.previous = Math.max(widths.previous, mask(param.getPreviousUserInputValue()).length);
+ widths.current = Math.max(widths.current, mask(param.getValue()).length);
+ }
+
+ return widths;
+ }
+
+ /**
+ * Formats a single row of the parameter table.
+ *
+ * @param key - Parameter key
+ * @param label - Parameter label
+ * @param initial - Initial value
+ * @param previous - Previous user input value
+ * @param current - Current value
+ * @param widths - Column widths
+ * @returns Formatted row string
+ */
+ private static formatRow(
+ key: string,
+ label: string,
+ initial: string,
+ previous: string,
+ current: string,
+ widths: { key: number; label: number; initial: number; previous: number; current: number }
+ ): string {
+ const padKey = key.padEnd(widths.key).substring(0, widths.key);
+ const padLabel = label.padEnd(widths.label).substring(0, widths.label);
+ const padInitial = initial.padEnd(widths.initial).substring(0, widths.initial);
+ const padPrevious = previous.padEnd(widths.previous).substring(0, widths.previous);
+ const padCurrent = current.padEnd(widths.current).substring(0, widths.current);
+
+ return `${padKey} | ${padLabel} | ${padInitial} | ${padPrevious} | ${padCurrent}`;
+ }
+
+ /**
+ * Formats a simple two-column row.
+ *
+ * @param col1 - First column value
+ * @param col2 - Second column value
+ * @param col3 - Third column value
+ * @returns Formatted row string
+ */
+ private static formatSimpleRow(col1: string, col2: string, col3: string): string {
+ return `${col1.padEnd(20)} | ${col2.padEnd(15)} | ${col3.padEnd(15)}`;
+ }
+
+ /**
+ * Calculates total table width from column widths.
+ *
+ * @param widths - Column widths
+ * @returns Total width
+ */
+ private static getTotalWidth(
+ widths: { key: number; label: number; initial: number; previous: number; current: number }
+ ): number {
+ return widths.key + widths.label + widths.initial + widths.previous + widths.current + 8;
+ }
+
+ /**
+ * Displays modifications summary.
+ *
+ * @param modifications - Array of modified parameters by component
+ */
+ static displayModifications(
+ modifications: Array<{ component: string; parameters: EditableConfigParameter[] }>
+ ): void {
+ if (modifications.length === 0) {
+ console.log('No modifications detected.\n');
+ return;
+ }
+
+ console.log(`\n${'='.repeat(80)}`);
+ console.log('Modified Parameters');
+ console.log('='.repeat(80));
+
+ for (const mod of modifications) {
+ console.log(`\n[${mod.component}]`);
+ for (const param of mod.parameters) {
+ console.log(` ${param.getKey()}: ${param.getInitialValue() || '<EMPTY>'} → ${param.getValue() || '<EMPTY>'}`);
+ }
+ }
+
+ console.log('\n' + '='.repeat(80) + '\n');
+ }
+
+ /**
+ * Clears the terminal screen.
+ */
+ static clearScreen(): void {
+ console.clear();
+ }
+}
diff --git a/util/config/configurator/config-parser.ts b/util/config/configurator/config-parser.ts
new file mode 100644
index 0000000..e845644
--- /dev/null
+++ b/util/config/configurator/config-parser.ts
@@ -0,0 +1,179 @@
+import type { Result } from './types';
+import {ComponentConfig, ConfigParameter} from "./types-core.ts";
+import {OpsConfigFile} from "./types-config-file.ts";
+
+/**
+ * Parses TOML configuration files and creates OpsConfigFile instances.
+ *
+ * Responsible for reading configuration from disk, parsing TOML format,
+ * and constructing the object-oriented configuration structure.
+ */
+export class ConfigParser {
+ private _filePath: string;
+
+ /**
+ * Creates a new ConfigParser instance.
+ *
+ * @param filePath - Path to the TOML configuration file
+ */
+ constructor(filePath: string) {
+ this._filePath = filePath;
+ }
+
+ /**
+ * Loads and parses the TOML configuration file.
+ *
+ * Reads the file from disk, parses TOML content, and constructs
+ * an OpsConfigFile object with all components and parameters.
+ *
+ * @returns Result containing OpsConfigFile on success, or error message on failure
+ */
+ async load(): Promise<Result<OpsConfigFile, string>> {
+ try {
+ const file = Bun.file(Bun.pathToFileURL(this._filePath));
+
+ if (!(await file.exists())) {
+ return {
+ success: false,
+ error: `Configuration file not found: ${this._filePath}`,
+ };
+ }
+
+ const content = await file.text();
+ const rawData = await this.parseToml(content);
+
+ if (!rawData) {
+ return {
+ success: false,
+ error: `Failed to parse TOML file: ${this._filePath}`,
+ };
+ }
+
+ const config = this.extractComponents(rawData);
+
+ return {
+ success: true,
+ data: config,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: `Error loading configuration: ${error}`,
+ };
+ }
+ }
+
+ /**
+ * Parses TOML content using Bun's import assertion.
+ *
+ * @param content - Raw TOML content (unused, file imported directly)
+ * @returns Parsed TOML data as unknown object, or null on failure
+ */
+ private async parseToml(content: string): Promise<unknown> {
+ try {
+ const data = await import(this._filePath, {
+ assert: { type: 'toml' },
+ });
+ return data;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ /**
+ * Extracts all components from parsed TOML data.
+ *
+ * Iterates through the 'components' section in TOML and creates
+ * ComponentConfig instances for each section found.
+ *
+ * @param rawData - Parsed TOML data
+ * @returns OpsConfigFile containing all extracted components
+ */
+ extractComponents(rawData: unknown): OpsConfigFile {
+ const config = new OpsConfigFile();
+
+ if (typeof rawData !== 'object' || rawData === null) {
+ return config;
+ }
+
+ const data = rawData as Record<string, unknown>;
+
+ if (!data.components || typeof data.components !== 'object') {
+ return config;
+ }
+
+ const components = data.components as Record<string, unknown>;
+
+ for (const [componentName, componentData] of Object.entries(components)) {
+ const component = this.parseComponentSection(componentName, componentData);
+ if (component.getParameterCount() > 0) {
+ config.addComponent(component);
+ }
+ }
+
+ return config;
+ }
+
+ /**
+ * Parses a single component section from TOML.
+ *
+ * Processes all key-value pairs in a component section, creating
+ * ConfigParameter instances for each parameter found.
+ * Skips keys that start with '#' (commented-out parameters).
+ *
+ * @param sectionName - Name of the component section (e.g., 'redis')
+ * @param sectionData - Key-value pairs in this section
+ * @returns ComponentConfig with all parsed parameters
+ */
+ parseComponentSection(sectionName: string, sectionData: unknown): ComponentConfig {
+ const component = new ComponentConfig(sectionName);
+
+ if (typeof sectionData !== 'object' || sectionData === null) {
+ return component;
+ }
+
+ const data = sectionData as Record<string, unknown>;
+
+ for (const [key, value] of Object.entries(data)) {
+ if (key.startsWith('#')) {
+ continue;
+ }
+
+ const parameter = this.parseParameter(key, value);
+ component.addParameter(parameter);
+ }
+
+ return component;
+ }
+
+ /**
+ * Parses a single parameter value from TOML.
+ *
+ * Handles two formats:
+ * - Simple string: `KEY=""` - uses value as initialValue
+ * - Object with metadata: `KEY={label="", initialValue="", userInputValue=""}`
+ *
+ * @param key - The parameter key
+ * @param value - The parameter value (string or object)
+ * @returns ConfigParameter instance
+ */
+ parseParameter(key: string, value: unknown): ConfigParameter {
+ if (typeof value === 'string') {
+ return new ConfigParameter(key, '', value, '', false);
+ }
+
+ if (typeof value === 'object' && value !== null) {
+ const data = value as Record<string, unknown>;
+ const label = typeof data.label === 'string' ? data.label : '';
+ const initialValue = typeof data.initialValue === 'string' ? data.initialValue : '';
+ const userInputValue = typeof data.userInputValue === 'string' ? data.userInputValue : '';
+ const isMandatory = typeof data.isMandatory === 'boolean' ? data.isMandatory : false;
+ const type = data.type === 'password' ? 'password' : 'string';
+ const hint = typeof data.hint === 'string' ? data.hint : undefined;
+
+ return new ConfigParameter(key, label, initialValue, userInputValue, isMandatory, type, hint);
+ }
+
+ return new ConfigParameter(key, '', '', '', false);
+ }
+}
diff --git a/util/config/configurator/config-validator.ts b/util/config/configurator/config-validator.ts
new file mode 100644
index 0000000..fe7e59e
--- /dev/null
+++ b/util/config/configurator/config-validator.ts
@@ -0,0 +1,142 @@
+import type { ValidationResult } from './types';
+import {ComponentConfig, ConfigParameter} from "./types-core.ts";
+import {OpsConfigFile} from "./types-config-file.ts";
+
+/**
+ * List of all managed components in OpenServerless.
+ * Used to validate that configuration only contains known components.
+ */
+const MANAGED_COMPONENTS = [
+ 'redis',
+ 'postgres',
+ 'ferretdb',
+ 'cron',
+ 'prometheus',
+ 'slack',
+ 'mail',
+ 'affinity',
+ 'tolerations',
+ 'quota',
+ 'alert manager',
+ 'aws',
+ 'eks',
+ 'gcloud',
+ 'gke',
+ 'azcloud',
+ 'alert',
+ 'aks',
+];
+
+/**
+ * Validates configuration against business rules.
+ *
+ * Checks that:
+ * - All component names are from the managed components list
+ * - All parameter keys follow naming conventions (uppercase, underscores)
+ */
+export class ConfigValidator {
+ private _config: OpsConfigFile;
+ private _managedComponents: string[];
+ private _errors: string[];
+
+ /**
+ * Creates a new ConfigValidator instance.
+ *
+ * @param config - The OpsConfigFile to validate
+ * @param managedComponents - List of valid component names (default: MANAGED_COMPONENTS)
+ */
+ constructor(config: OpsConfigFile, managedComponents: string[] = MANAGED_COMPONENTS) {
+ this._config = config;
+ this._managedComponents = managedComponents;
+ this._errors = [];
+ }
+
+ /**
+ * Runs all validations and returns the result.
+ *
+ * Validates:
+ * 1. Component names against managed components list
+ * 2. Parameter key naming conventions
+ *
+ * @returns ValidationResult with success status and any errors found
+ */
+ validate(): ValidationResult {
+ this._errors = [];
+
+ this.validateComponentNames();
+ this.validateAllParameters();
+
+ return {
+ success: this._errors.length === 0,
+ errors: this._errors,
+ };
+ }
+
+ /**
+ * Validates that all component names are in the managed components list.
+ * Adds errors for each unknown component found.
+ */
+ private validateComponentNames(): void {
+ const componentNames = this._config.getComponentNames();
+
+ for (const name of componentNames) {
+ if (!this._managedComponents.includes(name)) {
+ this._errors.push(`Unknown component: '${name}'. Valid components: ${this._managedComponents.join(', ')}`);
+ }
+ }
+ }
+
+ /**
+ * Validates parameters for all components.
+ */
+ private validateAllParameters(): void {
+ const components = this._config.getAllComponents();
+
+ for (const component of components) {
+ this.validateComponentParameters(component);
+ }
+ }
+
+ /**
+ * Validates all parameters in a single component.
+ *
+ * @param component - The component to validate
+ */
+ private validateComponentParameters(component: ComponentConfig): void {
+ const parameters = component.getAllParameters();
+
+ for (const parameter of parameters) {
+ this.validateParameterKey(component.getName(), parameter);
+ }
+ }
+
+ /**
+ * Validates a parameter key naming convention.
+ *
+ * Keys must:
+ * - Start with uppercase letter
+ * - Contain only uppercase letters, numbers, and underscores
+ *
+ * @param componentName - Name of the component (for error messages)
+ * @param parameter - The parameter to validate
+ */
+ private validateParameterKey(componentName: string, parameter: ConfigParameter): void {
+ const key = parameter.getKey();
+
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
+ this._errors.push(
+ `Invalid parameter key '${key}' in component '${componentName}'. Keys must be uppercase letters, numbers, and underscores only`
+ );
+ }
+ }
+
+ /**
+ * Returns all validation errors found.
+ * Should be called after validate() to get detailed error messages.
+ *
+ * @returns Array of error messages
+ */
+ getErrors(): string[] {
+ return this._errors;
+ }
+}
diff --git a/util/config/configurator/configurator-class-diagram.mmd b/util/config/configurator/configurator-class-diagram.mmd
new file mode 100644
index 0000000..5967e29
--- /dev/null
+++ b/util/config/configurator/configurator-class-diagram.mmd
@@ -0,0 +1,146 @@
+classDiagram
+ %% Core Domain Classes (types.ts)
+ class ConfigParameter {
+ -_key: string
+ -_label: string
+ -_initialValue: string
+ -_userInputValue: string
+ +getKey(): string
+ +getLabel(): string
+ +setLabel(label: string): void
+ +getInitialValue(): string
+ +getUserInputValue(): string
+ +setUserInputValue(value: string): void
+ +getValue(): string
+ +hasUserInput(): boolean
+ }
+
+ class ComponentConfig {
+ -_name: string
+ -_parameters: Map~string, ConfigParameter~
+ +getName(): string
+ +addParameter(parameter: ConfigParameter): void
+ +getParameter(key: string): ConfigParameter | undefined
+ +getAllParameters(): ConfigParameter[]
+ +getParameterKeys(): string[]
+ +hasParameter(key: string): boolean
+ +getParameterCount(): number
+ }
+
+ class OpsConfigFile {
+ -_components: Map~string, ComponentConfig~
+ +addComponent(component: ComponentConfig): void
+ +getComponent(name: string): ComponentConfig | undefined
+ +getAllComponents(): ComponentConfig[]
+ +getComponentNames(): string[]
+ +hasComponent(name: string): boolean
+ +getComponentCount(): number
+ }
+
+ class EditableConfigParameter {
+ -_previousUserInputValue: string
+ +getPreviousUserInputValue(): string
+ +setPreviousUserInputValue(value: string): void
+ +updateUserInputValue(value: string): void
+ +revertToPrevious(): boolean
+ +fromConfigParameter(param: ConfigParameter)$ EditableConfigParameter
+ }
+
+ class EditableComponentConfig {
+ +getAllParameters(): EditableConfigParameter[]
+ +getParameter(key: string): EditableConfigParameter | undefined
+ }
+
+ class EditableOpsConfigFile {
+ +getAllComponents(): EditableComponentConfig[]
+ +getComponent(name: string): EditableComponentConfig | undefined
+ }
+
+ %% Inheritance
+ ConfigParameter <|-- EditableConfigParameter
+ ComponentConfig <|-- EditableComponentConfig
+ OpsConfigFile <|-- EditableOpsConfigFile
+
+ %% Composition relationships
+ OpsConfigFile *-- ComponentConfig : contains
+ ComponentConfig *-- ConfigParameter : contains
+ EditableOpsConfigFile *-- EditableComponentConfig : contains
+ EditableComponentConfig *-- EditableConfigParameter : contains
+
+ %% Parser & Validator
+ class ConfigParser {
+ -_filePath: string
+ +load(): Promise~Result~OpsConfigFile, string~~
+ +extractComponents(rawData: unknown): OpsConfigFile
+ +parseComponentSection(sectionName: string, sectionData: unknown): ComponentConfig
+ +parseParameter(key: string, value: unknown): ConfigParameter
+ }
+
+ class ConfigValidator {
+ -_config: OpsConfigFile
+ -_managedComponents: string[]
+ -_errors: string[]
+ +validate(): ValidationResult
+ +getErrors(): string[]
+ }
+
+ ConfigParser ..> OpsConfigFile : creates
+ ConfigValidator ..> OpsConfigFile : validates
+
+ %% Configuration Management
+ class PartialConfigManager {
+ -_configFilePath: string
+ -_tmpFilePath: string
+ -_config: EditableOpsConfigFile
+ +load(): Promise~Result~EditableOpsConfigFile, string~~
+ +save(): Promise~Result~void, string~~
+ +clear(): Promise~Result~void, string~~
+ +getConfig(): EditableOpsConfigFile
+ +setParameter(componentName: string, key: string, value: string): Promise~Result~void, string~~
+ +revertParameter(componentName: string, key: string): Promise~Result~void, string~~
+ +hasModifications(): boolean
+ +getModifiedParameters(): Array
+ }
+
+ PartialConfigManager *-- EditableOpsConfigFile : manages
+
+ %% Interactive Components
+ class EditLoop {
+ -_configManager: PartialConfigManager
+ -_exitHandler: ExitHandler
+ -_running: boolean
+ +start(): Promise~Result~void, string~~
+ +stop(): void
+ +isRunning(): boolean
+ }
+
+ class ExitHandler {
+ -_configManager: PartialConfigManager
+ +handleExit(): Promise~Result~void, string~~
+ }
+
+ class ConfigDisplay {
+ +displayParameterTable(componentName: string, parameters: EditableConfigParameter[])$ void
+ +displayComponentSummary(components: Array)$ void
+ +displayParameterDetail(parameter: EditableConfigParameter)$ void
+ +displayModifications(modifications: Array)$ void
+ +clearScreen()$ void
+ }
+
+ EditLoop *-- ExitHandler : uses
+ EditLoop --> PartialConfigManager : uses
+ EditLoop --> ConfigDisplay : uses
+ ExitHandler --> PartialConfigManager : uses
+ ExitHandler --> ConfigDisplay : uses
+
+ %% Result Types
+ class Result~T, E~ {
+ +success: boolean
+ +data?: T
+ +error?: E
+ }
+
+ class ValidationResult {
+ +success: boolean
+ +errors: string[]
+ }
diff --git a/util/config/configurator/configurator.ts b/util/config/configurator/configurator.ts
index 6ae98c3..3057928 100644
--- a/util/config/configurator/configurator.ts
+++ b/util/config/configurator/configurator.ts
@@ -1,17 +1,16 @@
-import { $ } from "bun";
-
import {
intro,
outro,
- select,
- isCancel,
cancel,
- text,
- password,
+ isCancel,
} from "@clack/prompts";
-import color from "picocolors";
-import { parseArgs } from "util";
+import { styleText } from "node:util";
+
+import { ConfigParser } from "./config-parser";
+import { ConfigValidator } from "./config-validator";
+import { EditLoop } from "./edit-loop";
+import { PartialConfigManager } from "./partial-config-manager";
type Prompts = Record<string, PromptData>;
@@ -22,8 +21,6 @@ type PromptData = {
value?: string;
};
-const OPS = process.env.OPS || "ops";
-
export const AdditionalArgsMsg = "Additional arguments will be ignored.";
export const NotValidJsonMsg = "Not a valid JSON file";
export const FileNotFoundJsonMsg = "The JSON file was not found. Pathname: ";
@@ -31,121 +28,154 @@ export const BadConfigMsg =
"Bad configuration file. Check the help message (-h) to see the expected format.";
export const HelpMsg = `
-Usage: config <configjson> [-o | --override]
+Usage: config [-h | --help]
Description:
-Prompt the user for configuration data defined in the config.json file.
-The script will ignore the keys from the input config that are already set in ops
-and only prompt the user for the missing data, unless the override flag is set.
-Then they will be saved in the ops config. You can also define a default value.
-
-The config.json file must be a JSON file with the following structure:
-
- {
- "KEY": {
- "type": "string"
- },
- "OTHER_KEY": {
- "label": "An optional custom message",
- "type": "int",
- "default": "42"
- },
- ...
- }
-
-The keys must be uppercase words (separated by underscores).
-The value for the "type" key must be either string with the following values:
-- string
-- int
-- float
-- bool
-- password
-- an array of strings with specific values (an enum).
+Interactive configuration editor for ops configuration.
+The tool loads configuration from all-config-parameters.toml and provides
+an intuitive interface for editing parameters across all components.
+
+Features:
+- Select components to configure
+- Edit individual parameters
+- Auto-save to .tmp file
+- Resume editing after interruptions
`;
export default async function main() {
- const options: { showhelp: { type: 'boolean' }, override: { type: 'boolean' } } = {
- showhelp: {
- type: 'boolean',
- },
- override: {
- type: 'boolean',
- },
- };
- const { values, positionals } = parseArgs({
- args: Bun.argv,
- options: options,
- strict: true,
- allowPositionals: true,
-
- });
-
- if (values.showhelp) {
+ if (Bun.argv.includes('-h') || Bun.argv.includes('--help')) {
console.log(HelpMsg);
return process.exit(0);
}
- const override = values.override || false;
+ // Load and parse the TOML configuration file using OOP ConfigParser
+ const filePath = './all-config-parameters.toml';
+
+ // Create parser instance and load configuration from TOML file
+ const parser = new ConfigParser(filePath);
+ const loadResult = await parser.load();
- // 1. Read input config json
- const readPosRes = readPositionalFile(positionals);
- if (!readPosRes.success) {
- cancel(readPosRes.message);
+ if (!loadResult.success) {
+ cancel(loadResult.error || 'Failed to load configuration');
return process.exit(1);
}
- if (readPosRes.help) {
- console.log(readPosRes.help);
- return process.exit(0);
+ const config = loadResult.data!;
+
+ // Validate configuration using OOP ConfigValidator
+ // Checks component names and parameter key conventions
+ const validator = new ConfigValidator(config);
+ const validationResult = validator.validate();
+
+ if (!validationResult.success) {
+ console.error('Configuration validation errors:');
+ validationResult.errors.forEach(err => console.error(` - ${err}`));
+ cancel('Invalid configuration file');
+ return process.exit(1);
}
- if (readPosRes.message) {
- console.warn(readPosRes.message);
+ // Create PartialConfigManager and EditLoop for interactive editing
+ const configManager = new PartialConfigManager(filePath);
+
+ const loadPartialResult = await configManager.load();
+ if (!loadPartialResult.success) {
+ cancel(loadPartialResult.error || 'Failed to load partial configuration');
+ return process.exit(1);
}
- // 2. Parse the json
- const jsonRes = await parsePositionalFile(readPosRes.jsonFilePath!);
+ intro(styleText("inverse", " ops configurator "));
+
+ const editLoop = new EditLoop(configManager);
+ const editResult = await editLoop.start();
- if (!jsonRes.success) {
- cancel(jsonRes.message);
+ if (!editResult.success) {
+ cancel(editResult.error || 'Configuration editing failed');
return process.exit(1);
}
- const config = jsonRes.body;
+ console.log();
+ outro("You're all set!");
+ return process.exit(0);
+}
- // 3. Validate the given config json
- if (!isInputConfigValid(config)) {
- cancel(BadConfigMsg);
- return process.exit(1);
+export function findMissingConfig(
+ config: Record<string, any>,
+ opsCurrentConfig: string
+): Prompts {
+ let newConfig: Record<string, any> = {};
+
+ let opsConfigKeys = opsCurrentConfig.split("\n").map((line) => line.split("=")[0]);
+
+ for (const key in config) {
+ if (opsConfigKeys.includes(key)) {
+ continue;
+ }
+ newConfig[key] = config[key];
}
- // 4. Run OPS to get the available config data
- const { exitCode, stderr, stdout } = await $`${OPS} -config -d`.quiet();
- if (exitCode !== 0) {
- cancel(stderr.toString());
- return process.exit(1);
+ return newConfig;
+}
+
+export function readPositionalFile(positionals: string[]): {
+ success: boolean;
+ message?: string;
+ help?: string;
+ jsonFilePath?: string;
+} {
+
+ if (positionals.length < 2) {
+ console.error("This should not happen");
+ return { success: false, message: "This should not happen" };
}
- const opsCurrentConfig = stdout.toString();
+ if (positionals.length === 2) {
+ return { success: true, help: HelpMsg };
+ }
- // 5. Remove the keys from config that are already in the opsConfig
- let missingData = config;
- if (!override) {
- missingData = findMissingConfig(config, opsCurrentConfig);
+ if (positionals.length > 3) {
+ return {
+ success: true,
+ message: AdditionalArgsMsg,
+ jsonFilePath: positionals[2],
+ };
}
- // 6. Ask the user for the missing data
- console.log();
- intro(color.inverse(" ops configurator "));
+ return { success: true, jsonFilePath: positionals[2] };
+}
+
+export function isInputConfigValid(body: Record<string, any>): boolean {
+ if (Object.keys(body).length === 0) {
+ return false;
+ }
- await askMissingData(missingData, override);
+ for (const key in body) {
+ const value = body[key];
+ if (typeof value !== "object") {
+ return false;
+ }
- // 7. Save the data to the config?
- console.log();
+ if (!value.type) {
+ return false;
+ }
- outro("You're all set!");
+ if (
+ !["string", "int", "float", "bool", "password"].includes(value.type) &&
+ !Array.isArray(value.type)
+ ) {
+ return false;
+ }
+ }
+
+ for (const key in body) {
+ if (!/^[A-Z][A-Z_]|[0-9]*[A-Z]|[0-9]$/.test(key)) {
+ return false;
+ }
+ }
+
+ return true;
}
+
async function askMissingData(missingData: Prompts, override: boolean = false) {
if (Object.keys(missingData).length === 0) {
outro("Configuration set from ops");
@@ -305,7 +335,7 @@ export function readPositionalFile(positionals: string[]): {
}
if (positionals.length > 3) {
- return {
+ return {
success: true,
message: AdditionalArgsMsg,
jsonFilePath: positionals[2],
@@ -315,24 +345,7 @@ export function readPositionalFile(positionals: string[]): {
return { success: true, jsonFilePath: positionals[2] };
}
-export async function parsePositionalFile(
- path: string
-): Promise<{ success: boolean; message?: string; body?: any }> {
-
- const file = Bun.file(Bun.pathToFileURL(path));
- if (!await file.exists()) {
- return { success: false, message: FileNotFoundJsonMsg + Bun.pathToFileURL(path) };
- }
-
-
- try {
- const contents = await file.json();
- return { success: true, body: contents };
- } catch (error) {
- return { success: false, message: NotValidJsonMsg };
- }
-}
export function isInputConfigValid(body: Record<string, any>): boolean {
// 1. If the body is empty, return false
@@ -367,4 +380,4 @@ export function isInputConfigValid(body: Record<string, any>): boolean {
}
return true;
-}
+}
\ No newline at end of file
diff --git a/util/config/configurator/edit-loop.ts b/util/config/configurator/edit-loop.ts
new file mode 100644
index 0000000..102b724
--- /dev/null
+++ b/util/config/configurator/edit-loop.ts
@@ -0,0 +1,280 @@
+import { select, isCancel, cancel, text, password, note } from '@clack/prompts';
+import { PartialConfigManager } from './partial-config-manager';
+import { ConfigDisplay } from './config-display';
+import { ExitHandler } from './exit-handler';
+import type { Result } from './types';
+import {EditableComponentConfig, EditableConfigParameter} from "./types-core.ts";
+
+/**
+ * Manages the interactive editing loop for configuration.
+ *
+ * Provides the main workflow:
+ * 1. Display list of components
+ * 2. User selects component with arrows + ENTER
+ * 3. Display parameters table
+ * 4. User selects parameter to edit
+ * 5. Edit parameter value
+ * 6. Save changes
+ * 7. Loop until user exits
+ */
+export class EditLoop {
+ private _configManager: PartialConfigManager;
+ private _exitHandler: ExitHandler;
+ private _running: boolean;
+
+ /**
+ * Creates a new EditLoop instance.
+ *
+ * @param configManager - The configuration manager instance
+ */
+ constructor(configManager: PartialConfigManager) {
+ this._configManager = configManager;
+ this._exitHandler = new ExitHandler(configManager);
+ this._running = false;
+ }
+
+ /**
+ * Starts the interactive editing loop.
+ *
+ * Continues until user chooses to exit.
+ */
+ async start(): Promise<Result<void, string>> {
+ this._running = true;
+
+ while (this._running) {
+ const result = await this.selectComponent();
+
+ if (!result.success) {
+ if (result.error === 'User cancelled') {
+ this._running = false;
+ continue;
+ }
+ return { success: false, error: result.error };
+ }
+
+ if (!result.data) {
+ this._running = false;
+ continue;
+ }
+
+ await this.editComponent(result.data);
+ }
+
+ return await this._exitHandler.handleExit();
+ }
+
+ /**
+ * Displays component selection prompt.
+ *
+ * User can navigate with ↑/↓ arrows and select with ENTER. Pressing ESC or CTRL+C cancels the selection.
+ *
+ * @returns Result with selected component name or error
+ *
+ * Result can be:
+ * - { success: false, error: 'No components found in configuration' } when no components are found in the given configuration.
+ * - { success: false, error: 'User cancelled' } when user cancels the selection
+ * - { success: true, data: 'ComponentName' } when a component is selected
+ * - { success: true, data: null } when user chooses to exit
+ * - { success: false, error: 'Error message' } on failure or cancellation
+ */
+ private async selectComponent(): Promise<Result<string | null, string>> {
+ const config = this._configManager.getConfig();
+ const components = config.getAllComponents();
+
+ if (components.length === 0) {
+ return {
+ success: false,
+ error: 'No components found in configuration',
+ };
+ }
+
+ const options = components.map((comp) => {
+ const paramCount = comp.getParameterCount();
+ return {
+ label: `${comp.getName()} (${paramCount} parameters)`,
+ value: comp.getName(),
+ };
+ });
+
+ options.push({ label: 'Exit Configuration', value: '__exit__' });
+
+ const selected = await select({
+ message: 'Select a component to configure (use ↑/↓ arrows, ENTER to select):',
+ options,
+ });
+
+ if (isCancel(selected)) {
+ return { success: false, error: 'User cancelled' };
+ }
+
+ if (selected === '__exit__') {
+ return { success: true, data: null };
+ }
+
+ return { success: true, data: selected as string };
+ }
+
+ /**
+ * Handles editing of a specific component.
+ *
+ * @param componentName - Name of the component to edit
+ */
+ private async editComponent(componentName: string): Promise<void> {
+ const component = this._configManager.getConfig().getComponent(componentName);
+
+ if (!component) {
+ console.error(`Component '${componentName}' not found`);
+ return;
+ }
+
+ let editingComponent = true;
+
+ while (editingComponent) {
+ ConfigDisplay.clearScreen();
+ ConfigDisplay.displayParameterTable(componentName, component.getAllParameters());
+
+ const result = await this.selectParameter(component);
+
+ if (!result.success) {
+ editingComponent = false;
+ continue;
+ }
+
+ if (!result.data) {
+ editingComponent = false;
+ continue;
+ }
+
+ await this.editParameter(component, result.data);
+ }
+ }
+
+ /**
+ * Displays parameter selection prompt for a component.
+ *
+ * @param component - The component to select parameters from
+ * @returns Result with selected parameter key or error
+ */
+ private async selectParameter(
+ component: EditableComponentConfig
+ ): Promise<Result<string | null, string>> {
+ const parameters = component.getAllParameters();
+
+ if (parameters.length === 0) {
+ return { success: false, error: 'No parameters in this component' };
+ }
+
+ const options = parameters.map((param) => {
+ const raw = param.getValue();
+ const currentValue = param.getType() === 'password'
+ ? (raw ? '***' : '<EMPTY>')
+ : (raw || '<EMPTY>');
+ return {
+ label: `${param.getLabel()} = ${currentValue}`,
+ value: param.getKey(),
+ };
+ });
+
+ options.push({ label: '← Back to components', value: '__back__' });
+
+ const selected = await select({
+ message: 'Select a parameter to edit (use ↑/↓ arrows, ENTER to select, ESC or CTRL+C to Cancel):',
+ options,
+ });
+
+ if (isCancel(selected)) {
+ return { success: false, error: 'User cancelled' };
+ }
+
+ if (selected === '__back__') {
+ return { success: true, data: null };
+ }
+
+ return { success: true, data: selected as string };
+ }
+
+ /**
+ * Handles editing of a specific parameter.
+ *
+ * @param component - The component containing the parameter
+ * @param paramKey - The parameter key to edit
+ */
+ private async editParameter(
+ component: EditableComponentConfig,
+ paramKey: string
+ ): Promise<void> {
+ const parameter = component.getParameter(paramKey);
+
+ if (!parameter) {
+ console.error(`Parameter '${paramKey}' not found`);
+ return;
+ }
+
+ // ConfigDisplay.displayParameterDetail(parameter);
+
+ // note("Type new value and press ENTER to confirm\nPress CTRL+C or ESC to cancel. The previous value will be preserved if cancelled\n","Instructions")
+
+ const currentValue = parameter.getUserInputValue() || parameter.getInitialValue();
+ const isPassword = parameter.getType() === 'password';
+ const hint = parameter.getHint();
+
+ if (hint) {
+ note(hint, "What is this parameter for?");
+ }
+
+ let value: string | null;
+
+ const promptInput = isPassword
+ ? await password({
+ //message: `Enter new value for ${paramKey}:`,
+ message: `Enter new value for ${parameter.getLabel()}:`,
+ validate: (v) => {
+ if (parameter.isMandatory() && !v) return 'this field is mandatory and cannot be empty';
+ return undefined;
+ },
+ })
+ : await text({
+ message: `Enter new value for ${parameter.getLabel()}:`,
+ initialValue: currentValue,
+ placeholder: currentValue || 'Enter value...',
+ validate: (v) => {
+ if (parameter.isMandatory() && !v) return 'this field is mandatory and cannot be empty';
+ return undefined;
+ },
+ });
+
+ if (isCancel(promptInput)) {
+ console.log('\nCancelled - value unchanged.\n');
+ return;
+ }
+ value = promptInput as string;
+
+ const result = await this._configManager.setParameter(
+ component.getName(),
+ paramKey,
+ value
+ );
+
+ if (result.success) {
+ console.log(`\n✓ Updated ${paramKey} to: ${isPassword ? '***' : value}\n`);
+ } else {
+ console.error(`\n✗ Failed to update ${paramKey}: ${result.error}\n`);
+ }
+ }
+
+ /**
+ * Stops the editing loop.
+ */
+ stop(): void {
+ this._running = false;
+ }
+
+ /**
+ * Checks if the editing loop is running.
+ *
+ * @returns true if running, false otherwise
+ */
+ isRunning(): boolean {
+ return this._running;
+ }
+}
diff --git a/util/config/configurator/exit-handler.ts b/util/config/configurator/exit-handler.ts
new file mode 100644
index 0000000..371405a
--- /dev/null
+++ b/util/config/configurator/exit-handler.ts
@@ -0,0 +1,245 @@
+import { $ } from 'bun';
+import { select, isCancel, cancel } from '@clack/prompts';
+import { PartialConfigManager } from './partial-config-manager';
+import { ConfigDisplay } from './config-display';
+import type { Result } from './types';
+
+const OPS = process.env.OPS || 'ops';
+
+/**
+ * Handles application exit workflow with configuration options.
+ *
+ * Provides options to:
+ * - Apply configuration directly to ops
+ * - Save configuration to a file
+ * - Discard all changes
+ */
+export class ExitHandler {
+ private _configManager: PartialConfigManager;
+
+ /**
+ * Creates a new ExitHandler instance.
+ *
+ * @param configManager - The configuration manager instance
+ */
+ constructor(configManager: PartialConfigManager) {
+ this._configManager = configManager;
+ }
+
+ /**
+ * Handles the exit workflow by presenting options to the user.
+ *
+ * @returns Result indicating success or failure with error message
+ */
+ async handleExit(): Promise<Result<void, string>> {
+ const hasModifications = this._configManager.hasModifications();
+
+ if (!hasModifications) {
+ console.log('\nNo modifications to apply.\n');
+ await this._configManager.clear();
+ return { success: true };
+ }
+
+ const modifications = this._configManager.getModifiedParameters();
+ ConfigDisplay.displayModifications(modifications);
+
+ const action = await select({
+ message: 'What would you like to do with your changes?',
+ options: [
+ { label: 'Apply configuration directly', value: 'apply' },
+ { label: 'Save configuration to file', value: 'save' },
+ { label: 'Discard all changes', value: 'discard' },
+ ],
+ });
+
+ if (isCancel(action)) {
+ return { success: true };
+ }
+
+ switch (action) {
+ case 'apply':
+ return await this.applyConfiguration();
+ case 'save':
+ return await this.saveConfigurationToFile();
+ case 'discard':
+ return await this.discardChanges();
+ default:
+ return { success: true };
+ }
+ }
+
+ /**
+ * Applies configuration directly to ops by executing ops -config KEY=VALUE commands.
+ *
+ * @returns Result indicating success or failure
+ */
+ private async applyConfiguration(): Promise<Result<void, string>> {
+ const modifications = this._configManager.getModifiedParameters();
+
+ if (modifications.length === 0) {
+ console.log('No modifications to apply.\n');
+ return { success: true };
+ }
+
+ console.log('\nApplying configuration to ops...\n');
+
+ let appliedCount = 0;
+ let errorCount = 0;
+
+ for (const mod of modifications) {
+ for (const param of mod.parameters) {
+ const key = param.getKey();
+ const value = param.getValue();
+
+ if (!value || value === '') {
+ console.log(`Skipping ${key} (empty value)`);
+ continue;
+ }
+
+ try {
+ const { exitCode, stderr } = await $`${OPS} -config ${key}=${value}`.nothrow().quiet();
+
+ if (exitCode === 0) {
+ console.log(`✓ Set ${key}=${value}`);
+ appliedCount++;
+ } else {
+ console.error(`✗ Failed to set ${key}: ${stderr.toString()}`);
+ errorCount++;
+ }
+ } catch (error) {
+ console.error(`✗ Error setting ${key}: ${error}`);
+ errorCount++;
+ }
+ }
+ }
+
+ console.log(`\nApplied ${appliedCount} configuration(s).`);
+ if (errorCount > 0) {
+ console.log(`Failed to apply ${errorCount} configuration(s).\n`);
+ } else {
+ console.log();
+ }
+
+ if (errorCount === 0) {
+ await this._configManager.clear();
+ return { success: true };
+ } else {
+ return {
+ success: false,
+ error: `Failed to apply ${errorCount} configuration(s)`,
+ };
+ }
+ }
+
+ /**
+ * Saves configuration to a TOML file that can be used later.
+ *
+ * @returns Result indicating success or failure
+ */
+ private async saveConfigurationToFile(): Promise<Result<void, string>> {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
+ const outputFileName = `ops-config-${timestamp}.toml`;
+
+ try {
+ const config = this._configManager.getConfig();
+ const tomlData = this.serializeConfigToToml(config);
+
+ await Bun.write(outputFileName, tomlData);
+
+ console.log(`\n✓ Configuration saved to: ${outputFileName}\n`);
+
+ const shouldClear = await select({
+ message: 'Clear partial configuration?',
+ options: [
+ { label: 'Yes', value: 'yes' },
+ { label: 'No', value: 'no' },
+ ],
+ });
+
+ if (!isCancel(shouldClear) && shouldClear === 'yes') {
+ await this._configManager.clear();
+ console.log('Partial configuration cleared.\n');
+ }
+
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: `Failed to save configuration: ${error}`,
+ };
+ }
+ }
+
+ /**
+ * Discards all changes by clearing the partial configuration.
+ *
+ * @returns Result indicating success or failure
+ */
+ private async discardChanges(): Promise<Result<void, string>> {
+ const clearResult = await this._configManager.clear();
+
+ if (clearResult.success) {
+ console.log('\n✓ All changes discarded.\n');
+ }
+
+ return clearResult;
+ }
+
+ /**
+ * Serializes configuration to TOML format for file output.
+ *
+ * @param config - The configuration to serialize
+ * @returns TOML formatted string
+ */
+ private serializeConfigToToml(config: any): string {
+ let toml = '# OpenServerless Configuration\n';
+ toml += `# Generated: ${new Date().toISOString()}\n\n`;
+
+ const components = config.getAllComponents();
+ for (const component of components) {
+ const parameters = component.getAllParameters();
+ const modifiedParams = parameters.filter(
+ (p: any) => p.getValue() !== '' && p.getValue() !== p.getInitialValue()
+ );
+
+ if (modifiedParams.length === 0) {
+ continue;
+ }
+
+ toml += `[components.${component.getName()}]\n`;
+
+ for (const param of modifiedParams) {
+ const key = param.getKey();
+ const value = param.getValue();
+ const label = param.getLabel();
+ const escapedValue = this.escapeTomlValue(value);
+
+ if (label) {
+ const escapedLabel = this.escapeTomlValue(label);
+ toml += `${key}={label="${escapedLabel}", userInputValue="${escapedValue}"}\n`;
+ } else {
+ toml += `${key}="${escapedValue}"\n`;
+ }
+ }
+
+ toml += '\n';
+ }
+
+ return toml;
+ }
+
+ /**
+ * Escapes a value for TOML format.
+ *
+ * @param value - The value to escape
+ * @returns Escaped string safe for TOML
+ */
+ private escapeTomlValue(value: string): string {
+ return value
+ .replace(/\\/g, '\\\\')
+ .replace(/"/g, '\\"')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '\\r')
+ .replace(/\t/g, '\\t');
+ }
+}
diff --git a/util/config/configurator/package-lock.json b/util/config/configurator/package-lock.json
new file mode 100644
index 0000000..da30228
--- /dev/null
+++ b/util/config/configurator/package-lock.json
@@ -0,0 +1,123 @@
+{
+ "name": "prompt",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "prompt",
+ "dependencies": {
+ "@clack/prompts": "^1.0.1",
+ "toml": "^3.0.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ }
+ },
+ "node_modules/@clack/core": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.0.tgz",
+ "integrity": "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==",
+ "dependencies": {
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
+ "node_modules/@clack/prompts": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.3.0.tgz",
+ "integrity": "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==",
+ "dependencies": {
+ "@clack/core": "1.3.0",
+ "fast-string-width": "^3.0.2",
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
+ "node_modules/@types/bun": {
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.13.tgz",
+ "integrity": "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==",
+ "dev": true,
+ "dependencies": {
+ "bun-types": "1.3.13"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.6.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
+ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~7.19.0"
+ }
+ },
+ "node_modules/bun-types": {
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.13.tgz",
+ "integrity": "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz",
+ "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="
+ },
+ "node_modules/toml": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
+ "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.19.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
+ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "dev": true
+ }
+ }
+}
diff --git a/util/config/configurator/package.json b/util/config/configurator/package.json
index bbd833c..606eeba 100644
--- a/util/config/configurator/package.json
+++ b/util/config/configurator/package.json
@@ -4,6 +4,7 @@
"type": "module",
"scripts": {
"start": "bun index.ts",
+ "startdebug": "bun --inspect-wait index.ts",
"test": "bun test tests/index.test.ts",
"build": "bun build --target=bun index.ts > configurator.js && mv configurator.js ../configurator.js"
},
@@ -14,6 +15,7 @@
"typescript": "^5.0.0"
},
"dependencies": {
- "@clack/prompts": "^0.7.0"
+ "@clack/prompts": "^1.0.1",
+ "toml": "^3.0.0"
}
}
diff --git a/util/config/configurator/partial-config-manager.ts b/util/config/configurator/partial-config-manager.ts
new file mode 100644
index 0000000..53536cd
--- /dev/null
+++ b/util/config/configurator/partial-config-manager.ts
@@ -0,0 +1,424 @@
+import type { Result } from './types';
+import {EditableComponentConfig, EditableConfigParameter} from "./types-core.ts";
+import {EditableOpsConfigFile} from "./types-config-file.ts";
+
+/**
+ * Manages partial configuration state with persistence to .tmp file.
+ *
+ * This class provides an abstraction layer for reading and writing
+ * partial configuration, enabling users to continue where they left off
+ * after exiting the application.
+ */
+export class PartialConfigManager {
+ private _configFilePath: string;
+ private _tmpFilePath: string;
+ private _config: EditableOpsConfigFile;
+
+ /**
+ * Creates a new PartialConfigManager instance.
+ *
+ * @param configFilePath - Path to the main configuration file
+ */
+ constructor(configFilePath: string) {
+ this._configFilePath = configFilePath;
+ this._tmpFilePath = configFilePath.replace(/\.toml$/, '.tmp');
+ this._config = new EditableOpsConfigFile();
+ }
+
+ /**
+ * Returns the path to the temporary file.
+ *
+ * @returns The .tmp file path
+ */
+ getTmpFilePath(): string {
+ return this._tmpFilePath;
+ }
+
+ /**
+ * Returns the path to the main configuration file.
+ *
+ * @returns The configuration file path
+ */
+ getConfigFilePath(): string {
+ return this._configFilePath;
+ }
+
+ /**
+ * Checks if a partial configuration file exists.
+ *
+ * @returns true if .tmp file exists, false otherwise
+ */
+ async hasPartialConfig(): Promise<boolean> {
+ const file = Bun.file(this._tmpFilePath);
+ return await file.exists();
+ }
+
+ /**
+ * Loads configuration from the .tmp file if it exists,
+ * otherwise loads from the main configuration file.
+ *
+ * @returns Result containing the loaded configuration or error message
+ */
+ async load(): Promise<Result<EditableOpsConfigFile, string>> {
+ try {
+ const hasPartial = await this.hasPartialConfig();
+ const filePathToLoad = hasPartial ? this._tmpFilePath : this._configFilePath;
+
+ const file = Bun.file(filePathToLoad);
+ if (!(await file.exists())) {
+ return {
+ success: false,
+ error: `Configuration file not found: ${filePathToLoad}`,
+ };
+ }
+
+ const content = await file.text();
+ const rawData = await this.parseTomlContent(content);
+
+ if (!rawData) {
+ return {
+ success: false,
+ error: `Failed to parse TOML file: ${filePathToLoad}`,
+ };
+ }
+
+ this._config = this.extractComponents(rawData);
+
+ return {
+ success: true,
+ data: this._config,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: `Error loading configuration: ${error}`,
+ };
+ }
+ }
+
+ /**
+ * Parses TOML content directly without caching.
+ *
+ * @param content - Raw TOML content
+ * @returns Parsed TOML data as unknown object, or null on failure
+ */
+ private async parseTomlContent(content: string): Promise<unknown> {
+ try {
+ const toml = await import('toml');
+ return toml.parse(content);
+ } catch (error) {
+ return null;
+ }
+ }
+
+ /**
+ * Saves the current configuration state to the .tmp file.
+ *
+ * @returns Result indicating success or failure with error message
+ */
+ async save(): Promise<Result<void, string>> {
+ try {
+ const tomlData = this.serializeToToml();
+ await Bun.write(this._tmpFilePath, tomlData);
+
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: `Error saving configuration: ${error}`,
+ };
+ }
+ }
+
+ /**
+ * Clears the partial configuration by deleting the .tmp file.
+ *
+ * @returns Result indicating success or failure with error message
+ */
+ async clear(): Promise<Result<void, string>> {
+ try {
+ const file = Bun.file(this._tmpFilePath);
+ if (await file.exists()) {
+ const { $ } = await import('bun');
+ await $`rm ${this._tmpFilePath}`.quiet();
+ }
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: `Error clearing partial configuration: ${error}`,
+ };
+ }
+ }
+
+ /**
+ * Returns the current configuration state.
+ *
+ * @returns The editable configuration
+ */
+ getConfig(): EditableOpsConfigFile {
+ return this._config;
+ }
+
+ /**
+ * Retrieves a parameter from a specific component.
+ *
+ * @param componentName - Name of the component
+ * @param key - Parameter key
+ * @returns The parameter if found, undefined otherwise
+ */
+ getParameter(componentName: string, key: string): EditableConfigParameter | undefined {
+ const component = this._config.getComponent(componentName);
+ if (!component) {
+ return undefined;
+ }
+ return component.getParameter(key);
+ }
+
+ /**
+ * Sets the user input value for a parameter.
+ *
+ * Updates the parameter and automatically saves to the .tmp file.
+ * Tracks the previous value in previousUserInputValue.
+ *
+ * @param componentName - Name of the component
+ * @param key - Parameter key
+ * @param value - New user input value
+ * @returns Result indicating success or failure
+ */
+ async setParameter(
+ componentName: string,
+ key: string,
+ value: string
+ ): Promise<Result<void, string>> {
+ const parameter = this.getParameter(componentName, key);
+ if (!parameter) {
+ return {
+ success: false,
+ error: `Parameter '${key}' not found in component '${componentName}'`,
+ };
+ }
+
+ parameter.updateUserInputValue(value);
+
+ const saveResult = await this.save();
+ if (!saveResult.success) {
+ return saveResult;
+ }
+
+ return { success: true };
+ }
+
+ /**
+ * Reverts a parameter to its previous user input value.
+ *
+ * @param componentName - Name of the component
+ * @param key - Parameter key
+ * @returns Result indicating success or failure
+ */
+ async revertParameter(
+ componentName: string,
+ key: string
+ ): Promise<Result<void, string>> {
+ const parameter = this.getParameter(componentName, key);
+ if (!parameter) {
+ return {
+ success: false,
+ error: `Parameter '${key}' not found in component '${componentName}'`,
+ };
+ }
+
+ const reverted = parameter.revertToPrevious();
+ if (!reverted) {
+ return {
+ success: false,
+ error: `No previous value to revert for parameter '${key}'`,
+ };
+ }
+
+ const saveResult = await this.save();
+ if (!saveResult.success) {
+ return saveResult;
+ }
+
+ return { success: true };
+ }
+
+ /**
+ * Extracts all components from parsed TOML data.
+ *
+ * @param rawData - Parsed TOML data
+ * @returns EditableOpsConfigFile containing all extracted components
+ */
+ private extractComponents(rawData: unknown): EditableOpsConfigFile {
+ const config = new EditableOpsConfigFile();
+
+ if (typeof rawData !== 'object' || rawData === null) {
+ return config;
+ }
+
+ const data = rawData as Record<string, unknown>;
+
+ if (!data.components || typeof data.components !== 'object') {
+ return config;
+ }
+
+ const components = data.components as Record<string, unknown>;
+
+ for (const [componentName, componentData] of Object.entries(components)) {
+ const component = this.parseComponentSection(componentName, componentData);
+ if (component.getParameterCount() > 0) {
+ config.addComponent(component);
+ }
+ }
+
+ return config;
+ }
+
+ /**
+ * Parses a single component section from TOML.
+ *
+ * @param sectionName - Name of the component section
+ * @param sectionData - Key-value pairs in this section
+ * @returns EditableComponentConfig with all parsed parameters
+ */
+ private parseComponentSection(sectionName: string, sectionData: unknown): EditableComponentConfig {
+ const component = new EditableComponentConfig(sectionName);
+
+ if (typeof sectionData !== 'object' || sectionData === null) {
+ return component;
+ }
+
+ const data = sectionData as Record<string, unknown>;
+
+ for (const [key, value] of Object.entries(data)) {
+ if (key.startsWith('#')) {
+ continue;
+ }
+
+ const parameter = this.parseParameter(key, value);
+ component.addParameter(parameter);
+ }
+
+ return component;
+ }
+
+ /**
+ * Parses a single parameter value from TOML.
+ *
+ * @param key - The parameter key
+ * @param value - The parameter value
+ * @returns EditableConfigParameter instance
+ */
+ private parseParameter(key: string, value: unknown): EditableConfigParameter {
+ if (typeof value === 'string') {
+ return new EditableConfigParameter(key, '', value, '', '', false);
+ }
+
+ if (typeof value === 'object' && value !== null) {
+ const data = value as Record<string, unknown>;
+ const label = typeof data.label === 'string' ? data.label : '';
+ const initialValue = typeof data.initialValue === 'string' ? data.initialValue : '';
+ const userInputValue = typeof data.userInputValue === 'string' ? data.userInputValue : '';
+ const previousUserInputValue = typeof data.previousUserInputValue === 'string'
+ ? data.previousUserInputValue : '';
+ const isMandatory = typeof data.isMandatory === 'boolean' ? data.isMandatory : false;
+ const type = data.type === 'password' ? 'password' : 'string';
+ const hint = typeof data.hint === 'string' ? data.hint : undefined;
+
+ return new EditableConfigParameter(key, label, initialValue, userInputValue, previousUserInputValue, isMandatory, type, hint);
+ }
+
+ return new EditableConfigParameter(key, '', '', '', '', false);
+ }
+
+ /**
+ * Serializes the editable configuration to TOML format.
+ *
+ * Format: KEY={label="<LABEL>", initialValue="<VALUE>", userInputValue="<VALUE>", previousUserInputValue="<VALUE>"}
+ *
+ * @returns TOML formatted string
+ */
+ private serializeToToml(): string {
+ let toml = '# Partial configuration file\n';
+ toml += '# Auto-generated by the ops configurator\n\n';
+
+ const components = this._config.getAllComponents();
+ for (const component of components) {
+ toml += `[components.${component.getName()}]\n`;
+
+ const parameters = component.getAllParameters();
+ for (const param of parameters) {
+ const label = this.escapeTomlValue(param.getLabel());
+ const initialValue = this.escapeTomlValue(param.getInitialValue());
+ const userInputValue = this.escapeTomlValue(param.getUserInputValue());
+ const previousUserInputValue = this.escapeTomlValue(param.getPreviousUserInputValue());
+
+ const hintPart = param.getHint() ? `, hint="${this.escapeTomlValue(param.getHint()!)}"` : '';
+ toml += `${param.getKey()}={label="${label}", initialValue="${initialValue}", userInputValue="${userInputValue}", previousUserInputValue="${previousUserInputValue}", isMandatory=${param.isMandatory()}, type="${param.getType()}"${hintPart}}\n`;
+ }
+
+ toml += '\n';
+ }
+
+ return toml;
+ }
+
+ /**
+ * Escapes a value for TOML format.
+ *
+ * @param value - The value to escape
+ * @returns Escaped string safe for TOML
+ */
+ private escapeTomlValue(value: string): string {
+ return value
+ .replace(/\\/g, '\\\\')
+ .replace(/"/g, '\\"')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '\\r')
+ .replace(/\t/g, '\\t');
+ }
+
+ /**
+ * Checks if there are any modifications since the initial load.
+ *
+ * @returns true if any parameter has been modified, false otherwise
+ */
+ hasModifications(): boolean {
+ const components = this._config.getAllComponents();
+ for (const component of components) {
+ const parameters = component.getAllParameters();
+ for (const param of parameters) {
+ if (param.getUserInputValue() !== '' || param.getPreviousUserInputValue() !== '') {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns all modified parameters across all components.
+ *
+ * @returns Array of objects containing component name and modified parameters
+ */
+ getModifiedParameters(): Array<{ component: string; parameters: EditableConfigParameter[] }> {
+ const modified: Array<{ component: string; parameters: EditableConfigParameter[] }> = [];
+
+ const components = this._config.getAllComponents();
+ for (const component of components) {
+ const modifiedParams = component.getAllParameters().filter(
+ param => param.getUserInputValue() !== '' || param.getPreviousUserInputValue() !== ''
+ );
+
+ if (modifiedParams.length > 0) {
+ modified.push({
+ component: component.getName(),
+ parameters: modifiedParams,
+ });
+ }
+ }
+
+ return modified;
+ }
+}
diff --git a/util/config/configurator/tests/config-parser.test.ts b/util/config/configurator/tests/config-parser.test.ts
new file mode 100644
index 0000000..1692cb5
--- /dev/null
+++ b/util/config/configurator/tests/config-parser.test.ts
@@ -0,0 +1,323 @@
+import { expect, test, describe } from 'bun:test';
+import { ConfigParser } from '../config-parser';
+import { join } from 'path';
+
+const fixturesDir = join(import.meta.dir, 'fixtures');
+
+// ─── parseParameter ──────────────────────────────────────────────────────────
+
+describe('ConfigParser.parseParameter', () => {
+ const parser = new ConfigParser('');
+
+ test('string value uses value as initialValue', () => {
+ const param = parser.parseParameter('MY_KEY', 'hello');
+ expect(param.getKey()).toBe('MY_KEY');
+ expect(param.getInitialValue()).toBe('hello');
+ expect(param.getLabel()).toBe('');
+ expect(param.getUserInputValue()).toBe('');
+ expect(param.isMandatory()).toBe(false);
+ expect(param.getType()).toBe('string');
+ });
+
+ test('empty string value produces empty initialValue', () => {
+ const param = parser.parseParameter('EMPTY', '');
+ expect(param.getInitialValue()).toBe('');
+ expect(param.isMandatory()).toBe(false);
+ });
+
+ test('object value maps all fields', () => {
+ const param = parser.parseParameter('DB_HOST', {
+ label: 'Database host',
+ initialValue: '127.0.0.1',
+ userInputValue: 'prod.db.local',
+ isMandatory: false,
+ });
+ expect(param.getKey()).toBe('DB_HOST');
+ expect(param.getLabel()).toBe('Database host');
+ expect(param.getInitialValue()).toBe('127.0.0.1');
+ expect(param.getUserInputValue()).toBe('prod.db.local');
+ expect(param.isMandatory()).toBe(false);
+ expect(param.getType()).toBe('string');
+ });
+
+ test('object with isMandatory true', () => {
+ const param = parser.parseParameter('SECRET', {
+ label: 'Secret key',
+ initialValue: '',
+ userInputValue: '',
+ isMandatory: true,
+ });
+ expect(param.isMandatory()).toBe(true);
+ });
+
+ test('object with type password', () => {
+ const param = parser.parseParameter('API_SECRET', {
+ label: 'API Secret',
+ initialValue: '',
+ userInputValue: '',
+ type: 'password',
+ });
+ expect(param.getType()).toBe('password');
+ });
+
+ test('object with type string is explicit string', () => {
+ const param = parser.parseParameter('API_KEY', {
+ label: 'API Key',
+ initialValue: '',
+ userInputValue: '',
+ type: 'string',
+ });
+ expect(param.getType()).toBe('string');
+ });
+
+ test('object with unknown type defaults to string', () => {
+ const param = parser.parseParameter('KEY', { type: 'email' });
+ expect(param.getType()).toBe('string');
+ });
+
+ test('object without type defaults to string', () => {
+ const param = parser.parseParameter('KEY', { label: 'Some label' });
+ expect(param.getType()).toBe('string');
+ });
+
+ test('object with missing fields falls back to defaults', () => {
+ const param = parser.parseParameter('KEY', {});
+ expect(param.getLabel()).toBe('');
+ expect(param.getInitialValue()).toBe('');
+ expect(param.getUserInputValue()).toBe('');
+ expect(param.isMandatory()).toBe(false);
+ });
+
+ test('object with non-string label field is ignored', () => {
+ const param = parser.parseParameter('KEY', { label: 42, initialValue: 'x' });
+ expect(param.getLabel()).toBe('');
+ expect(param.getInitialValue()).toBe('x');
+ });
+
+ test('null value returns parameter with empty fields', () => {
+ const param = parser.parseParameter('KEY', null);
+ expect(param.getKey()).toBe('KEY');
+ expect(param.getInitialValue()).toBe('');
+ expect(param.getUserInputValue()).toBe('');
+ expect(param.isMandatory()).toBe(false);
+ });
+
+ test('number value returns parameter with empty fields', () => {
+ const param = parser.parseParameter('NUM_KEY', 42);
+ expect(param.getInitialValue()).toBe('');
+ expect(param.getUserInputValue()).toBe('');
+ });
+
+ test('object with hint field extracts it', () => {
+ const param = parser.parseParameter('VM_TYPE', {
+ label: 'VM instance type',
+ hint: 'Choose an instance with at least 8GB RAM',
+ });
+ expect(param.getHint()).toBe('Choose an instance with at least 8GB RAM');
+ });
+
+ test('object with multiline hint preserves newlines', () => {
+ const param = parser.parseParameter('VM_TYPE', {
+ hint: 'Line one\nLine two\nLine three',
+ });
+ expect(param.getHint()).toBe('Line one\nLine two\nLine three');
+ });
+
+ test('object without hint field returns undefined', () => {
+ const param = parser.parseParameter('NO_HINT', { label: 'No hint here' });
+ expect(param.getHint()).toBeUndefined();
+ });
+
+ test('object with non-string hint is ignored', () => {
+ const param = parser.parseParameter('BAD_HINT', { hint: 42 });
+ expect(param.getHint()).toBeUndefined();
+ });
+
+ test('string value has no hint', () => {
+ const param = parser.parseParameter('PLAIN', 'somevalue');
+ expect(param.getHint()).toBeUndefined();
+ });
+});
+
+// ─── parseComponentSection ───────────────────────────────────────────────────
+
+describe('ConfigParser.parseComponentSection', () => {
+ const parser = new ConfigParser('');
+
+ test('returns component with correct name', () => {
+ const component = parser.parseComponentSection('redis', {});
+ expect(component.getName()).toBe('redis');
+ });
+
+ test('null sectionData returns empty component', () => {
+ const component = parser.parseComponentSection('redis', null);
+ expect(component.getParameterCount()).toBe(0);
+ });
+
+ test('non-object sectionData returns empty component', () => {
+ const component = parser.parseComponentSection('redis', 'invalid');
+ expect(component.getParameterCount()).toBe(0);
+ });
+
+ test('parses string-valued parameters', () => {
+ const component = parser.parseComponentSection('aws', {
+ AWS_REGION: 'us-east-1',
+ AWS_PROFILE: 'default',
+ });
+ expect(component.getParameterCount()).toBe(2);
+ expect(component.getParameter('AWS_REGION')?.getInitialValue()).toBe('us-east-1');
+ expect(component.getParameter('AWS_PROFILE')?.getInitialValue()).toBe('default');
+ });
+
+ test('parses object-valued parameters', () => {
+ const component = parser.parseComponentSection('redis', {
+ REDIS_URL: { label: 'Redis URL', initialValue: 'localhost', userInputValue: '' },
+ });
+ expect(component.getParameterCount()).toBe(1);
+ expect(component.getParameter('REDIS_URL')?.getLabel()).toBe('Redis URL');
+ });
+
+ test('skips keys starting with #', () => {
+ const component = parser.parseComponentSection('aws', {
+ ACTIVE_KEY: 'value',
+ '#COMMENTED_KEY': 'should-be-skipped',
+ });
+ expect(component.getParameterCount()).toBe(1);
+ expect(component.hasParameter('ACTIVE_KEY')).toBe(true);
+ expect(component.hasParameter('#COMMENTED_KEY')).toBe(false);
+ });
+
+ test('adds all parameters to component', () => {
+ const component = parser.parseComponentSection('multi', {
+ KEY1: 'a',
+ KEY2: 'b',
+ KEY3: { label: 'C', initialValue: 'c', userInputValue: '' },
+ });
+ expect(component.getParameterCount()).toBe(3);
+ expect(component.getParameterKeys()).toEqual(expect.arrayContaining(['KEY1', 'KEY2', 'KEY3']));
+ });
+});
+
+// ─── extractComponents ───────────────────────────────────────────────────────
+
+describe('ConfigParser.extractComponents', () => {
+ const parser = new ConfigParser('');
+
+ test('null rawData returns empty config', () => {
+ const config = parser.extractComponents(null);
+ expect(config.getComponentCount()).toBe(0);
+ });
+
+ test('non-object rawData returns empty config', () => {
+ const config = parser.extractComponents('not an object');
+ expect(config.getComponentCount()).toBe(0);
+ });
+
+ test('rawData without components key returns empty config', () => {
+ const config = parser.extractComponents({ other: 'value' });
+ expect(config.getComponentCount()).toBe(0);
+ });
+
+ test('components value that is not an object returns empty config', () => {
+ const config = parser.extractComponents({ components: 'invalid' });
+ expect(config.getComponentCount()).toBe(0);
+ });
+
+ test('extracts a single component with parameters', () => {
+ const config = parser.extractComponents({
+ components: {
+ redis: { REDIS_HOST: 'localhost' },
+ },
+ });
+ expect(config.getComponentCount()).toBe(1);
+ expect(config.hasComponent('redis')).toBe(true);
+ expect(config.getComponent('redis')?.getParameterCount()).toBe(1);
+ });
+
+ test('extracts multiple components', () => {
+ const config = parser.extractComponents({
+ components: {
+ redis: { REDIS_HOST: 'localhost' },
+ postgres: { DB_NAME: 'mydb', DB_HOST: '127.0.0.1' },
+ },
+ });
+ expect(config.getComponentCount()).toBe(2);
+ expect(config.hasComponent('redis')).toBe(true);
+ expect(config.hasComponent('postgres')).toBe(true);
+ expect(config.getComponent('postgres')?.getParameterCount()).toBe(2);
+ });
+
+ test('excludes components with no parameters', () => {
+ const config = parser.extractComponents({
+ components: {
+ empty: {},
+ redis: { REDIS_HOST: 'localhost' },
+ },
+ });
+ expect(config.getComponentCount()).toBe(1);
+ expect(config.hasComponent('redis')).toBe(true);
+ expect(config.hasComponent('empty')).toBe(false);
+ });
+
+ test('component with only commented-out keys is excluded', () => {
+ const config = parser.extractComponents({
+ components: {
+ aws: { '#DISABLED': 'value' },
+ },
+ });
+ expect(config.getComponentCount()).toBe(0);
+ });
+
+ test('password type flows through to parameter', () => {
+ const config = parser.extractComponents({
+ components: {
+ auth: {
+ API_SECRET: { label: 'API Secret', initialValue: '', userInputValue: '', type: 'password' },
+ API_KEY: { label: 'API Key', initialValue: '', userInputValue: '' },
+ },
+ },
+ });
+ expect(config.getComponent('auth')?.getParameter('API_SECRET')?.getType()).toBe('password');
+ expect(config.getComponent('auth')?.getParameter('API_KEY')?.getType()).toBe('string');
+ });
+});
+
+// ─── load ─────────────────────────────────────────────────────────────────────
+
+describe('ConfigParser.load', () => {
+ test('returns error when file does not exist', async () => {
+ const parser = new ConfigParser('/nonexistent/path/config.toml');
+ const result = await parser.load();
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('/nonexistent/path/config.toml');
+ });
+
+ test('loads simple.toml and extracts both components', async () => {
+ const parser = new ConfigParser(join(fixturesDir, 'simple.toml'));
+ const result = await parser.load();
+ expect(result.success).toBe(true);
+ const config = result.data!;
+ expect(config.getComponentCount()).toBe(2);
+ expect(config.hasComponent('redis')).toBe(true);
+ expect(config.hasComponent('postgres')).toBe(true);
+ });
+
+ test('redis component has three parameters with correct values', async () => {
+ const parser = new ConfigParser(join(fixturesDir, 'simple.toml'));
+ const result = await parser.load();
+ const redis = result.data!.getComponent('redis')!;
+ expect(redis.getParameterCount()).toBe(3);
+ expect(redis.getParameter('REDIS_HOST')?.getLabel()).toBe('Redis hostname');
+ expect(redis.getParameter('REDIS_HOST')?.getInitialValue()).toBe('localhost');
+ expect(redis.getParameter('REDIS_HOST')?.getUserInputValue()).toBe('redis.example.com');
+ expect(redis.getParameter('REDIS_PASSWORD')?.isMandatory()).toBe(true);
+ });
+
+ test('postgres component has two parameters including plain string', async () => {
+ const parser = new ConfigParser(join(fixturesDir, 'simple.toml'));
+ const result = await parser.load();
+ const postgres = result.data!.getComponent('postgres')!;
+ expect(postgres.getParameterCount()).toBe(2);
+ expect(postgres.getParameter('DB_NAME')?.getInitialValue()).toBe('mydb');
+ });
+});
diff --git a/util/config/configurator/tests/configuration-file-components-parameters.test.ts b/util/config/configurator/tests/configuration-file-components-parameters.test.ts
new file mode 100644
index 0000000..1e0ea4b
--- /dev/null
+++ b/util/config/configurator/tests/configuration-file-components-parameters.test.ts
@@ -0,0 +1,250 @@
+import { expect, test, describe, beforeEach, afterEach } from 'bun:test';
+import { PartialConfigManager } from '../partial-config-manager';
+import { ConfigDisplay } from '../config-display';
+import { existsSync, unlinkSync, writeFileSync } from 'fs';
+import {ConfigParameter, EditableComponentConfig, EditableConfigParameter} from "../types-core.ts";
+import {EditableOpsConfigFile} from "../types-config-file.ts";
+
+describe('ConfigParameter - isMandatory', () => {
+ test('should default isMandatory to false', () => {
+ const param = new ConfigParameter('KEY');
+ expect(param.isMandatory()).toBe(false);
+ });
+
+ test('should set isMandatory to true when specified', () => {
+ const param = new ConfigParameter('KEY', 'Label', '', '', true);
+ expect(param.isMandatory()).toBe(true);
+ });
+
+ test('should set isMandatory to false when explicitly false', () => {
+ const param = new ConfigParameter('KEY', 'Label', 'val', '', false);
+ expect(param.isMandatory()).toBe(false);
+ });
+
+ test('should not affect other fields', () => {
+ const param = new ConfigParameter('MY_KEY', 'My Label', 'init', 'input', true);
+ expect(param.getKey()).toBe('MY_KEY');
+ expect(param.getLabel()).toBe('My Label');
+ expect(param.getInitialValue()).toBe('init');
+ expect(param.getUserInputValue()).toBe('input');
+ expect(param.isMandatory()).toBe(true);
+ });
+});
+
+describe('EditableConfigParameter - isMandatory', () => {
+ test('should default isMandatory to false', () => {
+ const param = new EditableConfigParameter('KEY', '', '', '', '');
+ expect(param.isMandatory()).toBe(false);
+ });
+
+ test('should accept isMandatory true', () => {
+ const param = new EditableConfigParameter('KEY', 'Label', 'init', '', '', true);
+ expect(param.isMandatory()).toBe(true);
+ });
+
+ test('should preserve isMandatory through fromConfigParameter', () => {
+ const base = new ConfigParameter('KEY', 'Label', 'init', 'input', true);
+ const editable = EditableConfigParameter.fromConfigParameter(base);
+
+ expect(editable.getKey()).toBe('KEY');
+ expect(editable.isMandatory()).toBe(true);
+ expect(editable.getPreviousUserInputValue()).toBe('');
+ });
+
+ test('fromConfigParameter should preserve isMandatory false', () => {
+ const base = new ConfigParameter('KEY', '', '', '', false);
+ const editable = EditableConfigParameter.fromConfigParameter(base);
+ expect(editable.isMandatory()).toBe(false);
+ });
+
+ test('isMandatory should not be affected by updateUserInputValue', () => {
+ const param = new EditableConfigParameter('KEY', '', '', '', '', true);
+ param.updateUserInputValue('new_value');
+ expect(param.isMandatory()).toBe(true);
+ });
+
+ test('isMandatory should not be affected by revertToPrevious', () => {
+ const param = new EditableConfigParameter('KEY', '', '', 'current', 'previous', true);
+ param.revertToPrevious();
+ expect(param.isMandatory()).toBe(true);
+ });
+});
+
+describe('EditableConfigParameter', () => {
+ test('should create instance with all properties', () => {
+ const param = new EditableConfigParameter('TEST_KEY', 'Test Label', 'initial', 'current', 'previous');
+
+ expect(param.getKey()).toBe('TEST_KEY');
+ expect(param.getLabel()).toBe('Test Label');
+ expect(param.getInitialValue()).toBe('initial');
+ expect(param.getUserInputValue()).toBe('current');
+ expect(param.getPreviousUserInputValue()).toBe('previous');
+ });
+
+ test('should update user input value and track previous', () => {
+ const param = new EditableConfigParameter('KEY', '', '', 'old_value', '');
+
+ param.updateUserInputValue('new_value');
+
+ expect(param.getUserInputValue()).toBe('new_value');
+ expect(param.getPreviousUserInputValue()).toBe('old_value');
+ });
+
+ test('should revert to previous value', () => {
+ const param = new EditableConfigParameter('KEY', '', '', 'current', 'previous');
+
+ const reverted = param.revertToPrevious();
+
+ expect(reverted).toBe(true);
+ expect(param.getUserInputValue()).toBe('previous');
+ expect(param.getPreviousUserInputValue()).toBe('');
+ });
+
+ test('should not revert when no previous value', () => {
+ const param = new EditableConfigParameter('KEY', '', '', 'current', '');
+
+ const reverted = param.revertToPrevious();
+
+ expect(reverted).toBe(false);
+ expect(param.getUserInputValue()).toBe('current');
+ });
+
+ test('should create from ConfigParameter', () => {
+ const baseParam = new EditableConfigParameter('KEY', 'Label', 'initial', 'user', '');
+ const editableParam = EditableConfigParameter.fromConfigParameter(baseParam);
+
+ expect(editableParam.getKey()).toBe('KEY');
+ expect(editableParam.getLabel()).toBe('Label');
+ expect(editableParam.getInitialValue()).toBe('initial');
+ expect(editableParam.getUserInputValue()).toBe('user');
+ expect(editableParam.getPreviousUserInputValue()).toBe('');
+ });
+});
+
+describe('ConfigParameter - type', () => {
+ test('should default type to string', () => {
+ const param = new ConfigParameter('KEY');
+ expect(param.getType()).toBe('string');
+ });
+
+ test('should set type to password when specified', () => {
+ const param = new ConfigParameter('KEY', '', '', '', false, 'password');
+ expect(param.getType()).toBe('password');
+ });
+
+ test('should set type to string when explicitly specified', () => {
+ const param = new ConfigParameter('KEY', '', '', '', false, 'string');
+ expect(param.getType()).toBe('string');
+ });
+
+ test('should not affect other fields when type is password', () => {
+ const param = new ConfigParameter('MY_KEY', 'My Label', 'init', 'input', true, 'password');
+ expect(param.getKey()).toBe('MY_KEY');
+ expect(param.getLabel()).toBe('My Label');
+ expect(param.getInitialValue()).toBe('init');
+ expect(param.getUserInputValue()).toBe('input');
+ expect(param.isMandatory()).toBe(true);
+ expect(param.getType()).toBe('password');
+ });
+});
+
+describe('EditableConfigParameter - type', () => {
+ test('should default type to string', () => {
+ const param = new EditableConfigParameter('KEY');
+ expect(param.getType()).toBe('string');
+ });
+
+ test('should accept type password', () => {
+ const param = new EditableConfigParameter('KEY', 'Label', 'init', '', '', false, 'password');
+ expect(param.getType()).toBe('password');
+ });
+
+ test('should preserve type through fromConfigParameter', () => {
+ const base = new ConfigParameter('KEY', 'Label', 'init', '', false, 'password');
+ const editable = EditableConfigParameter.fromConfigParameter(base);
+ expect(editable.getType()).toBe('password');
+ });
+
+ test('fromConfigParameter should preserve type string', () => {
+ const base = new ConfigParameter('KEY', '', '', '', false, 'string');
+ const editable = EditableConfigParameter.fromConfigParameter(base);
+ expect(editable.getType()).toBe('string');
+ });
+
+ test('type should not be affected by updateUserInputValue', () => {
+ const param = new EditableConfigParameter('KEY', '', '', '', '', false, 'password');
+ param.updateUserInputValue('secret');
+ expect(param.getType()).toBe('password');
+ });
+
+ test('type should not be affected by revertToPrevious', () => {
+ const param = new EditableConfigParameter('KEY', '', '', 'current', 'previous', false, 'password');
+ param.revertToPrevious();
+ expect(param.getType()).toBe('password');
+ });
+});
+
+describe('ConfigParameter - hint', () => {
+ test('should default hint to undefined', () => {
+ const param = new ConfigParameter('KEY');
+ expect(param.getHint()).toBeUndefined();
+ });
+
+ test('should store hint when provided', () => {
+ const param = new ConfigParameter('KEY', '', '', '', false, 'string', 'Choose wisely');
+ expect(param.getHint()).toBe('Choose wisely');
+ });
+
+ test('should store multiline hint', () => {
+ const hint = 'Line one\nLine two';
+ const param = new ConfigParameter('KEY', '', '', '', false, 'string', hint);
+ expect(param.getHint()).toBe(hint);
+ });
+
+ test('hint should not affect other fields', () => {
+ const param = new ConfigParameter('MY_KEY', 'My Label', 'init', 'input', true, 'password', 'a hint');
+ expect(param.getKey()).toBe('MY_KEY');
+ expect(param.getLabel()).toBe('My Label');
+ expect(param.getInitialValue()).toBe('init');
+ expect(param.getUserInputValue()).toBe('input');
+ expect(param.isMandatory()).toBe(true);
+ expect(param.getType()).toBe('password');
+ expect(param.getHint()).toBe('a hint');
+ });
+});
+
+describe('EditableConfigParameter - hint', () => {
+ test('should default hint to undefined', () => {
+ const param = new EditableConfigParameter('KEY');
+ expect(param.getHint()).toBeUndefined();
+ });
+
+ test('should store hint when provided', () => {
+ const param = new EditableConfigParameter('KEY', '', '', '', '', false, 'string', 'Use t3a.large');
+ expect(param.getHint()).toBe('Use t3a.large');
+ });
+
+ test('should preserve hint through fromConfigParameter', () => {
+ const base = new ConfigParameter('KEY', 'Label', 'init', '', false, 'string', 'helpful hint');
+ const editable = EditableConfigParameter.fromConfigParameter(base);
+ expect(editable.getHint()).toBe('helpful hint');
+ });
+
+ test('fromConfigParameter with no hint gives undefined', () => {
+ const base = new ConfigParameter('KEY', 'Label', 'init', '', false, 'string');
+ const editable = EditableConfigParameter.fromConfigParameter(base);
+ expect(editable.getHint()).toBeUndefined();
+ });
+
+ test('hint should not be affected by updateUserInputValue', () => {
+ const param = new EditableConfigParameter('KEY', '', '', '', '', false, 'string', 'my hint');
+ param.updateUserInputValue('new_value');
+ expect(param.getHint()).toBe('my hint');
+ });
+
+ test('hint should not be affected by revertToPrevious', () => {
+ const param = new EditableConfigParameter('KEY', '', '', 'current', 'previous', false, 'string', 'my hint');
+ param.revertToPrevious();
+ expect(param.getHint()).toBe('my hint');
+ });
+});
\ No newline at end of file
diff --git a/util/config/configurator/tests/configuration-file-components.test.ts b/util/config/configurator/tests/configuration-file-components.test.ts
new file mode 100644
index 0000000..82bd547
--- /dev/null
+++ b/util/config/configurator/tests/configuration-file-components.test.ts
@@ -0,0 +1,24 @@
+import { expect, test, describe, beforeEach, afterEach } from 'bun:test';
+import { PartialConfigManager } from '../partial-config-manager';
+import { ConfigDisplay } from '../config-display';
+import { existsSync, unlinkSync, writeFileSync } from 'fs';
+import {EditableComponentConfig, EditableConfigParameter} from "../types-core.ts";
+import {EditableOpsConfigFile} from "../types-config-file.ts";
+
+describe('EditableComponentConfig', () => {
+ test('should create component with editable parameters', () => {
+ const component = new EditableComponentConfig('test');
+ const param1 = new EditableConfigParameter('KEY1', '', '', '', '');
+ const param2 = new EditableConfigParameter('KEY2', '', '', '', '');
+
+ component.addParameter(param1);
+ component.addParameter(param2);
+
+ expect(component.getName()).toBe('test');
+ expect(component.getParameterCount()).toBe(2);
+ expect(component.getAllParameters()).toHaveLength(2);
+ expect(component.getParameter('KEY1')).toBeDefined();
+ expect(component.getParameter('KEY3')).toBeUndefined();
+ });
+});
+
diff --git a/util/config/configurator/tests/configuration-file.test.ts b/util/config/configurator/tests/configuration-file.test.ts
new file mode 100644
index 0000000..6fae5fa
--- /dev/null
+++ b/util/config/configurator/tests/configuration-file.test.ts
@@ -0,0 +1,140 @@
+import { expect, test, describe, beforeEach, afterEach } from 'bun:test';
+import { PartialConfigManager } from '../partial-config-manager';
+import { ConfigDisplay } from '../config-display';
+import { existsSync, unlinkSync, writeFileSync } from 'fs';
+import {EditableComponentConfig, EditableConfigParameter} from "../types-core.ts";
+import {EditableOpsConfigFile} from "../types-config-file.ts";
+
+
+describe('EditableOpsConfigFile', () => {
+ test('should create config with editable components', () => {
+ const config = new EditableOpsConfigFile();
+ const component = new EditableComponentConfig('redis');
+ component.addParameter(new EditableConfigParameter('REDIS_URL', '', '', '', ''));
+
+ config.addComponent(component);
+
+ expect(config.getComponentCount()).toBe(1);
+ expect(config.getComponent('redis')).toBeDefined();
+ expect(config.getAllComponents()).toHaveLength(1);
+ expect(config.getComponentNames()).toContain('redis');
+ });
+});
+
+
+describe('ConfigDisplay', () => {
+ test('should format parameter table', () => {
+ const params = [
+ new EditableConfigParameter('KEY1', 'Label 1', 'initial1', 'current1', 'prev1'),
+ new EditableConfigParameter('KEY2', 'Label 2', 'initial2', '', ''),
+ ];
+
+ let output = '';
+ const originalWrite = process.stdout.write.bind(process.stdout);
+ process.stdout.write = (chunk: any) => { output += chunk.toString(); return true; };
+
+ ConfigDisplay.displayParameterTable('test-component', params);
+
+ process.stdout.write = originalWrite;
+
+ expect(output.length).toBeGreaterThan(0);
+ expect(output).toContain('test-component');
+ expect(output).toContain('KEY1');
+ expect(output).toContain('KEY2');
+ });
+
+ test('should format empty parameter table', () => {
+ let output = '';
+ const originalWrite = process.stdout.write.bind(process.stdout);
+ process.stdout.write = (chunk: any) => { output += chunk.toString(); return true; };
+
+ ConfigDisplay.displayParameterTable('empty-component', []);
+
+ process.stdout.write = originalWrite;
+
+ expect(output).toContain('No parameters configured');
+ });
+
+ test('should display component summary', () => {
+ const components = [
+ { name: 'redis', parameterCount: 4, modifiedCount: 1 },
+ { name: 'postgres', parameterCount: 5, modifiedCount: 0 },
+ ];
+
+ const logs: string[] = [];
+ const originalLog = console.log;
+ console.log = (...args: any[]) => logs.push(args.join(' '));
+
+ ConfigDisplay.displayComponentSummary(components);
+
+ console.log = originalLog;
+
+ expect(logs.length).toBeGreaterThan(0);
+ });
+
+ test('should display parameter detail', () => {
+ const param = new EditableConfigParameter('TEST_KEY', 'Test Label', 'initial', 'current', 'prev');
+
+ const logs: string[] = [];
+ const originalLog = console.log;
+ console.log = (...args: any[]) => logs.push(args.join(' '));
+
+ ConfigDisplay.displayParameterDetail(param);
+
+ console.log = originalLog;
+
+ expect(logs.some(l => l.includes('TEST_KEY'))).toBe(true);
+ expect(logs.some(l => l.includes('Test Label'))).toBe(true);
+ });
+
+ test('should not display hint in parameter detail (hint is shown by editParameter)', () => {
+ const param = new EditableConfigParameter(
+ 'VM_TYPE', 'VM instance type', '', '', '', false, 'string',
+ 'The suggested instance type has 8GB RAM'
+ );
+
+ const logs: string[] = [];
+ const originalLog = console.log;
+ console.log = (...args: any[]) => logs.push(args.join(' '));
+
+ ConfigDisplay.displayParameterDetail(param);
+
+ console.log = originalLog;
+
+ expect(logs.some(l => l.includes('Hint:'))).toBe(false);
+ });
+
+ test('should display modifications', () => {
+ const modifications = [
+ {
+ component: 'redis',
+ parameters: [
+ new EditableConfigParameter('REDIS_URL', 'URL', '', 'new_url', 'old_url'),
+ ],
+ },
+ ];
+
+ const logs: string[] = [];
+ const originalLog = console.log;
+ console.log = (...args: any[]) => logs.push(args.join(' '));
+
+ ConfigDisplay.displayModifications(modifications);
+
+ console.log = originalLog;
+
+ expect(logs.some(l => l.includes('redis'))).toBe(true);
+ expect(logs.some(l => l.includes('REDIS_URL'))).toBe(true);
+ });
+
+ test('should display message when no modifications', () => {
+ const logs: string[] = [];
+ const originalLog = console.log;
+ console.log = (...args: any[]) => logs.push(args.join(' '));
+
+ ConfigDisplay.displayModifications([]);
+
+ console.log = originalLog;
+
+ expect(logs.some(l => l.includes('No modifications detected'))).toBe(true);
+ });
+});
\ No newline at end of file
diff --git a/util/config/configurator/tests/fixtures/commented-params.toml b/util/config/configurator/tests/fixtures/commented-params.toml
new file mode 100644
index 0000000..7432f82
--- /dev/null
+++ b/util/config/configurator/tests/fixtures/commented-params.toml
@@ -0,0 +1,4 @@
+[components.aws]
+OUT_AWS_ACCESS_KEY_ID = { label = "AWS Access Id", initialValue = "AAA", userInputValue = "BBB" }
+"#OUT_AWS_DISABLED" = { label = "Disabled param", initialValue = "", userInputValue = "" }
+OUT_AWS_REGION = "us-east-1"
diff --git a/util/config/configurator/tests/fixtures/components-list.json b/util/config/configurator/tests/fixtures/components-list.json
new file mode 100644
index 0000000..b6a7b4f
--- /dev/null
+++ b/util/config/configurator/tests/fixtures/components-list.json
@@ -0,0 +1,59 @@
+{
+ "components" : {
+ "REDIS": {
+ "type": "component",
+ "label": "Redis server",
+ "enabled": {
+ "label": "Do you want to enable Redis?",
+ "type": "bool",
+ "initialValue=": true,
+ "userInputValue" :true
+ },
+ "parameters" : {
+ "REDIS_URL": {
+ "label": "Redis URL",
+ "initialValue=": "",
+ "userInputValue" : ""
+ },
+ "REDIS_SERVICE": {
+ "label": "Redis Service",
+ "initialValue=": "",
+ "userInputValue" : ""
+ },
+ "REDIS_PREFIX": {
+ "label": "Redis Prefix",
+ "initialValue=": "",
+ "userInputValue" : ""
+ },
+ "REDIS_PASSWORD": {
+ "label": "Redis password",
+ "initialValue=": "",
+ "userInputValue" : ""
+ }
+ }
+ }
+ },
+ "POSTGRESQL": {
+ "type": "component",
+ "label": "PostgreSql server",
+ "enabled": {
+ "label": "Do you want to enable PostgreSql?",
+ "type": "bool",
+ "initialValue=": true,
+ "userInputValue" :true
+ },
+ "parameters" : {
+ "POSTGRES_USERNAME": {
+ "label": "PostgreSql Username",
+ "initialValue=": "",
+ "userInputValue" : ""
+ },
+ "POSTGRES_DATABASE": {
+ "label": "PostgreSql database",
+ "initialValue=": "",
+ "userInputValue" : ""
+ },
+ }
+ }
+}
+}
diff --git a/util/config/configurator/tests/fixtures/simple.toml b/util/config/configurator/tests/fixtures/simple.toml
new file mode 100644
index 0000000..30d72a9
--- /dev/null
+++ b/util/config/configurator/tests/fixtures/simple.toml
@@ -0,0 +1,8 @@
+[components.redis]
+REDIS_HOST = { label = "Redis hostname", initialValue = "localhost", userInputValue = "redis.example.com" }
+REDIS_PORT = { label = "Redis port", initialValue = "6379", userInputValue = "" }
+REDIS_PASSWORD = { label = "Redis password", initialValue = "", userInputValue = "secret", isMandatory = true }
+
+[components.postgres]
+DB_HOST = { label = "Postgres hostname", initialValue = "127.0.0.1", userInputValue = "" }
+DB_NAME = "mydb"
diff --git a/util/config/configurator/tests/fixtures/tutorial.json b/util/config/configurator/tests/fixtures/tutorial.json
new file mode 100644
index 0000000..b4e7628
--- /dev/null
+++ b/util/config/configurator/tests/fixtures/tutorial.json
@@ -0,0 +1,5 @@
+{
+ "POSTGRES_PORT": {
+ "type": "string"
+ }
+}
diff --git a/util/config/configurator/tests/index.test.ts b/util/config/configurator/tests/index.test.ts
index eb35110..02b396c 100644
--- a/util/config/configurator/tests/index.test.ts
+++ b/util/config/configurator/tests/index.test.ts
@@ -1,13 +1,11 @@
import { expect, test, describe } from "bun:test";
import {
readPositionalFile,
- parsePositionalFile,
NotValidJsonMsg,
HelpMsg,
AdditionalArgsMsg,
isInputConfigValid,
findMissingConfig,
- FileNotFoundJsonMsg,
} from "../configurator.ts";
describe("findMissingConfig", () => {
@@ -94,32 +92,3 @@ describe("readPositionalFile", () => {
expect(res.jsonFilePath).toBe("config.json");
});
});
-
-describe("parsePositionalFile", () => {
- test("file does not exist", async () => {
- const filename = "not-existing.json";
- const jsonRes = await parsePositionalFile(filename);
- expect(jsonRes.success).toBe(false);
- expect(jsonRes.message).toBe(FileNotFoundJsonMsg + Bun.pathToFileURL(filename));
- });
-
- test("not a json file", async () => {
- const jsonRes = await parsePositionalFile("tests/fixtures/not-json.txt");
- expect(jsonRes.success).toBe(false);
- expect(jsonRes.message).toBe(NotValidJsonMsg);
- });
-
- test("not a valid json", async () => {
- const jsonRes = await parsePositionalFile("tests/fixtures/bad.json");
- expect(jsonRes.success).toBe(false);
- expect(jsonRes.message).toBe(NotValidJsonMsg);
- });
-
- test("valid json", async () => {
- const jsonRes = await parsePositionalFile("tests//fixtures/valid.json");
- expect(jsonRes.success).toBe(true);
- expect(jsonRes.body).toHaveProperty("HELLO", { type: "string" } );
- expect(jsonRes.body).toHaveProperty("HELLO3", { type: "string" } );
- expect(jsonRes.body).toHaveProperty("HELLO_3", { type: "string" });
- });
-});
diff --git a/util/config/configurator/tests/partial-config.test.ts b/util/config/configurator/tests/partial-config.test.ts
new file mode 100644
index 0000000..52e1085
--- /dev/null
+++ b/util/config/configurator/tests/partial-config.test.ts
@@ -0,0 +1,259 @@
+import { expect, test, describe, beforeEach, afterEach } from 'bun:test';
+import { PartialConfigManager } from '../partial-config-manager';
+import { ConfigDisplay } from '../config-display';
+import { existsSync, unlinkSync, writeFileSync } from 'fs';
+import {EditableComponentConfig, EditableConfigParameter} from "../types-core.ts";
+import {EditableOpsConfigFile} from "../types-config-file.ts";
+
+
+
+
+describe('PartialConfigManager', () => {
+ const testConfigPath = './test-config.toml';
+ const testTmpPath = './test-config.tmp';
+
+ beforeEach(() => {
+ const testConfig = `[components.redis]
+REDIS_URL = {label="Redis URL", initialValue="", userInputValue=""}
+REDIS_PORT = {label="Redis Port", initialValue="6379", userInputValue=""}
+`;
+ writeFileSync(testConfigPath, testConfig);
+ });
+
+ afterEach(() => {
+ try {
+ if (existsSync(testConfigPath)) {
+ unlinkSync(testConfigPath);
+ }
+ if (existsSync(testTmpPath)) {
+ unlinkSync(testTmpPath);
+ }
+ } catch (error) {
+ // Ignore cleanup errors
+ }
+ });
+
+ test('should check if partial config exists', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+
+ expect(await manager.hasPartialConfig()).toBe(false);
+
+ writeFileSync(testTmpPath, '# Partial config');
+
+ expect(await manager.hasPartialConfig()).toBe(true);
+ });
+
+ test('should load configuration from main file', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ const result = await manager.load();
+
+ expect(result.success).toBe(true);
+ expect(result.data).toBeDefined();
+
+ const config = result.data!;
+ expect(config.getComponentCount()).toBe(1);
+ expect(config.getComponent('redis')).toBeDefined();
+
+ const component = config.getComponent('redis')!;
+ expect(component.getParameterCount()).toBe(2);
+ });
+
+ test('should save and load partial configuration', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+
+ await manager.setParameter('redis', 'REDIS_URL', 'redis://localhost');
+
+ expect(existsSync(testTmpPath)).toBe(true);
+
+ const manager2 = new PartialConfigManager(testConfigPath);
+ const result = await manager2.load();
+
+ expect(result.success).toBe(true);
+ expect(result.data).toBeDefined();
+
+ const redisComponent = result.data!.getComponent('redis');
+ expect(redisComponent).toBeDefined();
+
+ if (redisComponent) {
+ const urlParam = redisComponent.getParameter('REDIS_URL');
+ expect(urlParam).toBeDefined();
+ if (urlParam) {
+ expect(urlParam.getUserInputValue()).toBe('redis://localhost');
+ }
+ }
+ });
+
+ test('should detect modifications', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+
+ expect(manager.hasModifications()).toBe(false);
+
+ await manager.setParameter('redis', 'REDIS_URL', 'redis://localhost');
+
+ expect(manager.hasModifications()).toBe(true);
+ });
+
+ test('should get modified parameters', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+
+ await manager.setParameter('redis', 'REDIS_URL', 'redis://localhost');
+
+ const modified = manager.getModifiedParameters();
+
+ expect(modified).toHaveLength(1);
+ expect(modified[0].component).toBe('redis');
+ expect(modified[0].parameters).toHaveLength(1);
+ expect(modified[0].parameters[0].getKey()).toBe('REDIS_URL');
+ });
+
+ test('should clear partial configuration', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+
+ await manager.setParameter('redis', 'REDIS_URL', 'test');
+
+ expect(existsSync(testTmpPath)).toBe(true);
+
+ const result = await manager.clear();
+
+ expect(result.success).toBe(true);
+ expect(existsSync(testTmpPath)).toBe(false);
+ });
+
+ test('should revert parameter to previous value', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+
+ await manager.setParameter('redis', 'REDIS_URL', 'first_value');
+ await manager.setParameter('redis', 'REDIS_URL', 'second_value');
+
+ const param = manager.getParameter('redis', 'REDIS_URL')!;
+ expect(param.getUserInputValue()).toBe('second_value');
+ expect(param.getPreviousUserInputValue()).toBe('first_value');
+
+ await manager.revertParameter('redis', 'REDIS_URL');
+
+ const revertedParam = manager.getParameter('redis', 'REDIS_URL')!;
+ expect(revertedParam.getUserInputValue()).toBe('first_value');
+ expect(revertedParam.getPreviousUserInputValue()).toBe('');
+ });
+
+ test('should return error for non-existent parameter', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+
+ const result = await manager.setParameter('redis', 'NONexistent', 'value');
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('not found');
+ });
+});
+
+describe('PartialConfigManager - serializeToToml (round-trip)', () => {
+ const testConfigPath = './test-serial.toml';
+ const testTmpPath = './test-serial.tmp';
+
+ beforeEach(() => {
+ const testConfig = `[components.postgres]
+POSTGRES_HOST = {label="Postgres host", initialValue="localhost", userInputValue=""}
+POSTGRES_PASSWORD = {label="Postgres password", initialValue="", userInputValue="", type="password"}
+POSTGRES_PORT = {label="Postgres port", initialValue="5432", userInputValue="", isMandatory=true}
+`;
+ writeFileSync(testConfigPath, testConfig);
+ });
+
+ afterEach(() => {
+ if (existsSync(testConfigPath)) unlinkSync(testConfigPath);
+ if (existsSync(testTmpPath)) unlinkSync(testTmpPath);
+ });
+
+ test('should preserve type=password through save and reload', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+ await manager.setParameter('postgres', 'POSTGRES_PASSWORD', 'secret');
+
+ const manager2 = new PartialConfigManager(testConfigPath);
+ await manager2.load();
+
+ const param = manager2.getParameter('postgres', 'POSTGRES_PASSWORD')!;
+ expect(param.getType()).toBe('password');
+ });
+
+ test('should preserve type=string through save and reload', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+ await manager.setParameter('postgres', 'POSTGRES_HOST', 'db.example.com');
+
+ const manager2 = new PartialConfigManager(testConfigPath);
+ await manager2.load();
+
+ const param = manager2.getParameter('postgres', 'POSTGRES_HOST')!;
+ expect(param.getType()).toBe('string');
+ });
+
+ test('should preserve userInputValue for password parameter through save and reload', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+ await manager.setParameter('postgres', 'POSTGRES_PASSWORD', 'mysecret');
+
+ const manager2 = new PartialConfigManager(testConfigPath);
+ await manager2.load();
+
+ const param = manager2.getParameter('postgres', 'POSTGRES_PASSWORD')!;
+ expect(param.getUserInputValue()).toBe('mysecret');
+ expect(param.getType()).toBe('password');
+ });
+
+ test('should preserve isMandatory through save and reload', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+ await manager.setParameter('postgres', 'POSTGRES_PORT', '5433');
+
+ const manager2 = new PartialConfigManager(testConfigPath);
+ await manager2.load();
+
+ const param = manager2.getParameter('postgres', 'POSTGRES_PORT')!;
+ expect(param.isMandatory()).toBe(true);
+ });
+
+ test('should preserve previousUserInputValue through save and reload', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+ await manager.setParameter('postgres', 'POSTGRES_PASSWORD', 'first');
+ await manager.setParameter('postgres', 'POSTGRES_PASSWORD', 'second');
+
+ const manager2 = new PartialConfigManager(testConfigPath);
+ await manager2.load();
+
+ const param = manager2.getParameter('postgres', 'POSTGRES_PASSWORD')!;
+ expect(param.getUserInputValue()).toBe('second');
+ expect(param.getPreviousUserInputValue()).toBe('first');
+ expect(param.getType()).toBe('password');
+ });
+
+ test('should preserve all fields for all parameters through save and reload', async () => {
+ const manager = new PartialConfigManager(testConfigPath);
+ await manager.load();
+ await manager.setParameter('postgres', 'POSTGRES_HOST', 'db.example.com');
+ await manager.setParameter('postgres', 'POSTGRES_PASSWORD', 'topsecret');
+
+ const manager2 = new PartialConfigManager(testConfigPath);
+ await manager2.load();
+
+ const host = manager2.getParameter('postgres', 'POSTGRES_HOST')!;
+ expect(host.getType()).toBe('string');
+ expect(host.getUserInputValue()).toBe('db.example.com');
+ expect(host.getLabel()).toBe('Postgres host');
+ expect(host.getInitialValue()).toBe('localhost');
+
+ const pw = manager2.getParameter('postgres', 'POSTGRES_PASSWORD')!;
+ expect(pw.getType()).toBe('password');
+ expect(pw.getUserInputValue()).toBe('topsecret');
+ expect(pw.getLabel()).toBe('Postgres password');
+ });
+});
+
+
diff --git a/util/config/configurator/tsconfig.json b/util/config/configurator/tsconfig.json
index 238655f..ce9dd17 100644
--- a/util/config/configurator/tsconfig.json
+++ b/util/config/configurator/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "types": ["@types/bun"],
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
diff --git a/util/config/configurator/types-config-file.ts b/util/config/configurator/types-config-file.ts
new file mode 100644
index 0000000..efbea38
--- /dev/null
+++ b/util/config/configurator/types-config-file.ts
@@ -0,0 +1,118 @@
+import {ComponentConfig, EditableComponentConfig} from "./types-core.ts";
+
+/**
+ * Represents the complete configuration in RAM.
+ * This class doesn't handle any file I/O or persistence. It is purely an in-memory representation.
+ *
+ * The class is a simple data structure that stores configuration parameters in memory (RAM). It has:
+ * - Private instance variables (_key, _label, _initialValue, _userInputValue)
+ * - Getters and setters for these values
+ * - No file I/O operations
+ * - No database connections
+ * - No localStorage/sessionStorage usage
+ *
+ * Contains all managed components and their configuration parameters.
+ * This is the root object for the configuration hierarchy.
+ */
+export class OpsConfigFile {
+ private _components: Map<string, ComponentConfig>;
+
+ /**
+ * Creates a new OpsConfigFile instance.
+ * Initializes with an empty components map.
+ */
+ constructor() {
+ this._components = new Map<string, ComponentConfig>();
+ }
+
+ /**
+ * Adds a component configuration.
+ * If a component with the same name exists, it will be replaced.
+ *
+ * @param component - The ComponentConfig to add
+ */
+ addComponent(component: ComponentConfig): void {
+ this._components.set(component.getName(), component);
+ }
+
+ /**
+ * Retrieves a component by its name.
+ *
+ * @param name - The component name to look up
+ * @returns The ComponentConfig if found, undefined otherwise
+ */
+ getComponent(name: string): ComponentConfig | undefined {
+ return this._components.get(name);
+ }
+
+ /**
+ * Returns all components in this configuration.
+ *
+ * @returns Array of all ComponentConfig instances
+ */
+ getAllComponents(): ComponentConfig[] {
+ return Array.from(this._components.values());
+ }
+
+ /**
+ * Returns all component names in this configuration.
+ *
+ * @returns Array of component names (strings)
+ */
+ getComponentNames(): string[] {
+ return Array.from(this._components.keys());
+ }
+
+ /**
+ * Checks if a component with the given name exists.
+ *
+ * @param name - The component name to check
+ * @returns True if component exists, false otherwise
+ */
+ hasComponent(name: string): boolean {
+ return this._components.has(name);
+ }
+
+ /**
+ * Returns the number of components in this configuration.
+ *
+ * @returns The component count
+ */
+ getComponentCount(): number {
+ return this._components.size;
+ }
+}
+
+/**
+ * Represents the complete editable configuration with edit tracking.
+ *
+ * Extends OpsConfigFile to use EditableComponentConfig instead of ComponentConfig,
+ * enabling full edit history tracking for all components and parameters.
+ */
+export class EditableOpsConfigFile extends OpsConfigFile {
+ /**
+ * Creates a new EditableOpsConfigFile instance.
+ */
+ constructor() {
+ super();
+ }
+
+ /**
+ * Returns all components in this configuration as EditableComponentConfig instances.
+ *
+ * @returns Array of all EditableComponentConfig instances
+ */
+ getAllComponents(): EditableComponentConfig[] {
+ return Array.from((this as any)._components.values()) as EditableComponentConfig[];
+ }
+
+ /**
+ * Retrieves a component by its name.
+ *
+ * @param name - The component name to look up
+ * @returns The EditableComponentConfig if found, undefined otherwise
+ */
+ getComponent(name: string): EditableComponentConfig | undefined {
+ return (this as any)._components.get(name) as EditableComponentConfig | undefined;
+ }
+}
\ No newline at end of file
diff --git a/util/config/configurator/types-core.ts b/util/config/configurator/types-core.ts
new file mode 100644
index 0000000..28b2f41
--- /dev/null
+++ b/util/config/configurator/types-core.ts
@@ -0,0 +1,375 @@
+/**
+ * Represents a single configuration parameter with metadata.
+ *
+ * Encapsulates key-value pairs with additional information like label,
+ * initial value (from file), and user-provided input value.
+ *
+ * The `key`, `initialValue`, and `isMandatory` are immutable after construction.
+ * Only `label` and `userInputValue` can be modified.
+ */
+export class ConfigParameter {
+ private _key: string;
+ private _label: string;
+ private _initialValue: string;
+ private _userInputValue: string;
+ private _isMandatory: boolean;
+ private _type: 'string' | 'password';
+ private _hint: string | undefined;
+
+ /**
+ * Creates a new ConfigParameter instance.
+ *
+ * @param key - The unique identifier for this parameter (immutable)
+ * @param label - Human-readable label for display in UI (default: '')
+ * @param initialValue - Initial value loaded from TOML file (default: '')
+ * @param userInputValue - User-provided value (default: '')
+ * @param isMandatory - Whether this parameter must have a non-empty value (default: false)
+ * @param type - Input type: 'string' for plain text, 'password' for masked input (default: 'string')
+ * @param hint - Optional guidance text shown to the user when editing this parameter
+ */
+ constructor(
+ key: string,
+ label: string = '',
+ initialValue: string = '',
+ userInputValue: string = '',
+ isMandatory: boolean = false,
+ type: 'string' | 'password' = 'string',
+ hint?: string
+ ) {
+ this._key = key;
+ this._label = label;
+ this._initialValue = initialValue;
+ this._userInputValue = userInputValue;
+ this._isMandatory = isMandatory;
+ this._type = type;
+ this._hint = hint;
+ }
+
+ /**
+ * Returns the unique key identifier for this parameter.
+ *
+ * @returns The parameter key (immutable)
+ */
+ getKey(): string {
+ return this._key;
+ }
+
+ /**
+ * Returns the human-readable label for this parameter.
+ *
+ * @returns The parameter label
+ */
+ getLabel(): string {
+ return this._label;
+ }
+
+ /**
+ * Sets the human-readable label for this parameter.
+ *
+ * @param label - The new label to set
+ */
+ setLabel(label: string): void {
+ this._label = label;
+ }
+
+ /**
+ * Returns the initial value loaded from the TOML file.
+ * This value is immutable after construction.
+ *
+ * @returns The initial value from file
+ */
+ getInitialValue(): string {
+ return this._initialValue;
+ }
+
+ /**
+ * Returns the user-provided input value.
+ *
+ * @returns The user input value
+ */
+ getUserInputValue(): string {
+ return this._userInputValue;
+ }
+
+ /**
+ * Sets the user-provided input value.
+ *
+ * @param value - The new user input value
+ */
+ setUserInputValue(value: string): void {
+ this._userInputValue = value;
+ }
+
+ /**
+ * Returns the effective value for this parameter.
+ *
+ * Returns `userInputValue` if set, otherwise falls back to `initialValue`.
+ * This follows the requirement that user input takes precedence.
+ *
+ * @returns The effective value (userInput || initialValue)
+ */
+ getValue(): string {
+ return this._userInputValue || this._initialValue;
+ }
+
+ /**
+ * Checks if user has provided input for this parameter.
+ *
+ * @returns True if userInputValue is not empty, false otherwise
+ */
+ hasUserInput(): boolean {
+ return this._userInputValue !== '';
+ }
+
+ /**
+ * Returns whether this parameter must have a non-empty value.
+ *
+ * @returns True if the parameter is mandatory, false otherwise
+ */
+ isMandatory(): boolean {
+ return this._isMandatory;
+ }
+
+ /**
+ * Returns the input type for this parameter.
+ *
+ * @returns 'password' if input should be masked, 'string' otherwise
+ */
+ getType(): 'string' | 'password' {
+ return this._type;
+ }
+
+ /**
+ * Returns the optional hint text for this parameter.
+ *
+ * @returns The hint string if present, undefined otherwise
+ */
+ getHint(): string | undefined {
+ return this._hint;
+ }
+}
+
+/**
+ * Represents configuration for a single managed component.
+ *
+ * A managed component (e.g., redis, postgres) contains multiple
+ * configuration parameters organized as key-value pairs.
+ */
+export class ComponentConfig {
+ private _name: string;
+ private _parameters: Map<string, ConfigParameter>;
+
+ /**
+ * Creates a new ComponentConfig instance.
+ *
+ * @param name - The name of the managed component (e.g., 'redis', 'postgres')
+ */
+ constructor(name: string) {
+ this._name = name;
+ this._parameters = new Map<string, ConfigParameter>();
+ }
+
+ /**
+ * Returns the name of this component.
+ *
+ * @returns The component name
+ */
+ getName(): string {
+ return this._name;
+ }
+
+ /**
+ * Adds a configuration parameter to this component.
+ * If a parameter with the same key exists, it will be replaced.
+ *
+ * @param parameter - The ConfigParameter to add
+ */
+ addParameter(parameter: ConfigParameter): void {
+ this._parameters.set(parameter.getKey(), parameter);
+ }
+
+ /**
+ * Retrieves a parameter by its key.
+ *
+ * @param key - The parameter key to look up
+ * @returns The ConfigParameter if found, undefined otherwise
+ */
+ getParameter(key: string): ConfigParameter | undefined {
+ return this._parameters.get(key);
+ }
+
+ /**
+ * Returns all parameters for this component.
+ *
+ * @returns Array of all ConfigParameter instances
+ */
+ getAllParameters(): ConfigParameter[] {
+ return Array.from(this._parameters.values());
+ }
+
+ /**
+ * Returns all parameter keys for this component.
+ *
+ * @returns Array of parameter keys (strings)
+ */
+ getParameterKeys(): string[] {
+ return Array.from(this._parameters.keys());
+ }
+
+ /**
+ * Checks if a parameter with the given key exists.
+ *
+ * @param key - The parameter key to check
+ * @returns True if parameter exists, false otherwise
+ */
+ hasParameter(key: string): boolean {
+ return this._parameters.has(key);
+ }
+
+ /**
+ * Returns the number of parameters in this component.
+ *
+ * @returns The parameter count
+ */
+ getParameterCount(): number {
+ return this._parameters.size;
+ }
+}
+
+/**
+ * Extends ConfigParameter with edit history tracking.
+ *
+ * Tracks previousUserInputValue to enable undo functionality
+ * and maintain a complete edit history for the configuration workflow.
+ */
+export class EditableConfigParameter extends ConfigParameter {
+ private _previousUserInputValue: string;
+
+ /**
+ * Creates a new EditableConfigParameter instance.
+ *
+ * @param key - The unique identifier for this parameter (immutable)
+ * @param label - Human-readable label for display in UI (default: '')
+ * @param initialValue - Initial value loaded from TOML file (default: '')
+ * @param userInputValue - Current user-provided value (default: '')
+ * @param previousUserInputValue - Previous user input value (default: '')
+ * @param isMandatory - Whether this parameter must have a non-empty value (default: false)
+ * @param type - Input type: 'string' for plain text, 'password' for masked input (default: 'string')
+ * @param hint - Optional guidance text shown to the user when editing this parameter
+ */
+ constructor(
+ key: string,
+ label: string = '',
+ initialValue: string = '',
+ userInputValue: string = '',
+ previousUserInputValue: string = '',
+ isMandatory: boolean = false,
+ type: 'string' | 'password' = 'string',
+ hint?: string
+ ) {
+ super(key, label, initialValue, userInputValue, isMandatory, type, hint);
+ this._previousUserInputValue = previousUserInputValue;
+ }
+
+ /**
+ * Returns the previous user input value.
+ *
+ * @returns The previous user input value
+ */
+ getPreviousUserInputValue(): string {
+ return this._previousUserInputValue;
+ }
+
+ /**
+ * Sets the previous user input value.
+ *
+ * @param value - The previous user input value to set
+ */
+ setPreviousUserInputValue(value: string): void {
+ this._previousUserInputValue = value;
+ }
+
+ /**
+ * Updates the user input value and tracks the previous value.
+ *
+ * Automatically moves the current userInputValue to previousUserInputValue
+ * before setting the new value.
+ *
+ * @param value - The new user input value
+ */
+ updateUserInputValue(value: string): void {
+ this._previousUserInputValue = this.getUserInputValue();
+ this.setUserInputValue(value);
+ }
+
+ /**
+ * Reverts to the previous user input value.
+ *
+ * Sets userInputValue to previousUserInputValue and clears previousUserInputValue.
+ *
+ * @returns true if there was a previous value to revert to, false otherwise
+ */
+ revertToPrevious(): boolean {
+ if (this._previousUserInputValue !== '') {
+ this.setUserInputValue(this._previousUserInputValue);
+ this._previousUserInputValue = '';
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Creates an EditableConfigParameter from a ConfigParameter instance.
+ *
+ * @param param - The ConfigParameter to convert
+ * @returns A new EditableConfigParameter instance
+ */
+ static fromConfigParameter(param: ConfigParameter): EditableConfigParameter {
+ return new EditableConfigParameter(
+ param.getKey(),
+ param.getLabel(),
+ param.getInitialValue(),
+ param.getUserInputValue(),
+ '',
+ param.isMandatory(),
+ param.getType(),
+ param.getHint()
+ );
+ }
+}
+
+/**
+ * Represents configuration for a single managed component with edit tracking.
+ *
+ * Extends ComponentConfig to use EditableConfigParameter instead of ConfigParameter,
+ * enabling full edit history tracking for all parameters in the component.
+ */
+export class EditableComponentConfig extends ComponentConfig {
+ /**
+ * Creates a new EditableComponentConfig instance.
+ *
+ * @param name - The name of the managed component (e.g., 'redis', 'postgres')
+ */
+ constructor(name: string) {
+ super(name);
+ }
+
+ /**
+ * Returns all parameters for this component as EditableConfigParameter instances.
+ *
+ * @returns Array of all EditableConfigParameter instances
+ */
+ getAllParameters(): EditableConfigParameter[] {
+ return Array.from((this as any)._parameters.values()) as EditableConfigParameter[];
+ }
+
+ /**
+ * Retrieves a parameter by its key.
+ *
+ * @param key - The parameter key to look up
+ * @returns The EditableConfigParameter if found, undefined otherwise
+ */
+ getParameter(key: string): EditableConfigParameter | undefined {
+ return (this as any)._parameters.get(key) as EditableConfigParameter | undefined;
+ }
+}
\ No newline at end of file
diff --git a/util/config/configurator/types.ts b/util/config/configurator/types.ts
new file mode 100644
index 0000000..56108a1
--- /dev/null
+++ b/util/config/configurator/types.ts
@@ -0,0 +1,19 @@
+/**
+ * Result type for operations that can succeed or fail.
+ *
+ * @template T - The type of data returned on success
+ * @template E - The type of error returned on failure
+ */
+export type Result<T, E> = {
+ success: boolean;
+ data?: T;
+ error?: E;
+};
+
+/**
+ * Result type for validation operations.
+ */
+export type ValidationResult = {
+ success: boolean;
+ errors: string[];
+};
\ No newline at end of file