Compare commits

...

8 Commits

Author SHA1 Message Date
91fe5f7ae6 v5.2.0
Some checks failed
CI / Type Check & Lint (push) Failing after 8s
CI / Build Test (Current Platform) (push) Successful in 9s
Release / build-and-release (push) Successful in 1m2s
Publish to npm / npm-publish (push) Failing after 1m7s
CI / Build All Platforms (push) Successful in 1m10s
2026-01-29 17:04:12 +00:00
07648b4880 feat(core): Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with safety and SNMP refactors 2026-01-29 17:04:12 +00:00
d0e3a4ae74 v5.1.11
Some checks failed
CI / Type Check & Lint (push) Successful in 9s
CI / Build Test (Current Platform) (push) Successful in 9s
Publish to npm / npm-publish (push) Failing after 23s
Release / build-and-release (push) Successful in 51s
CI / Build All Platforms (push) Successful in 1m0s
2025-11-09 11:30:39 +00:00
89ffd61717 fix(readme): Update README installation instructions to recommend automated installer script and clarify npm installation 2025-11-09 11:30:39 +00:00
60eadaf6a1 5.1.10
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 20s
CI / Build All Platforms (push) Successful in 57s
Release / build-and-release (push) Successful in 54s
2025-10-23 18:25:52 +00:00
bd52ba4cb2 fix(config): Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings 2025-10-23 18:25:52 +00:00
a3d6a8b75d 5.1.9
Some checks failed
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 3s
CI / Build All Platforms (push) Successful in 48s
2025-10-23 18:19:51 +00:00
fbd71b1f3b fix(dev): new gitzone cli 2025-10-23 18:19:51 +00:00
26 changed files with 1066 additions and 613 deletions

1
.serena/.gitignore vendored
View File

@@ -1 +0,0 @@
/cache

View File

@@ -1,71 +0,0 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: 'utf-8'
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ''
project_name: 'nupst'

View File

@@ -1,5 +1,36 @@
# Changelog
## 2026-01-29 - 5.2.0 - feat(core)
Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with safety and SNMP refactors
- Add ts/constants.ts to centralize timing, SNMP, webhook, script, shutdown and UI constants and replace magic numbers across the codebase
- Introduce helpers/prompt.ts with createPrompt() and withPrompt() and refactor CLI handlers to use these helpers (cleaner prompt lifecycle)
- Add webhook action support: ts/actions/webhook-action.ts, IWebhookPayload type, and export from ts/actions/index.ts
- Enhance ShutdownAction safety checks (only trigger onBattery, stricter transition rules) and use constants/UI widths for displays
- Refactor SNMP manager to use logger instead of console, pull SNMP defaults from constants, improved debug output, and add INupstAccessor interface to break circular dependency (Nupst now implements the interface)
- Update many CLI and core types (stronger typing for configs/actions), expand tests and update README and npmextra.json to document new features
## 2025-11-09 - 5.1.11 - fix(readme)
Update README installation instructions to recommend automated installer script and clarify npm installation
- Replace the previous 'Via npm (NEW! - Recommended)' section with a clear 'Automated Installer Script (Recommended)' section and example curl installer.
- Move npm installation instructions into an 'Alternative: Via npm' subsection and clarify that the npm package downloads the appropriate pre-compiled binary for the platform during installation.
- Remove the 'NEW!' badge and streamline notes about binary downloads and installation methods.
## 2025-10-23 - 5.1.10 - fix(config)
Synchronize deno.json version with package.json, tidy formatting, and add local tooling settings
- Bumped deno.json version to 5.1.9 to match package.json/commitinfo
- Reformatted deno.json arrays (lint, fmt, compilerOptions) for readability
- Added .claude/settings.local.json for local development/tooling permissions (no runtime behaviour changes)
## 2025-10-23 - 5.1.9 - fix(dev)
Add local assistant permissions/settings file (.claude/settings.local.json)
- Added .claude/settings.local.json containing local assistant permission configuration used for development tasks (deno check, deno lint/format, npm/pack, running packaged binaries, etc.)
- This is a development/local configuration file and does not change runtime behavior or product code paths
- Patch version bump recommended
## 2025-10-23 - 5.1.2 - fix(scripts)
Add build script to package.json and include local dev tool settings

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.1.8",
"version": "5.2.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
@@ -15,7 +15,9 @@
},
"lint": {
"rules": {
"tags": ["recommended"]
"tags": [
"recommended"
]
}
},
"fmt": {
@@ -26,7 +28,9 @@
"singleQuote": true
},
"compilerOptions": {
"lib": ["deno.window"],
"lib": [
"deno.window"
],
"strict": true
},
"imports": {

View File

@@ -1 +1,20 @@
{}
{
"@git.zone/cli": {
"release": {
"registries": [
"https://verdaccio.lossless.digital"
],
"accessLevel": "public"
},
"projectType": "deno",
"module": {
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "nupst",
"description": "shut down in time when the power goes out",
"npmPackagename": "@serve.zone/nupst",
"license": "MIT"
}
},
"@ship.zone/szci": {}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.1.8",
"version": "5.2.0",
"description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies",
"keywords": [
"ups",

View File

@@ -0,0 +1,58 @@
# NUPST Project Hints
## Recent Refactoring (January 2026)
### Phase 1 - Quick Wins
1. **Prompt Utility (`ts/helpers/prompt.ts`)**
- Extracted readline/prompt pattern from all CLI handlers
- Provides `createPrompt()` and `withPrompt()` helper functions
- Used in: `ups-handler.ts`, `group-handler.ts`, `service-handler.ts`, `action-handler.ts`, `feature-handler.ts`
2. **Constants File (`ts/constants.ts`)**
- Centralized all magic numbers (timeouts, intervals, thresholds)
- Contains: `TIMING`, `SNMP`, `THRESHOLDS`, `WEBHOOK`, `SCRIPT`, `SHUTDOWN`, `HTTP_SERVER`, `UI`
- Used in: `daemon.ts`, `snmp/manager.ts`, `actions/*.ts`
3. **Logger Consistency**
- Replaced all `console.log/console.error` in `snmp/manager.ts` with proper `logger.*` calls
- Debug output uses `logger.dim()` for less intrusive output
### Phase 2 - Type Safety
4. **Circular Dependency Fix (`ts/interfaces/nupst-accessor.ts`)**
- Created `INupstAccessor` interface to break the circular dependency between `Nupst` and `NupstSnmp`
- `NupstSnmp.nupst` property now uses the interface instead of `any`
5. **Webhook Payload Interface (`ts/actions/webhook-action.ts`)**
- Added `IWebhookPayload` interface for webhook action payloads
- Exported from `ts/actions/index.ts`
6. **CLI Handler Type Safety**
- Replaced `any` types in `ups-handler.ts` and `group-handler.ts` with proper interfaces
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`, `ISnmpUpsStatus`
## Architecture Notes
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular imports
- **CLI Handlers**: All use the `helpers.withPrompt()` utility for interactive input
- **Constants**: All timing values should be referenced from `ts/constants.ts`
- **Actions**: Use `IActionConfig` from `ts/actions/base-action.ts` for action configuration
## File Organization
```
ts/
├── constants.ts # All timing/threshold constants
├── interfaces/
│ └── nupst-accessor.ts # Interface to break circular deps
├── helpers/
│ ├── prompt.ts # Readline utility
│ └── shortid.ts # ID generation
├── actions/
│ ├── base-action.ts # Base action class and interfaces
│ ├── webhook-action.ts # Includes IWebhookPayload
│ └── ...
└── cli/
└── ... # All handlers use helpers.withPrompt()
```

167
readme.md
View File

@@ -8,6 +8,10 @@ maximum reliability.
**Version 5.0+** is powered by Deno and distributed as single pre-compiled binaries—no installation,
no setup, just run.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## ✨ Features
- **🔌 Multi-UPS Support**: Monitor multiple UPS devices from a single installation
@@ -18,6 +22,8 @@ no setup, just run.
- Battery threshold triggers
- Runtime threshold triggers
- Power status change triggers
- Webhook notifications
- Custom shell scripts
- Configurable shutdown delays
- **🌐 Universal SNMP Support**: Full support for SNMP v1, v2c, and v3 with authentication and
encryption
@@ -62,26 +68,7 @@ nupst service status
## 📥 Installation
### Via npm (NEW! - Recommended)
Install NUPST globally using npm:
```bash
npm install -g @serve.zone/nupst
```
**Benefits:**
- Automatic platform detection and binary download
- Downloads only the binary for your platform (~400-500MB)
- Easy updates via `npm update -g @serve.zone/nupst`
- Version management with npm
- Works with Node.js >=14
**Note:** The installation will download the appropriate binary from GitHub releases during the
postinstall step.
### Automated Installer Script
### Automated Installer Script (Recommended)
The installer script handles everything automatically:
@@ -136,6 +123,16 @@ chmod +x nupst
sudo mv nupst /usr/local/bin/nupst
```
### Alternative: Via npm
Alternatively, NUPST can be installed via npm:
```bash
npm install -g @serve.zone/nupst
```
**Note:** This method downloads the appropriate pre-compiled binary for your platform during installation.
### Verify Installation
```bash
@@ -187,7 +184,7 @@ nupst group remove <id> # Remove a group
nupst group list # List all groups
```
### Action Management 🆕
### Action Management
Actions define what happens when UPS conditions are met. Actions can be attached to individual UPS
devices or to groups.
@@ -206,6 +203,14 @@ nupst action list
nupst action list <ups-id|group-id>
```
**Supported Action Types:**
| Type | Description |
| ---------- | ------------------------------------------------ |
| `shutdown` | Graceful system shutdown with configurable delay |
| `webhook` | HTTP POST/GET notification to external services |
| `script` | Execute custom shell scripts from `/etc/nupst/` |
**Example: Adding an action**
```bash
@@ -230,7 +235,7 @@ Add Action to UPS Main Server UPS
Changes saved and will be applied automatically
```
### Feature Management 🆕
### Feature Management
Optional features like the HTTP server for JSON status export:
@@ -321,7 +326,7 @@ nupst config show # Display current configuration
NUPST stores configuration at `/etc/nupst/config.json`. The easiest way to configure is through
interactive commands, but you can also edit the JSON directly.
### Example Configuration (v4.1+)
### Example Configuration
```json
{
@@ -462,11 +467,26 @@ Actions define automated responses to UPS conditions:
| Field | Description | Values |
| --------------- | -------------------------------- | -------------------------------------- |
| `type` | Action type | Currently only 'shutdown' |
| `type` | Action type | 'shutdown', 'webhook', 'script' |
| `thresholds` | Battery and runtime limits | `{ battery: 0-100, runtime: minutes }` |
| `triggerMode` | When to trigger action | See Trigger Modes below |
| `shutdownDelay` | Delay before executing (seconds) | Default: 5 |
**Webhook-Specific Fields:**
| Field | Description | Values |
| ---------------- | -------------------------- | ---------------- |
| `webhookUrl` | URL to call | HTTP/HTTPS URL |
| `webhookMethod` | HTTP method | 'POST' or 'GET' |
| `webhookTimeout` | Request timeout in ms | Default: 10000 |
**Script-Specific Fields:**
| Field | Description | Values |
| --------------- | ---------------------------------- | ----------------------- |
| `scriptPath` | Script filename in `/etc/nupst/` | Must end with `.sh` |
| `scriptTimeout` | Execution timeout in ms | Default: 60000 |
**Trigger Modes:**
| Mode | Description |
@@ -497,7 +517,7 @@ Groups allow coordinated management of multiple UPS devices:
- **`nonRedundant`**: System shuts down when ANY UPS device in the group is critical. Used when all
UPS devices must be operational.
#### HTTP Server Configuration 🆕
#### HTTP Server Configuration
Enable optional HTTP server for JSON status export with authentication:
@@ -825,7 +845,7 @@ When installed, NUPST makes the following changes:
## 🚀 Migration from v3.x
Upgrading from NUPST v3.x (Node.js) to v4.x (Deno) is seamless:
Upgrading from NUPST v3.x (Node.js) to v4.x+ (Deno) is seamless:
```bash
# One command to migrate everything
@@ -837,12 +857,12 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
- Detects v3.x installation
- Stops the service
- Replaces Node.js version with Deno binary
- Migrates configuration (v4.0 → v4.1 format if needed)
- Migrates configuration (v4.0 → v4.2 format if needed)
- Restarts the service
### Key Changes in v4.x
### Key Changes in v4.x+
| Aspect | v3.x | v4.x |
| Aspect | v3.x | v4.x+ |
| ------------------------ | -------------------------- | ----------------------------- |
| **Runtime** | Node.js + npm | Deno |
| **Distribution** | Git repo + npm install | Pre-compiled binaries |
@@ -854,38 +874,7 @@ curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh |
### Configuration Compatibility
Your v3.x configuration is **fully compatible**. The migration system automatically converts:
**v4.0 format** (UPS-level thresholds):
```json
{
"version": "4.0",
"upsDevices": [{
"id": "ups-1",
"thresholds": { "battery": 60, "runtime": 20 }
}]
}
```
**v4.1 format** (action-based thresholds):
```json
{
"version": "4.1",
"upsDevices": [{
"id": "ups-1",
"actions": [{
"type": "shutdown",
"thresholds": { "battery": 60, "runtime": 20 },
"triggerMode": "onlyThresholds",
"shutdownDelay": 5
}]
}]
}
```
Migration happens automatically on first run—no manual changes needed.
Your v3.x configuration is **fully compatible**. The migration system automatically converts older formats to the current version.
## 💻 Development
@@ -920,49 +909,39 @@ deno task compile
nupst/
├── mod.ts # Entry point
├── ts/
│ ├── cli.ts # CLI command routing
│ ├── nupst.ts # Main coordinator class
│ ├── daemon.ts # Background monitoring daemon
│ ├── systemd.ts # Systemd service management
│ ├── snmp/ # SNMP implementation
│ ├── actions/ # Action system
│ ├── migrations/ # Config migration system
── cli/ # CLI handlers
├── test/ # Test files
├── scripts/ # Build scripts
└── deno.json # Deno configuration
│ ├── cli.ts # CLI command routing
│ ├── nupst.ts # Main coordinator class
│ ├── daemon.ts # Background monitoring daemon
│ ├── systemd.ts # Systemd service management
│ ├── constants.ts # Centralized configuration constants
│ ├── interfaces/ # TypeScript interfaces
│ ├── snmp/ # SNMP implementation
── actions/ # Action system (shutdown, webhook, script)
│ ├── helpers/ # Utility functions
│ ├── migrations/ # Config migration system
│ └── cli/ # CLI handlers
├── test/ # Test files
├── scripts/ # Build scripts
└── deno.json # Deno configuration
```
## 📞 Support
- **Issues**: [Report bugs or request features](https://code.foss.global/serve.zone/nupst/issues)
- **Documentation**: [Full documentation](https://code.foss.global/serve.zone/nupst)
- **Source Code**: [View source](https://code.foss.global/serve.zone/nupst)
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT
License can be found in the [license](license) file within this repository.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks,
service marks, or product names of the project, except as required for reasonable and customary use
in describing the origin of the work and reproducing the content of the NOTICE file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated
with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture
Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these
trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be
approved in writing by Task Venture Capital GmbH.
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH Registered at District court Bremen HRB 35230 HB, Germany
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at
hello@task.vc.
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its
terms, and understand that the licensing of the code does not imply endorsement by Task Venture
Capital GmbH of any derivative works.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -1,93 +1,368 @@
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts';
import type { ISnmpConfig } from '../ts/snmp/types.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { ISnmpConfig, TUpsModel, IOidSet } from '../ts/snmp/types.ts';
import { shortId } from '../ts/helpers/shortid.ts';
import { TIMING, SNMP, THRESHOLDS, HTTP_SERVER, UI } from '../ts/constants.ts';
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
const testQenv = new qenv.Qenv('./', '.nogit/');
// Create an SNMP instance with debug enabled
// =============================================================================
// UNIT TESTS - No external dependencies required
// =============================================================================
// -----------------------------------------------------------------------------
// shortId() Tests
// -----------------------------------------------------------------------------
Deno.test('shortId: generates 6-character string', () => {
const id = shortId();
assertEquals(id.length, 6);
});
Deno.test('shortId: contains only alphanumeric characters', () => {
const id = shortId();
const alphanumericRegex = /^[a-zA-Z0-9]+$/;
assert(alphanumericRegex.test(id), `ID "${id}" contains non-alphanumeric characters`);
});
Deno.test('shortId: generates unique IDs', () => {
const ids = new Set<string>();
const count = 100;
for (let i = 0; i < count; i++) {
ids.add(shortId());
}
// All IDs should be unique (statistically extremely likely for 100 IDs)
assertEquals(ids.size, count, 'Generated IDs should be unique');
});
// -----------------------------------------------------------------------------
// Constants Tests
// -----------------------------------------------------------------------------
Deno.test('TIMING constants: all values are positive numbers', () => {
for (const [key, value] of Object.entries(TIMING)) {
assert(typeof value === 'number', `TIMING.${key} should be a number`);
assert(value > 0, `TIMING.${key} should be positive`);
}
});
Deno.test('SNMP constants: port is 161', () => {
assertEquals(SNMP.DEFAULT_PORT, 161);
});
Deno.test('SNMP constants: timeouts increase with security level', () => {
assert(SNMP.TIMEOUT_NO_AUTH_MS <= SNMP.TIMEOUT_AUTH_MS, 'Auth timeout should be >= noAuth timeout');
assert(SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS, 'AuthPriv timeout should be >= Auth timeout');
});
Deno.test('THRESHOLDS constants: defaults are reasonable', () => {
assert(THRESHOLDS.DEFAULT_BATTERY_PERCENT > 0 && THRESHOLDS.DEFAULT_BATTERY_PERCENT <= 100);
assert(THRESHOLDS.DEFAULT_RUNTIME_MINUTES > 0);
assert(THRESHOLDS.EMERGENCY_RUNTIME_MINUTES < THRESHOLDS.DEFAULT_RUNTIME_MINUTES);
});
Deno.test('HTTP_SERVER constants: valid defaults', () => {
assertEquals(HTTP_SERVER.DEFAULT_PORT, 8080);
assert(HTTP_SERVER.DEFAULT_PATH.startsWith('/'));
});
Deno.test('UI constants: box widths are ascending', () => {
assert(UI.DEFAULT_BOX_WIDTH < UI.WIDE_BOX_WIDTH);
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
});
// -----------------------------------------------------------------------------
// UpsOidSets Tests
// -----------------------------------------------------------------------------
const UPS_MODELS: TUpsModel[] = ['cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom'];
Deno.test('UpsOidSets: all models have OID sets', () => {
for (const model of UPS_MODELS) {
const oidSet = UpsOidSets.getOidSet(model);
assertExists(oidSet, `OID set for ${model} should exist`);
}
});
Deno.test('UpsOidSets: all non-custom models have complete OIDs', () => {
const requiredOids = ['POWER_STATUS', 'BATTERY_CAPACITY', 'BATTERY_RUNTIME', 'OUTPUT_LOAD'];
for (const model of UPS_MODELS.filter(m => m !== 'custom')) {
const oidSet = UpsOidSets.getOidSet(model);
for (const oid of requiredOids) {
const value = oidSet[oid as keyof IOidSet];
assert(
typeof value === 'string' && value.length > 0,
`${model} should have non-empty ${oid}`
);
}
}
});
Deno.test('UpsOidSets: power status values defined for non-custom models', () => {
for (const model of UPS_MODELS.filter(m => m !== 'custom')) {
const oidSet = UpsOidSets.getOidSet(model);
assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`);
assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`);
assertExists(oidSet.POWER_STATUS_VALUES?.onBattery, `${model} should have onBattery value`);
}
});
Deno.test('UpsOidSets: getStandardOids returns RFC 1628 OIDs', () => {
const standardOids = UpsOidSets.getStandardOids();
assert('power status' in standardOids);
assert('battery capacity' in standardOids);
assert('battery runtime' in standardOids);
// RFC 1628 OIDs start with 1.3.6.1.2.1.33
for (const oid of Object.values(standardOids)) {
assert(oid.startsWith('1.3.6.1.2.1.33'), `Standard OID should be RFC 1628: ${oid}`);
}
});
// -----------------------------------------------------------------------------
// Action Base Class Tests
// -----------------------------------------------------------------------------
// Create a concrete implementation for testing
class TestAction extends Action {
readonly type = 'test';
executeCallCount = 0;
execute(_context: IActionContext): Promise<void> {
this.executeCallCount++;
return Promise.resolve();
}
// Expose protected methods for testing
public testShouldExecute(context: IActionContext): boolean {
return this.shouldExecute(context);
}
public testAreThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean {
return this.areThresholdsExceeded(batteryCapacity, batteryRuntime);
}
}
function createMockContext(overrides: Partial<IActionContext> = {}): IActionContext {
return {
upsId: 'test-ups',
upsName: 'Test UPS',
powerStatus: 'online',
batteryCapacity: 100,
batteryRuntime: 60,
previousPowerStatus: 'online',
timestamp: Date.now(),
triggerReason: 'powerStatusChange',
...overrides,
};
}
Deno.test('Action.areThresholdsExceeded: returns false when no thresholds configured', () => {
const action = new TestAction({ type: 'shutdown' });
assertEquals(action.testAreThresholdsExceeded(50, 30), false);
});
Deno.test('Action.areThresholdsExceeded: returns true when battery below threshold', () => {
const action = new TestAction({
type: 'shutdown',
thresholds: { battery: 60, runtime: 20 },
});
assertEquals(action.testAreThresholdsExceeded(59, 30), true); // Battery below
assertEquals(action.testAreThresholdsExceeded(60, 30), false); // Battery at threshold
assertEquals(action.testAreThresholdsExceeded(100, 30), false); // Battery above
});
Deno.test('Action.areThresholdsExceeded: returns true when runtime below threshold', () => {
const action = new TestAction({
type: 'shutdown',
thresholds: { battery: 60, runtime: 20 },
});
assertEquals(action.testAreThresholdsExceeded(100, 19), true); // Runtime below
assertEquals(action.testAreThresholdsExceeded(100, 20), false); // Runtime at threshold
assertEquals(action.testAreThresholdsExceeded(100, 60), false); // Runtime above
});
Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'onlyPowerChanges',
});
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
false
);
});
Deno.test('Action.shouldExecute: onlyThresholds mode', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: { battery: 60, runtime: 20 },
});
// Below thresholds - should execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })),
true
);
// Above thresholds - should not execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })),
false
);
});
Deno.test('Action.shouldExecute: onlyThresholds mode without thresholds returns false', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'onlyThresholds',
// No thresholds configured
});
assertEquals(action.testShouldExecute(createMockContext()), false);
});
Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () => {
const action = new TestAction({
type: 'shutdown',
thresholds: { battery: 60, runtime: 20 },
// No triggerMode = defaults to powerChangesAndThresholds
});
// Power change - should execute
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
);
// Threshold violation - should execute
assertEquals(
action.testShouldExecute(createMockContext({
triggerReason: 'thresholdViolation',
batteryCapacity: 50,
})),
true
);
// No power change and above thresholds - should not execute
assertEquals(
action.testShouldExecute(createMockContext({
triggerReason: 'thresholdViolation',
batteryCapacity: 100,
batteryRuntime: 60,
})),
false
);
});
Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
const action = new TestAction({
type: 'shutdown',
triggerMode: 'anyChange',
});
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
true
);
});
// -----------------------------------------------------------------------------
// NupstSnmp Class Tests (Unit tests - no real UPS needed)
// -----------------------------------------------------------------------------
Deno.test('NupstSnmp: can be instantiated', () => {
const snmp = new NupstSnmp(false);
assertExists(snmp);
});
Deno.test('NupstSnmp: debug mode can be enabled', () => {
const snmpDebug = new NupstSnmp(true);
const snmpNormal = new NupstSnmp(false);
assertExists(snmpDebug);
assertExists(snmpNormal);
});
// =============================================================================
// INTEGRATION TESTS - Require real UPS (loaded from .nogit/env.json)
// =============================================================================
// Helper function to run UPS test with config
async function testUpsConnection(
snmp: NupstSnmp,
config: Record<string, unknown>,
description: string,
): Promise<void> {
console.log(`Testing ${description}...`);
const snmpConfig = config.snmp as ISnmpConfig;
console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`);
console.log(` UPS Model: ${snmpConfig.upsModel}`);
// Use a reasonable timeout for testing
const testSnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, SNMP.MAX_TEST_TIMEOUT_MS),
};
const status = await snmp.getUpsStatus(testSnmpConfig);
console.log('UPS Status:');
console.log(` Power Status: ${status.powerStatus}`);
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
// Validate response structure
assertExists(status, 'Status should exist');
assert(
['online', 'onBattery', 'unknown'].includes(status.powerStatus),
`Power status should be valid: ${status.powerStatus}`
);
assertEquals(typeof status.batteryCapacity, 'number', 'Battery capacity should be a number');
assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number');
// Validate ranges
assert(
status.batteryCapacity >= 0 && status.batteryCapacity <= 100,
`Battery capacity should be 0-100: ${status.batteryCapacity}`
);
assert(status.batteryRuntime >= 0, `Battery runtime should be non-negative: ${status.batteryRuntime}`);
}
// Create SNMP instance for integration tests
const snmp = new NupstSnmp(true);
// Load the test configuration from .nogit/env.json
// Load test configurations
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
Deno.test('should log config', () => {
console.log(testConfigV1);
assert(true);
Deno.test('Integration: Real UPS test v1', async () => {
await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection');
});
// Test with real UPS using the configuration from .nogit/env.json
Deno.test('Real UPS test v1', async () => {
try {
console.log('Testing with real UPS configuration...');
// Extract the correct SNMP config from the test configuration
const snmpConfig = testConfigV1.snmp as ISnmpConfig;
console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`);
console.log(` UPS Model: ${snmpConfig.upsModel}`);
// Use a short timeout for testing
const testSnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
// Try to get the UPS status
const status = await snmp.getUpsStatus(testSnmpConfig);
console.log('UPS Status:');
console.log(` Power Status: ${status.powerStatus}`);
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
// Just make sure we got valid data types back
assertExists(status);
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
assertEquals(typeof status.batteryCapacity, 'number');
assertEquals(typeof status.batteryRuntime, 'number');
} catch (error) {
console.log('Real UPS test failed:', error);
// Skip the test if we can't connect to the real UPS
console.log('Skipping this test since the UPS might not be available');
}
});
Deno.test('Real UPS test v3', async () => {
try {
console.log('Testing with real UPS configuration...');
// Extract the correct SNMP config from the test configuration
const snmpConfig = testConfigV3.snmp as ISnmpConfig;
console.log('SNMP Config:');
console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`);
console.log(` Version: SNMPv${snmpConfig.version}`);
console.log(` UPS Model: ${snmpConfig.upsModel}`);
// Use a short timeout for testing
const testSnmpConfig = {
...snmpConfig,
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
};
// Try to get the UPS status
const status = await snmp.getUpsStatus(testSnmpConfig);
console.log('UPS Status:');
console.log(` Power Status: ${status.powerStatus}`);
console.log(` Battery Capacity: ${status.batteryCapacity}%`);
console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`);
// Just make sure we got valid data types back
assertExists(status);
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
assertEquals(typeof status.batteryCapacity, 'number');
assertEquals(typeof status.batteryRuntime, 'number');
} catch (error) {
console.log('Real UPS test failed:', error);
// Skip the test if we can't connect to the real UPS
console.log('Skipping this test since the UPS might not be available');
}
Deno.test('Integration: Real UPS test v3', async () => {
await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection');
});

8
ts/00_commitinfo_data.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.2.0',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}

View File

@@ -13,6 +13,7 @@ import { ScriptAction } from './script-action.ts';
// Re-export types for convenience
export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts';
export type { IWebhookPayload } from './webhook-action.ts';
export { Action } from './base-action.ts';
export { ShutdownAction } from './shutdown-action.ts';
export { WebhookAction } from './webhook-action.ts';

View File

@@ -1,8 +1,9 @@
import * as fs from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { SHUTDOWN, UI } from '../constants.ts';
const execFileAsync = promisify(execFile);
@@ -15,6 +16,81 @@ const execFileAsync = promisify(execFile);
export class ShutdownAction extends Action {
readonly type = 'shutdown';
/**
* Override shouldExecute to add shutdown-specific safety checks
*
* Key safety rules:
* 1. Shutdown should NEVER trigger unless UPS is actually on battery
* (low battery while on grid power is not an emergency - it's charging)
* 2. For power status changes, only trigger on transitions TO onBattery from online
* (ignore unknown → online at startup, and power restoration events)
* 3. For threshold violations, verify UPS is on battery before acting
*
* @param context Action context with UPS state
* @returns True if shutdown should execute
*/
protected override shouldExecute(context: IActionContext): boolean {
const mode = this.config.triggerMode || 'powerChangesAndThresholds';
// CRITICAL SAFETY CHECK: Shutdown should NEVER trigger unless UPS is on battery
// A low battery while on grid power is not an emergency (the battery is charging)
if (context.powerStatus !== 'onBattery') {
logger.info(`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`);
return false;
}
// Handle threshold violations (UPS is confirmed on battery at this point)
if (context.triggerReason === 'thresholdViolation') {
// 'onlyPowerChanges' mode ignores thresholds
if (mode === 'onlyPowerChanges') {
logger.info('Shutdown action skipped: triggerMode is onlyPowerChanges, ignoring threshold');
return false;
}
// Check if thresholds are actually exceeded
return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime);
}
// Handle power status changes
if (context.triggerReason === 'powerStatusChange') {
// 'onlyThresholds' mode ignores power status changes
if (mode === 'onlyThresholds') {
logger.info('Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change');
return false;
}
const prev = context.previousPowerStatus;
// Only trigger on transitions TO onBattery from online (real power loss)
if (prev === 'online') {
logger.info('Shutdown action triggered: power loss detected (online → onBattery)');
return true;
}
// For unknown → onBattery (daemon started while on battery):
// This is a startup scenario - be cautious. The user may have just started
// the daemon for testing, or the UPS may have been on battery for a while.
// Only trigger if mode explicitly includes power changes.
if (prev === 'unknown') {
if (mode === 'onlyPowerChanges' || mode === 'powerChangesAndThresholds' || mode === 'anyChange') {
logger.info('Shutdown action triggered: UPS on battery at daemon startup (unknown → onBattery)');
return true;
}
return false;
}
// Other transitions (e.g., onBattery → onBattery) should not trigger
logger.info(`Shutdown action skipped: non-emergency transition (${prev}${context.powerStatus})`);
return false;
}
// For 'anyChange' mode, always execute (UPS is already confirmed on battery)
if (mode === 'anyChange') {
return true;
}
return false;
}
/**
* Execute the shutdown action
* @param context Action context with UPS state
@@ -26,10 +102,10 @@ export class ShutdownAction extends Action {
return;
}
const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes
const shutdownDelay = this.config.shutdownDelay || SHUTDOWN.DEFAULT_DELAY_MINUTES;
logger.log('');
logger.logBoxTitle('Initiating System Shutdown', 60, 'error');
logger.logBoxTitle('Initiating System Shutdown', UI.WIDE_BOX_WIDTH, 'error');
logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`);
logger.logBoxLine(`Power Status: ${context.powerStatus}`);
logger.logBoxLine(`Battery: ${context.batteryCapacity}%`);

View File

@@ -3,6 +3,32 @@ import * as https from 'node:https';
import { URL } from 'node:url';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { WEBHOOK } from '../constants.ts';
/**
* Payload sent to webhook endpoints
*/
export interface IWebhookPayload {
/** UPS ID */
upsId: string;
/** UPS name */
upsName: string;
/** Current power status */
powerStatus: 'online' | 'onBattery' | 'unknown';
/** Current battery capacity percentage */
batteryCapacity: number;
/** Current battery runtime in minutes */
batteryRuntime: number;
/** Reason this webhook was triggered */
triggerReason: 'powerStatusChange' | 'thresholdViolation';
/** Timestamp when webhook was triggered */
timestamp: number;
/** Thresholds configured for this action (if any) */
thresholds?: {
battery: number;
runtime: number;
};
}
/**
* WebhookAction - Calls an HTTP webhook with UPS state information
@@ -30,7 +56,7 @@ export class WebhookAction extends Action {
}
const method = this.config.webhookMethod || 'POST';
const timeout = this.config.webhookTimeout || 10000;
const timeout = this.config.webhookTimeout || WEBHOOK.DEFAULT_TIMEOUT_MS;
logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`);
@@ -56,7 +82,7 @@ export class WebhookAction extends Action {
method: 'GET' | 'POST',
timeout: number,
): Promise<void> {
const payload: any = {
const payload: IWebhookPayload = {
upsId: context.upsId,
upsName: context.upsName,
powerStatus: context.powerStatus,

View File

@@ -4,6 +4,7 @@ import { logger, type ITableColumn } from '../logger.ts';
import { theme, symbols } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts';
/**
* Class for handling action-related CLI commands
@@ -57,21 +58,7 @@ export class ActionHandler {
const targetType = ups ? 'UPS' : 'Group';
const targetName = ups ? ups.name : group!.name;
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
logger.log('');
logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`);
logger.log('');
@@ -154,9 +141,7 @@ export class ActionHandler {
logger.success(`Action added to ${targetType} ${targetName}`);
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
logger.log('');
} finally {
rl.close();
}
});
} catch (error) {
logger.error(
`Failed to add action: ${error instanceof Error ? error.message : String(error)}`,

View File

@@ -25,26 +25,9 @@ export class FeatureHandler {
*/
public async configureHttpServer(): Promise<void> {
try {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
await this.runHttpServerConfig(prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -186,17 +169,9 @@ export class FeatureHandler {
if (isActive) {
logger.log('');
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question('Service is running. Restart to apply changes? (Y/n): ', resolve);
});
rl.close();
const { prompt, close } = await helpers.createPrompt();
const answer = await prompt('Service is running. Restart to apply changes? (Y/n): ');
close();
if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
logger.info('Restarting service...');

View File

@@ -3,7 +3,7 @@ import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import { type IGroupConfig } from '../daemon.ts';
import type { IGroupConfig, IUpsConfig, INupstConfig } from '../daemon.ts';
/**
* Class for handling group-related CLI commands
@@ -100,24 +100,7 @@ export class GroupHandler {
*/
public async add(): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
// Try to load configuration
try {
await this.nupst.getDaemon().loadConfig();
@@ -200,10 +183,7 @@ export class GroupHandler {
this.nupst.getUpsHandler().restartServiceIfRunning();
logger.log('\nGroup setup complete!');
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -215,24 +195,7 @@ export class GroupHandler {
*/
public async edit(groupId: string): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
// Try to load configuration
try {
await this.nupst.getDaemon().loadConfig();
@@ -318,10 +281,7 @@ export class GroupHandler {
this.nupst.getUpsHandler().restartServiceIfRunning();
logger.log('\nGroup edit complete!');
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -362,23 +322,11 @@ export class GroupHandler {
const groupToDelete = config.groups[groupIndex];
// Get confirmation before deleting
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const confirm = await new Promise<string>((resolve) => {
rl.question(
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
(answer) => {
resolve(answer.toLowerCase());
},
);
});
rl.close();
process.stdin.destroy();
const { prompt, close } = await helpers.createPrompt();
const confirm = (await prompt(
`Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `,
)).toLowerCase();
close();
if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.');
@@ -419,8 +367,8 @@ export class GroupHandler {
* @param prompt Function to prompt for user input
*/
public async assignUpsToGroups(
ups: any,
groups: any[],
ups: IUpsConfig,
groups: IGroupConfig[],
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Initialize groups array if it doesn't exist
@@ -514,7 +462,7 @@ export class GroupHandler {
*/
public async assignUpsToGroup(
groupId: string,
config: any,
config: INupstConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
if (!config.upsDevices || config.upsDevices.length === 0) {
@@ -522,7 +470,7 @@ export class GroupHandler {
return;
}
const group = config.groups.find((g: { id: string }) => g.id === groupId);
const group = config.groups.find((g) => g.id === groupId);
if (!group) {
logger.error(`Group with ID "${groupId}" not found.`);
return;
@@ -530,7 +478,7 @@ export class GroupHandler {
// Show current assignments
logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`);
const upsInGroup = config.upsDevices.filter((ups: { groups?: string[] }) =>
const upsInGroup = config.upsDevices.filter((ups) =>
ups.groups && ups.groups.includes(groupId)
);
if (upsInGroup.length === 0) {

View File

@@ -2,6 +2,7 @@ import process from 'node:process';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
import * as helpers from '../helpers/index.ts';
/**
* Class for handling service-related CLI commands
@@ -196,22 +197,7 @@ export class ServiceHandler {
this.checkRootAccess('This command must be run as root.');
try {
// Import readline module for user input
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
const { prompt, close } = await helpers.createPrompt();
logger.log('');
logger.highlight('NUPST Uninstaller');
@@ -254,15 +240,13 @@ export class ServiceHandler {
if (!uninstallScriptPath) {
logger.error('Could not locate uninstall.sh script. Aborting uninstall.');
rl.close();
process.stdin.destroy();
close();
process.exit(1);
}
}
// Close readline before executing script
rl.close();
process.stdin.destroy();
// Close prompt before executing script
close();
// Execute uninstall.sh with the appropriate option
logger.log('');

View File

@@ -4,8 +4,17 @@ import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { TUpsModel } from '../snmp/types.ts';
import type { INupstConfig } from '../daemon.ts';
import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.ts';
import type { INupstConfig, IUpsConfig, IUpsStatus } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts';
/**
* Thresholds configuration for CLI display
*/
interface IThresholds {
battery: number;
runtime: number;
}
/**
* Class for handling UPS-related CLI commands
@@ -27,29 +36,9 @@ export class UpsHandler {
*/
public async add(): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
await this.runAddProcess(prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Add UPS error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -160,29 +149,9 @@ export class UpsHandler {
*/
public async edit(upsId?: string): Promise<void> {
try {
// Import readline module for user input
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Helper function to prompt for input
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
try {
await helpers.withPrompt(async (prompt) => {
await this.runEditProcess(upsId, prompt);
} finally {
rl.close();
process.stdin.destroy();
}
});
} catch (error) {
logger.error(`Edit UPS error: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -337,23 +306,11 @@ export class UpsHandler {
const upsToDelete = config.upsDevices[upsIndex];
// Get confirmation before deleting
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const confirm = await new Promise<string>((resolve) => {
rl.question(
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
(answer) => {
resolve(answer.toLowerCase());
},
);
});
rl.close();
process.stdin.destroy();
const { prompt, close } = await helpers.createPrompt();
const confirm = (await prompt(
`Are you sure you want to delete UPS "${upsToDelete.name}" (${upsId})? [y/N]: `,
)).toLowerCase();
close();
if (confirm !== 'y' && confirm !== 'yes') {
logger.log('Deletion cancelled.');
@@ -509,7 +466,7 @@ export class UpsHandler {
* Display the configuration for testing
* @param config Current configuration or individual UPS configuration
*/
private displayTestConfig(config: any): void {
private displayTestConfig(config: IUpsConfig | INupstConfig): void {
// Check if this is a UPS device or full configuration
const isUpsConfig = config.snmp;
const snmpConfig = isUpsConfig ? config.snmp : config.snmp || {};
@@ -571,7 +528,7 @@ export class UpsHandler {
* Test connection to the UPS
* @param config Current UPS configuration or legacy config
*/
private async testConnection(config: any): Promise<void> {
private async testConnection(config: IUpsConfig | INupstConfig): Promise<void> {
const upsId = config.id || 'default';
const upsName = config.name || 'Default UPS';
logger.log(`\nTesting connection to UPS: ${upsName} (${upsId})...`);
@@ -610,7 +567,7 @@ export class UpsHandler {
* @param status UPS status
* @param thresholds Threshold configuration
*/
private analyzeThresholds(status: any, thresholds: any): void {
private analyzeThresholds(status: ISnmpUpsStatus, thresholds: IThresholds): void {
const boxWidth = 45;
logger.logBoxTitle('Threshold Analysis', boxWidth);
@@ -649,7 +606,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherSnmpSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// SNMP IP Address
@@ -693,7 +650,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherSnmpV3Settings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -771,7 +728,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherAuthenticationSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Authentication protocol
@@ -798,7 +755,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherPrivacySettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
// Privacy protocol
@@ -823,7 +780,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherUpsModelSettings(
snmpConfig: any,
snmpConfig: Partial<ISnmpConfig>,
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -888,7 +845,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async gatherActionSettings(
actions: any[],
actions: IActionConfig[],
prompt: (question: string) => Promise<string>,
): Promise<void> {
logger.log('');
@@ -915,7 +872,7 @@ export class UpsHandler {
const typeInput = await prompt('Select action type [1]: ');
const typeValue = parseInt(typeInput, 10) || 1;
const action: any = {};
const action: Partial<IActionConfig> = {};
if (typeValue === 1) {
// Shutdown action
@@ -1014,8 +971,8 @@ export class UpsHandler {
};
}
actions.push(action);
logger.success(`${action.type.charAt(0).toUpperCase() + action.type.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
actions.push(action as IActionConfig);
logger.success(`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
const more = await prompt('Add another action? (y/N): ');
addMore = more.toLowerCase() === 'y';
@@ -1031,7 +988,7 @@ export class UpsHandler {
* Display UPS configuration summary
* @param ups UPS configuration
*/
private displayUpsConfigSummary(ups: any): void {
private displayUpsConfigSummary(ups: IUpsConfig): void {
const boxWidth = 45;
logger.log('');
logger.logBoxTitle(`UPS Configuration: ${ups.name}`, boxWidth);
@@ -1055,7 +1012,7 @@ export class UpsHandler {
* @param prompt Function to prompt for user input
*/
private async optionallyTestConnection(
snmpConfig: any,
snmpConfig: ISnmpConfig,
prompt: (question: string) => Promise<string>,
): Promise<void> {
const testConnection = await prompt(

118
ts/constants.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* NUPST Constants
*
* Central location for all timeout, interval, and threshold values.
* This makes configuration easier and code more self-documenting.
*/
/**
* Default timing values in milliseconds
*/
export const TIMING = {
/** Default interval between UPS status checks (30 seconds) */
CHECK_INTERVAL_MS: 30000,
/** Interval for idle monitoring mode (60 seconds) */
IDLE_CHECK_INTERVAL_MS: 60000,
/** Interval for checking config file changes (60 seconds) */
CONFIG_CHECK_INTERVAL_MS: 60000,
/** Interval for logging periodic status updates (5 minutes) */
LOG_INTERVAL_MS: 5 * 60 * 1000,
/** Maximum time to monitor during shutdown (5 minutes) */
MAX_SHUTDOWN_MONITORING_MS: 5 * 60 * 1000,
/** Interval for UPS checks during shutdown (30 seconds) */
SHUTDOWN_CHECK_INTERVAL_MS: 30000,
} as const;
/**
* SNMP-related constants
*/
export const SNMP = {
/** Default SNMP port */
DEFAULT_PORT: 161,
/** Default SNMP timeout (5 seconds) */
DEFAULT_TIMEOUT_MS: 5000,
/** Number of SNMP retries */
RETRIES: 2,
/** Timeout for noAuthNoPriv security level (5 seconds) */
TIMEOUT_NO_AUTH_MS: 5000,
/** Timeout for authNoPriv security level (10 seconds) */
TIMEOUT_AUTH_MS: 10000,
/** Timeout for authPriv security level (15 seconds) */
TIMEOUT_AUTH_PRIV_MS: 15000,
/** Maximum timeout for connection tests (10 seconds) */
MAX_TEST_TIMEOUT_MS: 10000,
} as const;
/**
* Default threshold values
*/
export const THRESHOLDS = {
/** Default battery capacity threshold for shutdown (60%) */
DEFAULT_BATTERY_PERCENT: 60,
/** Default runtime threshold for shutdown (20 minutes) */
DEFAULT_RUNTIME_MINUTES: 20,
/** Emergency runtime threshold during shutdown (5 minutes) */
EMERGENCY_RUNTIME_MINUTES: 5,
} as const;
/**
* Webhook action constants
*/
export const WEBHOOK = {
/** Default webhook request timeout (10 seconds) */
DEFAULT_TIMEOUT_MS: 10000,
} as const;
/**
* Script action constants
*/
export const SCRIPT = {
/** Default script execution timeout (60 seconds) */
DEFAULT_TIMEOUT_MS: 60000,
} as const;
/**
* Shutdown action constants
*/
export const SHUTDOWN = {
/** Default shutdown delay (5 minutes) */
DEFAULT_DELAY_MINUTES: 5,
} as const;
/**
* HTTP Server constants
*/
export const HTTP_SERVER = {
/** Default HTTP server port */
DEFAULT_PORT: 8080,
/** Default URL path for UPS status endpoint */
DEFAULT_PATH: '/ups-status',
} as const;
/**
* UI/Display constants
*/
export const UI = {
/** Default width for log boxes */
DEFAULT_BOX_WIDTH: 45,
/** Wide box width for status displays */
WIDE_BOX_WIDTH: 60,
/** Extra wide box width for detailed info */
EXTRA_WIDE_BOX_WIDTH: 70,
} as const;

View File

@@ -11,6 +11,7 @@ import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } f
import type { IActionConfig } from './actions/base-action.ts';
import { ActionManager, type IActionContext, type TPowerStatus } from './actions/index.ts';
import { NupstHttpServer } from './http-server.ts';
import { TIMING, THRESHOLDS, UI } from './constants.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -144,8 +145,8 @@ export class NupstDaemon {
type: 'shutdown',
triggerMode: 'onlyThresholds',
thresholds: {
battery: 60, // Shutdown when battery below 60%
runtime: 20, // Shutdown when runtime below 20 minutes
battery: THRESHOLDS.DEFAULT_BATTERY_PERCENT, // Shutdown when battery below 60%
runtime: THRESHOLDS.DEFAULT_RUNTIME_MINUTES, // Shutdown when runtime below 20 minutes
},
shutdownDelay: 5,
},
@@ -153,7 +154,7 @@ export class NupstDaemon {
},
],
groups: [],
checkInterval: 30000, // Check every 30 seconds
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
}
private config: INupstConfig;
@@ -282,20 +283,23 @@ export class NupstDaemon {
this.logConfigLoaded();
// Log version information
this.snmp.getNupst().logVersionInfo(false); // Don't check for updates immediately on startup
const nupst = this.snmp.getNupst();
if (nupst) {
nupst.logVersionInfo(false); // Don't check for updates immediately on startup
// Check for updates in the background
this.snmp.getNupst().checkForUpdates().then((updateAvailable: boolean) => {
if (updateAvailable) {
const updateStatus = this.snmp.getNupst().getUpdateStatus();
const boxWidth = 45;
logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
}
}).catch(() => {}); // Ignore errors checking for updates
// Check for updates in the background
nupst.checkForUpdates().then((updateAvailable: boolean) => {
if (updateAvailable) {
const updateStatus = nupst.getUpdateStatus();
const boxWidth = 45;
logger.logBoxTitle('Update Available', boxWidth);
logger.logBoxLine(`Current Version: ${updateStatus.currentVersion}`);
logger.logBoxLine(`Latest Version: ${updateStatus.latestVersion}`);
logger.logBoxLine('Run "sudo nupst update" to update');
logger.logBoxEnd();
}
}).catch(() => {}); // Ignore errors checking for updates
}
// Initialize UPS status tracking
this.initializeUpsStatus();
@@ -441,7 +445,6 @@ export class NupstDaemon {
}
let lastLogTime = 0; // Track when we last logged status
const LOG_INTERVAL = 5 * 60 * 1000; // Log at least every 5 minutes (300000ms)
// Monitor continuously
while (this.isRunning) {
@@ -451,7 +454,7 @@ export class NupstDaemon {
// Log periodic status update
const currentTime = Date.now();
if (currentTime - lastLogTime >= LOG_INTERVAL) {
if (currentTime - lastLogTime >= TIMING.LOG_INTERVAL_MS) {
this.logAllUpsStatus();
lastLogTime = currentTime;
}
@@ -789,21 +792,18 @@ export class NupstDaemon {
* Force immediate shutdown if any UPS gets critically low
*/
private async monitorDuringShutdown(): Promise<void> {
const EMERGENCY_RUNTIME_THRESHOLD = 5; // 5 minutes remaining is critical
const CHECK_INTERVAL = 30000; // Check every 30 seconds during shutdown
const MAX_MONITORING_TIME = 5 * 60 * 1000; // Max 5 minutes of monitoring
const startTime = Date.now();
logger.log('');
logger.logBoxTitle('Shutdown Monitoring Active', 60, 'warning');
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes runtime`);
logger.logBoxLine(`Check interval: ${CHECK_INTERVAL / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${MAX_MONITORING_TIME / 1000} seconds`);
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`);
logger.logBoxLine(`Check interval: ${TIMING.SHUTDOWN_CHECK_INTERVAL_MS / 1000} seconds`);
logger.logBoxLine(`Max monitoring time: ${TIMING.MAX_SHUTDOWN_MONITORING_MS / 1000} seconds`);
logger.logBoxEnd();
logger.log('');
// Continue monitoring until max monitoring time is reached
while (Date.now() - startTime < MAX_MONITORING_TIME) {
while (Date.now() - startTime < TIMING.MAX_SHUTDOWN_MONITORING_MS) {
try {
logger.info('Checking UPS status during shutdown...');
@@ -827,7 +827,7 @@ export class NupstDaemon {
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
const isCritical = status.batteryRuntime < EMERGENCY_RUNTIME_THRESHOLD;
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
rows.push({
name: ups.name,
@@ -868,7 +868,7 @@ export class NupstDaemon {
logger.logBoxLine(
`UPS ${emergencyUps.ups.name} runtime critically low: ${emergencyUps.status.batteryRuntime} minutes`,
);
logger.logBoxLine(`Emergency threshold: ${EMERGENCY_RUNTIME_THRESHOLD} minutes`);
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes`);
logger.logBoxLine('Forcing immediate shutdown!');
logger.logBoxEnd();
logger.log('');
@@ -879,14 +879,14 @@ export class NupstDaemon {
}
// Wait before checking again
await this.sleep(CHECK_INTERVAL);
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
} catch (error) {
logger.error(
`Error monitoring UPS during shutdown: ${
error instanceof Error ? error.message : String(error)
}`,
);
await this.sleep(CHECK_INTERVAL);
await this.sleep(TIMING.SHUTDOWN_CHECK_INTERVAL_MS);
}
}
@@ -988,12 +988,10 @@ export class NupstDaemon {
* Watches for config changes and reloads when detected
*/
private async idleMonitoring(): Promise<void> {
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
let lastConfigCheck = Date.now();
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
logger.log('Entering idle monitoring mode...');
logger.log('Daemon will check for config changes every 60 seconds');
logger.log(`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} seconds`);
// Start file watcher for hot-reload
this.watchConfigFile();
@@ -1003,7 +1001,7 @@ export class NupstDaemon {
const currentTime = Date.now();
// Periodically check if config has been updated
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) {
if (currentTime - lastConfigCheck >= TIMING.CONFIG_CHECK_INTERVAL_MS) {
try {
// Try to load config
const newConfig = await this.loadConfig();
@@ -1023,12 +1021,12 @@ export class NupstDaemon {
lastConfigCheck = currentTime;
}
await this.sleep(IDLE_CHECK_INTERVAL);
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
} catch (error) {
logger.error(
`Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`,
);
await this.sleep(IDLE_CHECK_INTERVAL);
await this.sleep(TIMING.IDLE_CHECK_INTERVAL_MS);
}
}

View File

@@ -1 +1,2 @@
export * from './shortid.ts';
export * from './prompt.ts';

55
ts/helpers/prompt.ts Normal file
View File

@@ -0,0 +1,55 @@
import process from 'node:process';
/**
* Result from creating a prompt interface
*/
export interface IPromptInterface {
/** Function to prompt for user input */
prompt: (question: string) => Promise<string>;
/** Function to close the prompt interface */
close: () => void;
}
/**
* Create a readline prompt interface for interactive CLI input
* @returns Promise resolving to prompt function and close function
*/
export async function createPrompt(): Promise<IPromptInterface> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const prompt = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer);
});
});
};
const close = (): void => {
rl.close();
process.stdin.destroy();
};
return { prompt, close };
}
/**
* Run an async function with a prompt interface, ensuring cleanup
* @param fn Function to run with the prompt interface
* @returns Promise resolving to the function's return value
*/
export async function withPrompt<T>(
fn: (prompt: (question: string) => Promise<string>) => Promise<T>,
): Promise<T> {
const { prompt, close } = await createPrompt();
try {
return await fn(prompt);
} finally {
close();
}
}

1
ts/interfaces/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './nupst-accessor.ts';

View File

@@ -0,0 +1,41 @@
import type { NupstDaemon } from '../daemon.ts';
/**
* Update status information
*/
export interface IUpdateStatus {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
}
/**
* Interface for accessing Nupst functionality from SNMP manager
* This breaks the circular dependency between Nupst and NupstSnmp
*/
export interface INupstAccessor {
/**
* Get the daemon manager for background monitoring
*/
getDaemon(): NupstDaemon;
/**
* Get the current version of NUPST
*/
getVersion(): string;
/**
* Check if an update is available
*/
checkForUpdates(): Promise<boolean>;
/**
* Get update status information
*/
getUpdateStatus(): IUpdateStatus;
/**
* Log the current version and update status
*/
logVersionInfo(checkForUpdates?: boolean): void;
}

View File

@@ -9,12 +9,13 @@ import { ServiceHandler } from './cli/service-handler.ts';
import { ActionHandler } from './cli/action-handler.ts';
import { FeatureHandler } from './cli/feature-handler.ts';
import * as https from 'node:https';
import type { INupstAccessor, IUpdateStatus } from './interfaces/index.ts';
/**
* Main Nupst class that coordinates all components
* Acts as a facade to access SNMP, Daemon, and Systemd functionality
*/
export class Nupst {
export class Nupst implements INupstAccessor {
private readonly snmp: NupstSnmp;
private readonly daemon: NupstDaemon;
private readonly systemd: NupstSystemd;
@@ -134,11 +135,7 @@ export class Nupst {
* Get update status information
* @returns Object with update status information
*/
public getUpdateStatus(): {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
} {
public getUpdateStatus(): IUpdateStatus {
return {
currentVersion: this.getVersion(),
latestVersion: this.latestVersion || this.getVersion(),

View File

@@ -2,6 +2,9 @@ import * as snmp from 'npm:net-snmp@3.26.0';
import { Buffer } from 'node:buffer';
import type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts';
import { UpsOidSets } from './oid-sets.ts';
import { SNMP } from '../constants.ts';
import { logger } from '../logger.ts';
import type { INupstAccessor } from '../interfaces/index.ts';
/**
* Class for SNMP communication with UPS devices
@@ -10,18 +13,18 @@ import { UpsOidSets } from './oid-sets.ts';
export class NupstSnmp {
// Active OID set
private activeOIDs: IOidSet;
// Reference to the parent Nupst instance
private nupst: any; // Type 'any' to avoid circular dependency
// Reference to the parent Nupst instance (uses interface to avoid circular dependency)
private nupst: INupstAccessor | null = null;
// Debug mode flag
private debug: boolean = false;
// Default SNMP configuration
private readonly DEFAULT_CONFIG: ISnmpConfig = {
host: '127.0.0.1', // Default to localhost
port: 161, // Default SNMP port
port: SNMP.DEFAULT_PORT, // Default SNMP port
community: 'public', // Default community string for v1/v2c
version: 1, // SNMPv1
timeout: 5000, // 5 seconds timeout
timeout: SNMP.DEFAULT_TIMEOUT_MS, // 5 seconds timeout
upsModel: 'cyberpower', // Default UPS model
};
@@ -39,14 +42,14 @@ export class NupstSnmp {
* Set reference to the main Nupst instance
* @param nupst Reference to the main Nupst instance
*/
public setNupst(nupst: any): void {
public setNupst(nupst: INupstAccessor): void {
this.nupst = nupst;
}
/**
* Get reference to the main Nupst instance
*/
public getNupst(): any {
public getNupst(): INupstAccessor | null {
return this.nupst;
}
@@ -55,7 +58,7 @@ export class NupstSnmp {
*/
public enableDebug(): void {
this.debug = true;
console.log('SNMP debug mode enabled - detailed logs will be shown');
logger.info('SNMP debug mode enabled - detailed logs will be shown');
}
/**
@@ -67,7 +70,7 @@ export class NupstSnmp {
if (config.upsModel === 'custom' && config.customOIDs) {
this.activeOIDs = config.customOIDs;
if (this.debug) {
console.log('Using custom OIDs:', this.activeOIDs);
logger.dim(`Using custom OIDs: ${JSON.stringify(this.activeOIDs)}`);
}
return;
}
@@ -77,7 +80,7 @@ export class NupstSnmp {
this.activeOIDs = UpsOidSets.getOidSet(model);
if (this.debug) {
console.log(`Using OIDs for UPS model: ${model}`);
logger.dim(`Using OIDs for UPS model: ${model}`);
}
}
@@ -95,16 +98,16 @@ export class NupstSnmp {
): Promise<any> {
return new Promise((resolve, reject) => {
if (this.debug) {
console.log(
logger.dim(
`Sending SNMP v${config.version} GET request for OID ${oid} to ${config.host}:${config.port}`,
);
console.log('Using community:', config.community);
logger.dim(`Using community: ${config.community}`);
}
// Create SNMP options based on configuration
const options: any = {
port: config.port,
retries: 2, // Number of retries
retries: SNMP.RETRIES, // Number of retries
timeout: config.timeout,
transport: 'udp4',
idBitsSize: 32,
@@ -151,7 +154,7 @@ export class NupstSnmp {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
} else if (securityLevel === 'authPriv') {
@@ -178,29 +181,23 @@ export class NupstSnmp {
// Fallback to authNoPriv if priv details missing
user.level = snmp.SecurityLevel.authNoPriv;
if (this.debug) {
console.log('Warning: Missing privProtocol or privKey, falling back to authNoPriv');
logger.warn('Missing privProtocol or privKey, falling back to authNoPriv');
}
}
} else {
// Fallback to noAuthNoPriv if auth details missing
user.level = snmp.SecurityLevel.noAuthNoPriv;
if (this.debug) {
console.log('Warning: Missing authProtocol or authKey, falling back to noAuthNoPriv');
logger.warn('Missing authProtocol or authKey, falling back to noAuthNoPriv');
}
}
}
if (this.debug) {
console.log('SNMPv3 user configuration:', {
name: user.name,
level: Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
),
authProtocol: user.authProtocol ? 'Set' : 'Not Set',
authKey: user.authKey ? 'Set' : 'Not Set',
privProtocol: user.privProtocol ? 'Set' : 'Not Set',
privKey: user.privKey ? 'Set' : 'Not Set',
});
const levelName = Object.keys(snmp.SecurityLevel).find((key) =>
snmp.SecurityLevel[key] === user.level
);
logger.dim(`SNMPv3 user configuration: name=${user.name}, level=${levelName}, authProtocol=${user.authProtocol ? 'Set' : 'Not Set'}, privProtocol=${user.privProtocol ? 'Set' : 'Not Set'}`);
}
session = snmp.createV3Session(config.host, user, options);
@@ -219,7 +216,7 @@ export class NupstSnmp {
if (error) {
if (this.debug) {
console.error('SNMP GET error:', error);
logger.error(`SNMP GET error: ${error}`);
}
reject(new Error(`SNMP GET error: ${error.message || error}`));
return;
@@ -227,7 +224,7 @@ export class NupstSnmp {
if (!varbinds || varbinds.length === 0) {
if (this.debug) {
console.error('No varbinds returned in response');
logger.error('No varbinds returned in response');
}
reject(new Error('No varbinds returned in response'));
return;
@@ -240,7 +237,7 @@ export class NupstSnmp {
varbinds[0].type === snmp.ObjectType.EndOfMibView
) {
if (this.debug) {
console.error('SNMP error:', snmp.ObjectType[varbinds[0].type]);
logger.error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`);
}
reject(new Error(`SNMP error: ${snmp.ObjectType[varbinds[0].type]}`));
return;
@@ -262,11 +259,7 @@ export class NupstSnmp {
}
if (this.debug) {
console.log('SNMP response:', {
oid: varbinds[0].oid,
type: varbinds[0].type,
value: value,
});
logger.dim(`SNMP response: oid=${varbinds[0].oid}, type=${varbinds[0].type}, value=${value}`);
}
resolve(value);
@@ -285,30 +278,30 @@ export class NupstSnmp {
this.setActiveOIDs(config);
if (this.debug) {
console.log('---------------------------------------');
console.log('Getting UPS status with config:');
console.log(' Host:', config.host);
console.log(' Port:', config.port);
console.log(' Version:', config.version);
console.log(' Timeout:', config.timeout, 'ms');
console.log(' UPS Model:', config.upsModel || 'cyberpower');
logger.dim('---------------------------------------');
logger.dim('Getting UPS status with config:');
logger.dim(` Host: ${config.host}`);
logger.dim(` Port: ${config.port}`);
logger.dim(` Version: ${config.version}`);
logger.dim(` Timeout: ${config.timeout} ms`);
logger.dim(` UPS Model: ${config.upsModel || 'cyberpower'}`);
if (config.version === 1 || config.version === 2) {
console.log(' Community:', config.community);
logger.dim(` Community: ${config.community}`);
} else if (config.version === 3) {
console.log(' Security Level:', config.securityLevel);
console.log(' Username:', config.username);
console.log(' Auth Protocol:', config.authProtocol || 'None');
console.log(' Privacy Protocol:', config.privProtocol || 'None');
logger.dim(` Security Level: ${config.securityLevel}`);
logger.dim(` Username: ${config.username}`);
logger.dim(` Auth Protocol: ${config.authProtocol || 'None'}`);
logger.dim(` Privacy Protocol: ${config.privProtocol || 'None'}`);
}
console.log('Using OIDs:');
console.log(' Power Status:', this.activeOIDs.POWER_STATUS);
console.log(' Battery Capacity:', this.activeOIDs.BATTERY_CAPACITY);
console.log(' Battery Runtime:', this.activeOIDs.BATTERY_RUNTIME);
console.log(' Output Load:', this.activeOIDs.OUTPUT_LOAD);
console.log(' Output Power:', this.activeOIDs.OUTPUT_POWER);
console.log(' Output Voltage:', this.activeOIDs.OUTPUT_VOLTAGE);
console.log(' Output Current:', this.activeOIDs.OUTPUT_CURRENT);
console.log('---------------------------------------');
logger.dim('Using OIDs:');
logger.dim(` Power Status: ${this.activeOIDs.POWER_STATUS}`);
logger.dim(` Battery Capacity: ${this.activeOIDs.BATTERY_CAPACITY}`);
logger.dim(` Battery Runtime: ${this.activeOIDs.BATTERY_RUNTIME}`);
logger.dim(` Output Load: ${this.activeOIDs.OUTPUT_LOAD}`);
logger.dim(` Output Power: ${this.activeOIDs.OUTPUT_POWER}`);
logger.dim(` Output Voltage: ${this.activeOIDs.OUTPUT_VOLTAGE}`);
logger.dim(` Output Current: ${this.activeOIDs.OUTPUT_CURRENT}`);
logger.dim('---------------------------------------');
}
// Get all values with independent retry logic
@@ -365,7 +358,7 @@ export class NupstSnmp {
if (outputPower === 0 && processedVoltage > 0 && processedCurrent > 0) {
processedPower = Math.round(processedVoltage * processedCurrent);
if (this.debug) {
console.log(
logger.dim(
`Calculated power from V×I: ${processedVoltage}V × ${processedCurrent}A = ${processedPower}W`,
);
}
@@ -391,27 +384,26 @@ export class NupstSnmp {
};
if (this.debug) {
console.log('---------------------------------------');
console.log('UPS status result:');
console.log(' Power Status:', result.powerStatus);
console.log(' Battery Capacity:', result.batteryCapacity + '%');
console.log(' Battery Runtime:', result.batteryRuntime, 'minutes');
console.log(' Output Load:', result.outputLoad + '%');
console.log(' Output Power:', result.outputPower, 'watts');
console.log(' Output Voltage:', result.outputVoltage, 'volts');
console.log(' Output Current:', result.outputCurrent, 'amps');
console.log('---------------------------------------');
logger.dim('---------------------------------------');
logger.dim('UPS status result:');
logger.dim(` Power Status: ${result.powerStatus}`);
logger.dim(` Battery Capacity: ${result.batteryCapacity}%`);
logger.dim(` Battery Runtime: ${result.batteryRuntime} minutes`);
logger.dim(` Output Load: ${result.outputLoad}%`);
logger.dim(` Output Power: ${result.outputPower} watts`);
logger.dim(` Output Voltage: ${result.outputVoltage} volts`);
logger.dim(` Output Current: ${result.outputCurrent} amps`);
logger.dim('---------------------------------------');
}
return result;
} catch (error) {
if (this.debug) {
console.error('---------------------------------------');
console.error(
'Error getting UPS status:',
error instanceof Error ? error.message : String(error),
logger.error('---------------------------------------');
logger.error(
`Error getting UPS status: ${error instanceof Error ? error.message : String(error)}`,
);
console.error('---------------------------------------');
logger.error('---------------------------------------');
}
throw new Error(
`Failed to get UPS status: ${error instanceof Error ? error.message : String(error)}`,
@@ -433,26 +425,25 @@ export class NupstSnmp {
): Promise<any> {
if (oid === '') {
if (this.debug) {
console.log(`No OID provided for ${description}, skipping`);
logger.dim(`No OID provided for ${description}, skipping`);
}
return 0;
}
if (this.debug) {
console.log(`Getting ${description} OID: ${oid}`);
logger.dim(`Getting ${description} OID: ${oid}`);
}
try {
const value = await this.snmpGet(oid, config);
if (this.debug) {
console.log(`${description} value:`, value);
logger.dim(`${description} value: ${value}`);
}
return value;
} catch (error) {
if (this.debug) {
console.error(
`Error getting ${description}:`,
error instanceof Error ? error.message : String(error),
logger.error(
`Error getting ${description}: ${error instanceof Error ? error.message : String(error)}`,
);
}
@@ -468,7 +459,7 @@ export class NupstSnmp {
// Return a default value if all attempts fail
if (this.debug) {
console.log(`Using default value 0 for ${description}`);
logger.dim(`Using default value 0 for ${description}`);
}
return 0;
}
@@ -487,7 +478,7 @@ export class NupstSnmp {
config: ISnmpConfig,
): Promise<any> {
if (this.debug) {
console.log(`Retrying ${description} with fallback security level...`);
logger.dim(`Retrying ${description} with fallback security level...`);
}
// Try with authNoPriv if current level is authPriv
@@ -495,18 +486,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
try {
if (this.debug) {
console.log(`Retrying with authNoPriv security level`);
logger.dim(`Retrying with authNoPriv security level`);
}
const value = await this.snmpGet(oid, retryConfig);
if (this.debug) {
console.log(`${description} retry value:`, value);
logger.dim(`${description} retry value: ${value}`);
}
return value;
} catch (retryError) {
if (this.debug) {
console.error(
`Retry failed for ${description}:`,
retryError instanceof Error ? retryError.message : String(retryError),
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
);
}
}
@@ -517,18 +507,17 @@ export class NupstSnmp {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
try {
if (this.debug) {
console.log(`Retrying with noAuthNoPriv security level`);
logger.dim(`Retrying with noAuthNoPriv security level`);
}
const value = await this.snmpGet(oid, retryConfig);
if (this.debug) {
console.log(`${description} retry value:`, value);
logger.dim(`${description} retry value: ${value}`);
}
return value;
} catch (retryError) {
if (this.debug) {
console.error(
`Retry failed for ${description}:`,
retryError instanceof Error ? retryError.message : String(retryError),
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
);
}
}
@@ -554,21 +543,20 @@ export class NupstSnmp {
const standardOIDs = UpsOidSets.getStandardOids();
if (this.debug) {
console.log(
logger.dim(
`Trying standard RFC 1628 OID for ${description}: ${standardOIDs[description]}`,
);
}
const standardValue = await this.snmpGet(standardOIDs[description], config);
if (this.debug) {
console.log(`${description} standard OID value:`, standardValue);
logger.dim(`${description} standard OID value: ${standardValue}`);
}
return standardValue;
} catch (stdError) {
if (this.debug) {
console.error(
`Standard OID retry failed for ${description}:`,
stdError instanceof Error ? stdError.message : String(stdError),
logger.error(
`Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`,
);
}
}
@@ -623,14 +611,14 @@ export class NupstSnmp {
batteryRuntime: number,
): number {
if (this.debug) {
console.log('Raw runtime value:', batteryRuntime);
logger.dim(`Raw runtime value: ${batteryRuntime}`);
}
if (upsModel === 'cyberpower' && batteryRuntime > 0) {
// CyberPower: TimeTicks is in 1/100 seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 6000); // 6000 ticks = 1 minute
if (this.debug) {
console.log(
logger.dim(
`Converting CyberPower runtime from ${batteryRuntime} ticks to ${minutes} minutes`,
);
}
@@ -639,7 +627,7 @@ export class NupstSnmp {
// Eaton: Runtime is in seconds, convert to minutes
const minutes = Math.floor(batteryRuntime / 60);
if (this.debug) {
console.log(
logger.dim(
`Converting Eaton runtime from ${batteryRuntime} seconds to ${minutes} minutes`,
);
}
@@ -648,7 +636,7 @@ export class NupstSnmp {
// Generic conversion for large tick values (likely TimeTicks)
const minutes = Math.floor(batteryRuntime / 6000);
if (this.debug) {
console.log(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
logger.dim(`Converting ${batteryRuntime} ticks to ${minutes} minutes`);
}
return minutes;
}
@@ -667,14 +655,14 @@ export class NupstSnmp {
outputVoltage: number,
): number {
if (this.debug) {
console.log('Raw voltage value:', outputVoltage);
logger.dim(`Raw voltage value: ${outputVoltage}`);
}
if (upsModel === 'cyberpower' && outputVoltage > 0) {
// CyberPower: Voltage is in 0.1V, convert to volts
const volts = outputVoltage / 10;
if (this.debug) {
console.log(
logger.dim(
`Converting CyberPower voltage from ${outputVoltage} (0.1V) to ${volts} volts`,
);
}
@@ -695,14 +683,14 @@ export class NupstSnmp {
outputCurrent: number,
): number {
if (this.debug) {
console.log('Raw current value:', outputCurrent);
logger.dim(`Raw current value: ${outputCurrent}`);
}
if (upsModel === 'cyberpower' && outputCurrent > 0) {
// CyberPower: Current is in 0.1A, convert to amps
const amps = outputCurrent / 10;
if (this.debug) {
console.log(
logger.dim(
`Converting CyberPower current from ${outputCurrent} (0.1A) to ${amps} amps`,
);
}
@@ -711,7 +699,7 @@ export class NupstSnmp {
// RFC 1628 standard: Current is in 0.1A, convert to amps
const amps = outputCurrent / 10;
if (this.debug) {
console.log(
logger.dim(
`Converting RFC 1628 current from ${outputCurrent} (0.1A) to ${amps} amps`,
);
}
@@ -720,7 +708,7 @@ export class NupstSnmp {
// Eaton XUPS-MIB and APC PowerNet report current directly in RMS Amps (no scaling needed)
if ((upsModel === 'eaton' || upsModel === 'apc') && this.debug && outputCurrent > 0) {
console.log(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
logger.dim(`${upsModel.toUpperCase()} current already in RMS Amps: ${outputCurrent}A`);
}
return outputCurrent;