Compare commits

...

6 Commits

Author SHA1 Message Date
782c8c9555 v5.2.4
Some checks failed
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
Publish to npm / npm-publish (push) Failing after 18s
Release / build-and-release (push) Successful in 53s
CI / Build All Platforms (push) Successful in 57s
2026-01-29 17:53:08 +00:00
463c32ebba fix(): no changes 2026-01-29 17:53:08 +00:00
51aa68ff8d v5.2.3
Some checks failed
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 8s
Publish to npm / npm-publish (push) Failing after 19s
CI / Build All Platforms (push) Successful in 1m0s
Release / build-and-release (push) Successful in 58s
2026-01-29 17:46:23 +00:00
cb34ae5041 fix(core): fix lint/type issues and small refactors 2026-01-29 17:46:23 +00:00
165c7d29bb v5.2.2
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 19s
Release / build-and-release (push) Successful in 54s
CI / Build All Platforms (push) Successful in 59s
2026-01-29 17:10:17 +00:00
ff2dc00f31 fix(core): tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging 2026-01-29 17:10:17 +00:00
33 changed files with 734 additions and 379 deletions

View File

@@ -5,19 +5,23 @@ Pre-compiled binaries for multiple platforms.
### Installation
#### Option 1: Via npm (recommended)
```bash
npm install -g @serve.zone/nupst
```
#### Option 2: Via installer script
```bash
curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash
```
#### Option 3: Direct binary download
Download the appropriate binary for your platform from the assets below and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
@@ -25,7 +29,9 @@ Download the appropriate binary for your platform from the assets below and make
- Windows x86_64
### Checksums
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
### npm Package
The npm package includes automatic binary detection and installation for your platform.

View File

@@ -9,7 +9,8 @@ import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
import { platform, arch } from 'os';
import { arch, platform } from 'os';
import process from "node:process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -25,12 +26,12 @@ function getBinaryName() {
const platformMap = {
'darwin': 'macos',
'linux': 'linux',
'win32': 'windows'
'win32': 'windows',
};
const archMap = {
'x64': 'x64',
'arm64': 'arm64'
'arm64': 'arm64',
};
const mappedPlatform = platformMap[plat];
@@ -76,7 +77,7 @@ function executeBinary() {
// Spawn the binary with all arguments passed through
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
shell: false
shell: false,
});
// Handle child process events
@@ -95,7 +96,7 @@ function executeBinary() {
// Forward signals to child process
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
signals.forEach(signal => {
signals.forEach((signal) => {
process.on(signal, () => {
if (!child.killed) {
child.kill(signal);
@@ -105,4 +106,4 @@ function executeBinary() {
}
// Execute
executeBinary();
executeBinary();

View File

@@ -1,69 +1,131 @@
# Changelog
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)
add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version display
## 2026-01-29 - 5.2.4 - fix()
no changes
- Introduce a type guard ('id' in config && 'name' in config) to distinguish IUpsConfig from legacy INupstConfig and route fields (snmp, checkInterval, name, id) accordingly.
- displayTestConfig now handles missing SNMP by logging 'Not configured' and returning, computes checkInterval/upsName/upsId correctly, and uses groups only for true UPS configs.
- testConnection now safely derives snmpConfig for both config types, throws if SNMP is missing, and caps test timeout to 10s for probes.
- Clear auth/priv credentials by setting undefined (instead of empty strings) when disabling security levels to avoid invalid/empty string values.
- Expanded customOIDs to include OUTPUT_LOAD, OUTPUT_POWER, OUTPUT_VOLTAGE, OUTPUT_CURRENT with defaults; trim prompt input and document RFC 1628 fallbacks.
- systemd.displayVersionInfo: guard against missing nupst (silent return) and avoid errors when printing version info; use ignored catch variables for clarity.
- No files changed in the provided git diff; no commit or version bump required.
## 2026-01-29 - 5.2.3 - fix(core)
fix lint/type issues and small refactors
- Add missing node:process imports in bin and scripts to ensure process is available
- Remove unused imports and unused type imports (e.g. writeFileSync, IActionConfig) to reduce noise
- Make some methods synchronous (service update, webhook call) to match actual usage
- Tighten SNMP typings and linting: added deno-lint-ignore comments, renamed unused params with leading underscore, and use `as const` for securityLevel fallbacks
- Improve error handling variable naming in systemd (use error instead of _error)
- Annotate ANSI regex with deno-lint-ignore no-control-regex and remove unused color/symbol imports across CLI/daemon/logger
## 2026-01-29 - 5.2.2 - fix(core)
tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging
- Normalize import ordering and improve logger/string formatting across many CLI handlers, daemon, systemd, actions and tests
- Apply formatting tidies: trailing commas, newline fixes, and more consistent multiline strings
- Allow BaseMigration methods to return either sync or async results (shouldRun/migrate signatures updated)
- Improve SNMP manager and HTTP server logging/error messages and tighten some typings (raw SNMP types, server error typing)
- Small robustness and messaging improvements in npm installer and wrapper (platform/arch mapping, error outputs)
- Update tests and documentation layout/formatting for readability
## 2026-01-29 - 5.2.1 - fix(cli(ups-handler), systemd)
add type guards and null checks for UPS configs; improve SNMP handling and prompts; guard version
display
- Introduce a type guard ('id' in config && 'name' in config) to distinguish IUpsConfig from legacy
INupstConfig and route fields (snmp, checkInterval, name, id) accordingly.
- displayTestConfig now handles missing SNMP by logging 'Not configured' and returning, computes
checkInterval/upsName/upsId correctly, and uses groups only for true UPS configs.
- testConnection now safely derives snmpConfig for both config types, throws if SNMP is missing, and
caps test timeout to 10s for probes.
- Clear auth/priv credentials by setting undefined (instead of empty strings) when disabling
security levels to avoid invalid/empty string values.
- Expanded customOIDs to include OUTPUT_LOAD, OUTPUT_POWER, OUTPUT_VOLTAGE, OUTPUT_CURRENT with
defaults; trim prompt input and document RFC 1628 fallbacks.
- systemd.displayVersionInfo: guard against missing nupst (silent return) and avoid errors when
printing version info; use ignored catch variables for clarity.
## 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
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.
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)
- 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
- 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
- Add a 'build' script to package.json (no-op placeholder) to provide an explicit build step
- Minor scripts section formatting tidy in package.json
- Add a hidden local settings file for development tooling permissions to the repository (local-only configuration)
- Add a hidden local settings file for development tooling permissions to the repository (local-only
configuration)
## 2025-10-23 - 5.1.1 - fix(tooling)
Add .claude/settings.local.json with local automation permissions
- Add .claude/settings.local.json to specify allowed permissions for local automated tasks
- Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers)
- Grants permissions for various developer/CI actions (deno check/lint/fmt, npm/npm pack, selective
Bash commands, WebFetch to docs.deno.com and code.foss.global, and file/read/replace helpers)
- This is a developer/local tooling config only and does not change runtime code or package behavior
## 2025-10-22 - 5.1.0 - feat(packaging)
Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging files
Add npm packaging and installer: wrapper, postinstall downloader, publish workflow, and packaging
files
- Add package.json (v5.0.5) and npm packaging metadata to publish @serve.zone/nupst
- Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled binaries
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the current platform and sets executable permissions
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and publish to npm, and create releases
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer and wrapper
- Move example action script into docs (docs/example-action.sh) and remove the top-level example-action.sh
- Include a small Node.js wrapper (bin/nupst-wrapper.js) to execute platform-specific precompiled
binaries
- Add postinstall script (scripts/install-binary.js) that downloads the correct binary for the
current platform and sets executable permissions
- Add GitHub Actions workflow (.github/workflows/npm-publish.yml) to build binaries, pack and
publish to npm, and create releases
- Add .npmignore to keep source, tests and dev files out of npm package; keep only runtime installer
and wrapper
- Move example action script into docs (docs/example-action.sh) and remove the top-level
example-action.sh
- Include generated npm package artifact (serve.zone-nupst-5.0.5.tgz) and npmextra.json
## 2025-10-18 - 4.0.0 - BREAKING CHANGE(core): Complete migration to Deno runtime

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "5.2.1",
"version": "5.2.4",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

View File

@@ -17,4 +17,4 @@
}
},
"@ship.zone/szci": {}
}
}

View File

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

View File

@@ -7,7 +7,8 @@
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`
- 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)
@@ -21,7 +22,8 @@
### 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`
- 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`)**
@@ -30,11 +32,13 @@
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`
- Uses: `IUpsConfig`, `INupstConfig`, `ISnmpConfig`, `IActionConfig`, `IThresholds`,
`ISnmpUpsStatus`
## Architecture Notes
- **SNMP Manager**: Uses `INupstAccessor` interface (not direct `Nupst` reference) to avoid circular imports
- **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

View File

@@ -10,7 +10,11 @@ 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.
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
@@ -131,7 +135,8 @@ Alternatively, NUPST can be installed via npm:
npm install -g @serve.zone/nupst
```
**Note:** This method downloads the appropriate pre-compiled binary for your platform during installation.
**Note:** This method downloads the appropriate pre-compiled binary for your platform during
installation.
### Verify Installation
@@ -474,18 +479,18 @@ Actions define automated responses to UPS conditions:
**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 |
| 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 |
| Field | Description | Values |
| --------------- | -------------------------------- | ------------------- |
| `scriptPath` | Script filename in `/etc/nupst/` | Must end with `.sh` |
| `scriptTimeout` | Execution timeout in ms | Default: 60000 |
**Trigger Modes:**
@@ -874,7 +879,8 @@ 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 older formats to the current version.
Your v3.x configuration is **fully compatible**. The migration system automatically converts older
formats to the current version.
## 💻 Development
@@ -927,21 +933,31 @@ nupst/
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
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 or third parties, and are not included within the scope of the MIT license granted herein.
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.
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 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,18 +1,20 @@
#!/usr/bin/env node
// deno-lint-ignore-file no-unused-vars
/**
* NUPST npm postinstall script
* Downloads the appropriate binary for the current platform from GitHub releases
*/
import { platform, arch } from 'os';
import { existsSync, mkdirSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
import { join, dirname } from 'path';
import { arch, platform } from 'os';
import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import https from 'https';
import { pipeline } from 'stream';
import { promisify } from 'util';
import { createWriteStream } from 'fs';
import process from "node:process";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -29,12 +31,12 @@ function getBinaryInfo() {
const platformMap = {
'darwin': 'macos',
'linux': 'linux',
'win32': 'windows'
'win32': 'windows',
};
const archMap = {
'x64': 'x64',
'arm64': 'arm64'
'arm64': 'arm64',
};
const mappedPlatform = platformMap[plat];
@@ -54,7 +56,7 @@ function getBinaryInfo() {
platform: mappedPlatform,
arch: mappedArch,
binaryName,
originalPlatform: plat
originalPlatform: plat,
};
}
@@ -122,7 +124,9 @@ async function main() {
const binaryInfo = getBinaryInfo();
if (!binaryInfo.supported) {
console.error(`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`);
console.error(
`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`,
);
console.error('');
console.error('Supported platforms:');
console.error(' • Linux (x64, arm64)');
@@ -185,7 +189,9 @@ async function main() {
console.error('You can try:');
console.error('1. Installing from source: https://code.foss.global/serve.zone/nupst');
console.error('2. Downloading the binary manually from the releases page');
console.error('3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash');
console.error(
'3. Using the install script: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash',
);
// Clean up partial download
if (existsSync(binaryPath)) {
@@ -225,7 +231,7 @@ async function main() {
}
// Run the installation
main().catch(err => {
main().catch((err) => {
console.error(`❌ Installation failed: ${err.message}`);
process.exit(1);
});
});

View File

@@ -1,6 +1,7 @@
# Manual Docker Testing Scripts
This directory contains scripts for manually testing NUPST installation and migration in Docker containers with systemd support.
This directory contains scripts for manually testing NUPST installation and migration in Docker
containers with systemd support.
## Prerequisites
@@ -15,12 +16,14 @@ This directory contains scripts for manually testing NUPST installation and migr
Creates a Docker container with systemd and installs NUPST v3.
**What it does:**
- Creates Ubuntu 22.04 container with systemd enabled
- Installs NUPST v3 from commit `806f81c6` (last v3 version)
- Enables and starts the systemd service
- Leaves container running for testing
**Usage:**
```bash
chmod +x 01-setup-v3-container.sh
./01-setup-v3-container.sh
@@ -33,6 +36,7 @@ chmod +x 01-setup-v3-container.sh
Tests the migration from v3 to v4.
**What it does:**
- Checks current v3 installation
- Pulls v4 code from `migration/deno-v4` branch
- Runs install.sh (should auto-detect and migrate)
@@ -40,6 +44,7 @@ Tests the migration from v3 to v4.
- Tests basic commands
**Usage:**
```bash
chmod +x 02-test-v3-to-v4-migration.sh
./02-test-v3-to-v4-migration.sh
@@ -52,6 +57,7 @@ chmod +x 02-test-v3-to-v4-migration.sh
Removes the test container.
**Usage:**
```bash
chmod +x 03-cleanup.sh
./03-cleanup.sh
@@ -134,16 +140,19 @@ docker rm -f nupst-test-v3
## Troubleshooting
### Container won't start
- Ensure Docker daemon is running
- Check you have privileged access
- Try: `docker logs nupst-test-v3`
### Systemd not working in container
- Requires Linux host (not macOS/Windows)
- Needs `--privileged` and cgroup volume mounts
- Check: `docker exec nupst-test-v3 systemctl --version`
### Migration fails
- Check logs: `docker exec nupst-test-v3 journalctl -xe`
- Verify install.sh ran: `docker exec nupst-test-v3 ls -la /opt/nupst/`
- Check service: `docker exec nupst-test-v3 systemctl status nupst`

View File

@@ -5,8 +5,8 @@
* Run with: deno run --allow-all test/showcase.ts
*/
import { logger, type ITableColumn } from '../ts/logger.ts';
import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts';
import { type ITableColumn, logger } from '../ts/logger.ts';
import { formatPowerStatus, getBatteryColor, symbols, theme } from '../ts/colors.ts';
console.log('');
console.log('═'.repeat(80));
@@ -38,31 +38,51 @@ logger.logBoxEnd();
console.log('');
logger.logBox('Success Box (Green)', [
'Used for successful operations',
'Installation complete, service started, etc.',
], 60, 'success');
logger.logBox(
'Success Box (Green)',
[
'Used for successful operations',
'Installation complete, service started, etc.',
],
60,
'success',
);
console.log('');
logger.logBox('Error Box (Red)', [
'Used for critical errors and failures',
'Configuration errors, connection failures, etc.',
], 60, 'error');
logger.logBox(
'Error Box (Red)',
[
'Used for critical errors and failures',
'Configuration errors, connection failures, etc.',
],
60,
'error',
);
console.log('');
logger.logBox('Warning Box (Yellow)', [
'Used for warnings and deprecations',
'Old command format, missing config, etc.',
], 60, 'warning');
logger.logBox(
'Warning Box (Yellow)',
[
'Used for warnings and deprecations',
'Old command format, missing config, etc.',
],
60,
'warning',
);
console.log('');
logger.logBox('Info Box (Cyan)', [
'Used for informational messages',
'Version info, update available, etc.',
], 60, 'info');
logger.logBox(
'Info Box (Cyan)',
[
'Used for informational messages',
'Version info, update available, etc.',
],
60,
'info',
);
console.log('');
@@ -112,15 +132,24 @@ const upsColumns: ITableColumn[] = [
{ header: 'ID', key: 'id' },
{ header: 'Name', key: 'name' },
{ header: 'Host', key: 'host' },
{ header: 'Status', key: 'status', color: (v) => {
if (v.includes('Online')) return theme.success(v);
if (v.includes('Battery')) return theme.warning(v);
return theme.dim(v);
}},
{ header: 'Battery', key: 'battery', align: 'right', color: (v) => {
const pct = parseInt(v);
return getBatteryColor(pct)(v);
}},
{
header: 'Status',
key: 'status',
color: (v) => {
if (v.includes('Online')) return theme.success(v);
if (v.includes('Battery')) return theme.warning(v);
return theme.dim(v);
},
},
{
header: 'Battery',
key: 'battery',
align: 'right',
color: (v) => {
const pct = parseInt(v);
return getBatteryColor(pct)(v);
},
},
{ header: 'Runtime', key: 'runtime', align: 'right' },
];

View File

@@ -1,9 +1,9 @@
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
import { NupstSnmp } from '../ts/snmp/manager.ts';
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
import type { ISnmpConfig, TUpsModel, IOidSet } from '../ts/snmp/types.ts';
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
import { shortId } from '../ts/helpers/shortid.ts';
import { TIMING, SNMP, THRESHOLDS, HTTP_SERVER, UI } from '../ts/constants.ts';
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, 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';
@@ -56,8 +56,14 @@ Deno.test('SNMP constants: port is 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');
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', () => {
@@ -92,21 +98,21 @@ Deno.test('UpsOidSets: all models have OID sets', () => {
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')) {
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}`
`${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')) {
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`);
@@ -200,11 +206,11 @@ Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => {
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
true,
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
false
false,
);
});
@@ -218,13 +224,13 @@ Deno.test('Action.shouldExecute: onlyThresholds mode', () => {
// Below thresholds - should execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })),
true
true,
);
// Above thresholds - should not execute
assertEquals(
action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })),
false
false,
);
});
@@ -248,7 +254,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
// Power change - should execute
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
true,
);
// Threshold violation - should execute
@@ -257,7 +263,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
triggerReason: 'thresholdViolation',
batteryCapacity: 50,
})),
true
true,
);
// No power change and above thresholds - should not execute
@@ -267,7 +273,7 @@ Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () =
batteryCapacity: 100,
batteryRuntime: 60,
})),
false
false,
);
});
@@ -279,11 +285,11 @@ Deno.test('Action.shouldExecute: anyChange mode always returns true', () => {
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })),
true
true,
);
assertEquals(
action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })),
true
true,
);
});
@@ -339,7 +345,7 @@ async function testUpsConnection(
assertExists(status, 'Status should exist');
assert(
['online', 'onBattery', 'unknown'].includes(status.powerStatus),
`Power status should be valid: ${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');
@@ -347,9 +353,12 @@ async function testUpsConnection(
// Validate ranges
assert(
status.batteryCapacity >= 0 && status.batteryCapacity <= 100,
`Battery capacity should be 0-100: ${status.batteryCapacity}`
`Battery capacity should be 0-100: ${status.batteryCapacity}`,
);
assert(
status.batteryRuntime >= 0,
`Battery runtime should be non-negative: ${status.batteryRuntime}`,
);
assert(status.batteryRuntime >= 0, `Battery runtime should be non-negative: ${status.batteryRuntime}`);
}
// Create SNMP instance for integration tests

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/nupst',
version: '5.2.1',
version: '5.2.4',
description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies'
}

View File

@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
import process from 'node:process';
import { exec } 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';
const execAsync = promisify(exec);
@@ -26,7 +26,11 @@ export class ScriptAction extends Action {
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) {
logger.info(`Script action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
logger.info(
`Script action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}

View File

@@ -35,7 +35,9 @@ export class ShutdownAction extends Action {
// 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})`);
logger.info(
`Shutdown action skipped: UPS is not on battery (status: ${context.powerStatus})`,
);
return false;
}
@@ -54,7 +56,9 @@ export class ShutdownAction extends Action {
if (context.triggerReason === 'powerStatusChange') {
// 'onlyThresholds' mode ignores power status changes
if (mode === 'onlyThresholds') {
logger.info('Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change');
logger.info(
'Shutdown action skipped: triggerMode is onlyThresholds, ignoring power change',
);
return false;
}
@@ -71,15 +75,22 @@ export class ShutdownAction extends Action {
// 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)');
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})`);
logger.info(
`Shutdown action skipped: non-emergency transition (${prev}${context.powerStatus})`,
);
return false;
}
@@ -98,7 +109,11 @@ export class ShutdownAction extends Action {
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode and thresholds
if (!this.shouldExecute(context)) {
logger.info(`Shutdown action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
logger.info(
`Shutdown action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}

View File

@@ -1,7 +1,7 @@
import * as http from 'node:http';
import * as https from 'node:https';
import { URL } from 'node:url';
import { Action, type IActionConfig, type IActionContext } from './base-action.ts';
import { Action, type IActionContext } from './base-action.ts';
import { logger } from '../logger.ts';
import { WEBHOOK } from '../constants.ts';
@@ -46,7 +46,11 @@ export class WebhookAction extends Action {
async execute(context: IActionContext): Promise<void> {
// Check if we should execute based on trigger mode
if (!this.shouldExecute(context)) {
logger.info(`Webhook action skipped (trigger mode: ${this.config.triggerMode || 'powerChangesAndThresholds'})`);
logger.info(
`Webhook action skipped (trigger mode: ${
this.config.triggerMode || 'powerChangesAndThresholds'
})`,
);
return;
}
@@ -77,7 +81,7 @@ export class WebhookAction extends Action {
* @param method HTTP method (GET or POST)
* @param timeout Request timeout in milliseconds
*/
private async callWebhook(
private callWebhook(
context: IActionContext,
method: 'GET' | 'POST',
timeout: number,
@@ -109,7 +113,7 @@ export class WebhookAction extends Action {
url.searchParams.append('powerStatus', payload.powerStatus);
url.searchParams.append('batteryCapacity', String(payload.batteryCapacity));
url.searchParams.append('batteryRuntime', String(payload.batteryRuntime));
url.searchParams.append('triggerReason', payload.triggerReason);
url.searchParams.append('timestamp', String(payload.timestamp));
}

187
ts/cli.ts
View File

@@ -1,7 +1,7 @@
import { execSync } from 'node:child_process';
import { Nupst } from './nupst.ts';
import { logger, type ITableColumn } from './logger.ts';
import { theme, symbols } from './colors.ts';
import { type ITableColumn, logger } from './logger.ts';
import { theme } from './colors.ts';
/**
* Class for handling CLI commands
@@ -287,10 +287,15 @@ export class NupstCli {
try {
await this.nupst.getDaemon().loadConfig();
} catch (_error) {
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
logger.logBox(
'Configuration Error',
[
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
],
50,
'error',
);
return;
}
@@ -300,17 +305,22 @@ export class NupstCli {
// Check if multi-UPS config
if (config.upsDevices && Array.isArray(config.upsDevices)) {
// === Multi-UPS Configuration ===
// Overview Box
logger.log('');
logger.logBox('NUPST Configuration', [
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
], 60, 'info');
logger.logBox(
'NUPST Configuration',
[
`UPS Devices: ${theme.highlight(String(config.upsDevices.length))}`,
`Groups: ${theme.highlight(String(config.groups ? config.groups.length : 0))}`,
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
],
60,
'info',
);
// HTTP Server Status (if configured)
if (config.httpServer) {
@@ -319,17 +329,24 @@ export class NupstCli {
: theme.dim('Disabled');
logger.log('');
logger.logBox('HTTP Server', [
`Status: ${serverStatus}`,
...(config.httpServer.enabled ? [
`Port: ${theme.highlight(String(config.httpServer.port))}`,
`Path: ${theme.highlight(config.httpServer.path)}`,
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
'',
theme.dim('Usage:'),
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
] : []),
], 70, config.httpServer.enabled ? 'success' : 'default');
logger.logBox(
'HTTP Server',
[
`Status: ${serverStatus}`,
...(config.httpServer.enabled
? [
`Port: ${theme.highlight(String(config.httpServer.port))}`,
`Path: ${theme.highlight(config.httpServer.path)}`,
`Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`,
'',
theme.dim('Usage:'),
` curl -H "Authorization: Bearer TOKEN" http://localhost:${config.httpServer.port}${config.httpServer.path}`,
]
: []),
],
70,
config.httpServer.enabled ? 'success' : 'default',
);
}
// UPS Devices Table
@@ -369,8 +386,8 @@ export class NupstCli {
id: theme.dim(group.id),
mode: group.mode,
upsCount: String(upsInGroup.length),
ups: upsInGroup.length > 0
? upsInGroup.map((ups) => ups.name).join(', ')
ups: upsInGroup.length > 0
? upsInGroup.map((ups) => ups.name).join(', ')
: theme.dim('None'),
description: group.description || theme.dim('—'),
};
@@ -392,62 +409,68 @@ export class NupstCli {
}
} else {
// === Legacy Single UPS Configuration ===
if (!config.snmp) {
logger.logBox('Configuration Error', [
'Error: Legacy configuration missing SNMP settings',
], 60, 'error');
logger.logBox(
'Configuration Error',
[
'Error: Legacy configuration missing SNMP settings',
],
60,
'error',
);
return;
}
logger.log('');
logger.logBox('NUPST Configuration (Legacy)', [
theme.warning('Legacy single-UPS configuration format'),
'',
theme.dim('SNMP Settings:'),
` Host: ${theme.info(config.snmp.host)}`,
` Port: ${theme.info(String(config.snmp.port))}`,
` Version: ${config.snmp.version}`,
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
...(config.snmp.version === 1 || config.snmp.version === 2
? [` Community: ${config.snmp.community}`]
: []
),
...(config.snmp.version === 3
? [
logger.logBox(
'NUPST Configuration (Legacy)',
[
theme.warning('Legacy single-UPS configuration format'),
'',
theme.dim('SNMP Settings:'),
` Host: ${theme.info(config.snmp.host)}`,
` Port: ${theme.info(String(config.snmp.port))}`,
` Version: ${config.snmp.version}`,
` UPS Model: ${config.snmp.upsModel || 'cyberpower'}`,
...(config.snmp.version === 1 || config.snmp.version === 2
? [` Community: ${config.snmp.community}`]
: []),
...(config.snmp.version === 3
? [
` Security Level: ${config.snmp.securityLevel}`,
` Username: ${config.snmp.username}`,
...(config.snmp.securityLevel === 'authNoPriv' || config.snmp.securityLevel === 'authPriv'
...(config.snmp.securityLevel === 'authNoPriv' ||
config.snmp.securityLevel === 'authPriv'
? [` Auth Protocol: ${config.snmp.authProtocol || 'None'}`]
: []
),
: []),
...(config.snmp.securityLevel === 'authPriv'
? [` Privacy Protocol: ${config.snmp.privProtocol || 'None'}`]
: []
),
: []),
` Timeout: ${config.snmp.timeout / 1000} seconds`,
]
: []
),
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
? [
: []),
...(config.snmp.upsModel === 'custom' && config.snmp.customOIDs
? [
theme.dim('Custom OIDs:'),
` Power Status: ${config.snmp.customOIDs.POWER_STATUS || 'Not set'}`,
` Battery Capacity: ${config.snmp.customOIDs.BATTERY_CAPACITY || 'Not set'}`,
` Battery Runtime: ${config.snmp.customOIDs.BATTERY_RUNTIME || 'Not set'}`,
]
: []
),
'',
` Check Interval: ${config.checkInterval / 1000} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
'',
theme.warning('Note: Using legacy single-UPS configuration format.'),
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
], 70, 'warning');
: []),
'',
` Check Interval: ${config.checkInterval / 1000} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path('/etc/nupst/config.json')}`,
'',
theme.warning('Note: Using legacy single-UPS configuration format.'),
`Consider using ${theme.command('nupst ups add')} to migrate to multi-UPS format.`,
],
70,
'warning',
);
}
// Service Status
@@ -458,10 +481,15 @@ export class NupstCli {
execSync('systemctl is-enabled nupst.service || true').toString().trim() === 'enabled';
logger.log('');
logger.logBox('Service Status', [
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
], 50, isActive ? 'success' : 'default');
logger.logBox(
'Service Status',
[
`Active: ${isActive ? theme.success('Yes') : theme.dim('No')}`,
`Enabled: ${isEnabled ? theme.success('Yes') : theme.dim('No')}`,
],
50,
isActive ? 'success' : 'default',
);
logger.log('');
} catch (_error) {
// Ignore errors checking service status
@@ -514,8 +542,16 @@ export class NupstCli {
// Service subcommands
logger.log(theme.info('Service Subcommands:'));
this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
this.printCommand(
'nupst service enable',
'Install and enable systemd service',
theme.dim('(requires root)'),
);
this.printCommand(
'nupst service disable',
'Stop and disable systemd service',
theme.dim('(requires root)'),
);
this.printCommand('nupst service start', 'Start the systemd service');
this.printCommand('nupst service stop', 'Stop the systemd service');
this.printCommand('nupst service restart', 'Restart the systemd service');
@@ -545,7 +581,10 @@ export class NupstCli {
logger.log(theme.info('Action Subcommands:'));
this.printCommand('nupst action add <target-id>', 'Add a new action to a UPS or group');
this.printCommand('nupst action remove <target-id> <index>', 'Remove an action by index');
this.printCommand('nupst action list [target-id]', 'List all actions (optionally for specific target)');
this.printCommand(
'nupst action list [target-id]',
'List all actions (optionally for specific target)',
);
console.log('');
// Feature subcommands

View File

@@ -1,9 +1,9 @@
import process from 'node:process';
import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { theme, symbols } from '../colors.ts';
import { type ITableColumn, logger } from '../logger.ts';
import { symbols, theme } from '../colors.ts';
import type { IActionConfig } from '../actions/base-action.ts';
import type { IUpsConfig, IGroupConfig } from '../daemon.ts';
import type { IGroupConfig, IUpsConfig } from '../daemon.ts';
import * as helpers from '../helpers/index.ts';
/**
@@ -48,7 +48,9 @@ export class ActionHandler {
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
@@ -90,12 +92,16 @@ export class ActionHandler {
// Trigger mode
logger.log('');
logger.log(` ${theme.dim('Trigger mode:')}`);
logger.log(` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`);
logger.log(
` ${theme.dim('1)')} onlyPowerChanges - Trigger only when power status changes`,
);
logger.log(
` ${theme.dim('2)')} onlyThresholds - Trigger only when thresholds are violated`,
);
logger.log(
` ${theme.dim('3)')} powerChangesAndThresholds - Trigger on power change AND thresholds`,
` ${
theme.dim('3)')
} powerChangesAndThresholds - Trigger on power change AND thresholds`,
);
logger.log(` ${theme.dim('4)')} anyChange - Trigger on any status change`);
const triggerChoice = await prompt(` ${theme.dim('Choice')} ${theme.dim('[2]:')} `);
@@ -158,7 +164,9 @@ export class ActionHandler {
if (!targetId || !actionIndexStr) {
logger.error('Target ID and action index are required');
logger.log(
` ${theme.dim('Usage:')} ${theme.command('nupst action remove <ups-id|group-id> <action-index>')}`,
` ${theme.dim('Usage:')} ${
theme.command('nupst action remove <ups-id|group-id> <action-index>')
}`,
);
logger.log('');
logger.log(` ${theme.dim('List actions:')} ${theme.command('nupst action list')}`);
@@ -182,7 +190,9 @@ export class ActionHandler {
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log('');
process.exit(1);
@@ -200,7 +210,9 @@ export class ActionHandler {
if (actionIndex >= target!.actions.length) {
logger.error(
`Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`,
`Invalid action index. ${targetType} '${targetName}' has ${
target!.actions.length
} action(s) (index 0-${target!.actions.length - 1})`,
);
logger.log('');
logger.log(
@@ -220,7 +232,9 @@ export class ActionHandler {
logger.log(` ${theme.dim('Type:')} ${removedAction.type}`);
if (removedAction.thresholds) {
logger.log(
` ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
` ${
theme.dim('Thresholds:')
} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`,
);
}
logger.log(` ${theme.dim('Changes saved and will be applied automatically')}`);
@@ -248,8 +262,12 @@ export class ActionHandler {
if (!ups && !group) {
logger.error(`UPS or Group with ID '${targetId}' not found`);
logger.log('');
logger.log(` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`);
logger.log(` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`);
logger.log(
` ${theme.dim('List available UPS devices:')} ${theme.command('nupst ups list')}`,
);
logger.log(
` ${theme.dim('List available groups:')} ${theme.command('nupst group list')}`,
);
logger.log('');
process.exit(1);
}
@@ -287,7 +305,9 @@ export class ActionHandler {
logger.log(` ${theme.dim('No actions configured')}`);
logger.log('');
logger.log(
` ${theme.dim('Add an action:')} ${theme.command('nupst action add <ups-id|group-id>')}`,
` ${theme.dim('Add an action:')} ${
theme.command('nupst action add <ups-id|group-id>')
}`,
);
logger.log('');
}
@@ -308,7 +328,9 @@ export class ActionHandler {
targetType: 'UPS' | 'Group',
): void {
logger.log(
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`,
`${symbols.info} ${targetType} ${theme.highlight(target.name)} ${
theme.dim(`(${target.id})`)
}`,
);
logger.log('');

View File

@@ -1,4 +1,3 @@
import process from 'node:process';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger } from '../logger.ts';
@@ -29,7 +28,9 @@ export class FeatureHandler {
await this.runHttpServerConfig(prompt);
});
} catch (error) {
logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`);
logger.error(
`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -149,7 +150,9 @@ export class FeatureHandler {
logger.logBoxLine(`Auth Token: ${theme.warning(authToken)}`);
logger.logBoxLine('');
logger.logBoxLine(theme.dim('Usage examples:'));
logger.logBoxLine(` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`);
logger.logBoxLine(
` curl -H "Authorization: Bearer ${authToken}" http://localhost:${port}${finalPath}`,
);
logger.logBoxLine(` curl "http://localhost:${port}${finalPath}?token=${authToken}"`);
logger.logBoxEnd();
logger.log('');
@@ -165,7 +168,8 @@ export class FeatureHandler {
*/
private async restartServiceIfRunning(): Promise<void> {
try {
const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
const isActive =
execSync('systemctl is-active nupst.service || true').toString().trim() === 'active';
if (isActive) {
logger.log('');

View File

@@ -1,9 +1,8 @@
import process from 'node:process';
import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { type ITableColumn, logger } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { IGroupConfig, IUpsConfig, INupstConfig } from '../daemon.ts';
import type { IGroupConfig, INupstConfig, IUpsConfig } from '../daemon.ts';
/**
* Class for handling group-related CLI commands
@@ -29,10 +28,15 @@ export class GroupHandler {
try {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
logger.logBox(
'Configuration Error',
[
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
],
50,
'error',
);
return;
}
@@ -41,21 +45,35 @@ export class GroupHandler {
// Check if multi-UPS config
if (!config.groups || !Array.isArray(config.groups)) {
logger.logBox('UPS Groups', [
'No groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
], 50, 'info');
logger.logBox(
'UPS Groups',
[
'No groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
theme.dim('to add a group')
}`,
],
50,
'info',
);
return;
}
// Display group list with modern table
if (config.groups.length === 0) {
logger.logBox('UPS Groups', [
'No UPS groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${theme.dim('to add a group')}`,
], 60, 'info');
logger.logBox(
'UPS Groups',
[
'No UPS groups configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst group add')} ${
theme.dim('to add a group')
}`,
],
60,
'info',
);
return;
}

View File

@@ -126,7 +126,7 @@ export class ServiceHandler {
/**
* Update NUPST from repository and refresh systemd service
*/
public async update(): Promise<void> {
public update(): void {
try {
// Check if running as root
this.checkRootAccess(
@@ -147,8 +147,12 @@ export class ServiceHandler {
const latestVersion = release.tag_name; // e.g., "v4.0.7"
// Normalize versions for comparison (ensure both have "v" prefix)
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
const normalizedCurrent = currentVersion.startsWith('v')
? currentVersion
: `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v')
? latestVersion
: `v${latestVersion}`;
logger.dim(`Current version: ${normalizedCurrent}`);
logger.dim(`Latest version: ${normalizedLatest}`);

View File

@@ -1,11 +1,11 @@
import process from 'node:process';
import { execSync } from 'node:child_process';
import { Nupst } from '../nupst.ts';
import { logger, type ITableColumn } from '../logger.ts';
import { type ITableColumn, logger } from '../logger.ts';
import { theme } from '../colors.ts';
import * as helpers from '../helpers/index.ts';
import type { ISnmpConfig, TUpsModel, IUpsStatus as ISnmpUpsStatus } from '../snmp/types.ts';
import type { INupstConfig, IUpsConfig, IUpsStatus } from '../daemon.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts';
import type { INupstConfig, IUpsConfig } from '../daemon.ts';
import type { IActionConfig } from '../actions/base-action.ts';
/**
@@ -66,10 +66,10 @@ export class UpsHandler {
checkInterval: config.checkInterval,
upsDevices: [{
id: 'default',
name: 'Default UPS',
snmp: config.snmp,
groups: [],
actions: [],
name: 'Default UPS',
snmp: config.snmp,
groups: [],
actions: [],
}],
groups: [],
};
@@ -123,7 +123,7 @@ export class UpsHandler {
await groupHandler.assignUpsToGroups(newUps, config.groups, prompt);
}
// Gather action settings
// Gather action settings
await this.gatherActionSettings(newUps.actions, prompt);
// Add the new UPS to the config
@@ -343,10 +343,15 @@ export class UpsHandler {
try {
await this.nupst.getDaemon().loadConfig();
} catch (error) {
logger.logBox('Configuration Error', [
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
], 50, 'error');
logger.logBox(
'Configuration Error',
[
'No configuration found.',
"Please run 'nupst ups add' first to create a configuration.",
],
50,
'error',
);
return;
}
@@ -356,31 +361,38 @@ export class UpsHandler {
// Check if multi-UPS config
if (!config.upsDevices || !Array.isArray(config.upsDevices)) {
// Legacy single UPS configuration
logger.logBox('UPS Devices', [
'Legacy single-UPS configuration detected.',
'',
...(!config.snmp
? ['Error: Configuration missing SNMP settings']
: [
'Default UPS:',
` Host: ${config.snmp.host}:${config.snmp.port}`,
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
'',
'Use "nupst ups add" to add more UPS devices and migrate',
'to the multi-UPS configuration format.',
]
),
], 60, 'warning');
logger.logBox(
'UPS Devices',
[
'Legacy single-UPS configuration detected.',
'',
...(!config.snmp ? ['Error: Configuration missing SNMP settings'] : [
'Default UPS:',
` Host: ${config.snmp.host}:${config.snmp.port}`,
` Model: ${config.snmp.upsModel || 'cyberpower'}`,
'',
'Use "nupst ups add" to add more UPS devices and migrate',
'to the multi-UPS configuration format.',
]),
],
60,
'warning',
);
return;
}
// Display UPS list with modern table
if (config.upsDevices.length === 0) {
logger.logBox('UPS Devices', [
'No UPS devices configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
], 60, 'info');
logger.logBox(
'UPS Devices',
[
'No UPS devices configured.',
'',
`${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
],
60,
'info',
);
return;
}
@@ -569,8 +581,6 @@ export class UpsHandler {
logger.logBoxLine(` Battery Capacity: ${status.batteryCapacity}%`);
logger.logBoxLine(` Runtime Remaining: ${status.batteryRuntime} minutes`);
logger.logBoxEnd();
} catch (error) {
const errorBoxWidth = 45;
logger.logBoxTitle(`Connection Failed: ${upsName}`, errorBoxWidth);
@@ -959,7 +969,7 @@ export class UpsHandler {
logger.dim(' 4) Any change (every ~30s check)');
const triggerInput = await prompt('Select trigger mode [1]: ');
const triggerValue = parseInt(triggerInput, 10) || 1;
switch (triggerValue) {
case 2:
action.triggerMode = 'onlyPowerChanges';
@@ -975,11 +985,16 @@ export class UpsHandler {
}
// Configure thresholds if needed for onlyThresholds or powerChangesAndThresholds modes
if (action.triggerMode === 'onlyThresholds' || action.triggerMode === 'powerChangesAndThresholds') {
if (
action.triggerMode === 'onlyThresholds' ||
action.triggerMode === 'powerChangesAndThresholds'
) {
logger.log('');
logger.info('Action Thresholds:');
logger.dim('Action will trigger when battery or runtime falls below these values (while on battery)');
logger.dim(
'Action will trigger when battery or runtime falls below these values (while on battery)',
);
const batteryInput = await prompt('Battery threshold percentage [60]: ');
const battery = parseInt(batteryInput, 10);
const batteryThreshold = (batteryInput.trim() && !isNaN(battery)) ? battery : 60;
@@ -995,7 +1010,11 @@ export class UpsHandler {
}
actions.push(action as IActionConfig);
logger.success(`${action.type!.charAt(0).toUpperCase() + action.type!.slice(1)} action added (mode: ${action.triggerMode || 'powerChangesAndThresholds'})`);
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';
@@ -1019,7 +1038,7 @@ export class UpsHandler {
logger.logBoxLine(`SNMP Host: ${ups.snmp.host}:${ups.snmp.port}`);
logger.logBoxLine(`SNMP Version: ${ups.snmp.version}`);
logger.logBoxLine(`UPS Model: ${ups.snmp.upsModel}`);
if (ups.groups && ups.groups.length > 0) {
logger.logBoxLine(`Groups: ${ups.groups.join(', ')}`);
} else {

View File

@@ -5,13 +5,13 @@ import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { NupstSnmp } from './snmp/manager.ts';
import type { ISnmpConfig, IUpsStatus as ISnmpUpsStatus } from './snmp/types.ts';
import { logger, type ITableColumn } from './logger.ts';
import { logger } from './logger.ts';
import { MigrationRunner } from './migrations/index.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, theme } from './colors.ts';
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';
import { THRESHOLDS, TIMING, UI } from './constants.ts';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -100,10 +100,10 @@ export interface IUpsStatus {
powerStatus: 'online' | 'onBattery' | 'unknown';
batteryCapacity: number;
batteryRuntime: number;
outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps
outputLoad: number; // Load percentage (0-100%)
outputPower: number; // Power in watts
outputVoltage: number; // Voltage in volts
outputCurrent: number; // Current in amps
lastStatusChange: number;
lastCheckTime: number;
}
@@ -155,7 +155,7 @@ export class NupstDaemon {
],
groups: [],
checkInterval: TIMING.CHECK_INTERVAL_MS, // Check every 30 seconds
}
};
private config: INupstConfig;
private snmp: NupstSnmp;
@@ -249,7 +249,12 @@ export class NupstDaemon {
* Helper method to log configuration errors consistently
*/
private logConfigError(message: string): void {
logger.logBox('Configuration Error', [message, "Please run 'nupst setup' first to create a configuration."], 45, 'error');
logger.logBox(
'Configuration Error',
[message, "Please run 'nupst setup' first to create a configuration."],
45,
'error',
);
}
/**
@@ -311,11 +316,15 @@ export class NupstDaemon {
this.config.httpServer.port,
this.config.httpServer.path,
this.config.httpServer.authToken,
() => this.upsStatus
() => this.upsStatus,
);
this.httpServer.start();
} catch (error) {
logger.error(`Failed to start HTTP server: ${error instanceof Error ? error.message : String(error)}`);
logger.error(
`Failed to start HTTP server: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
@@ -364,7 +373,6 @@ export class NupstDaemon {
* Log the loaded configuration settings
*/
private logConfigLoaded(): void {
logger.log('');
logger.logBoxTitle('Configuration Loaded', 70, 'success');
logger.logBoxLine(`Check Interval: ${this.config.checkInterval / 1000} seconds`);
@@ -374,8 +382,10 @@ export class NupstDaemon {
// Display UPS devices in a table
if (this.config.upsDevices && this.config.upsDevices.length > 0) {
logger.info(`UPS Devices (${this.config.upsDevices.length}):`);
const upsColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const upsColumns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Host:Port', key: 'host', align: 'left', color: theme.info },
@@ -399,8 +409,10 @@ export class NupstDaemon {
// Display groups in a table
if (this.config.groups && this.config.groups.length > 0) {
logger.info(`Groups (${this.config.groups.length}):`);
const groupColumns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const groupColumns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Mode', key: 'mode', align: 'left', color: theme.info },
@@ -538,7 +550,7 @@ export class NupstDaemon {
// Only check when on battery power
if (status.powerStatus === 'onBattery' && ups.actions && ups.actions.length > 0) {
let anyThresholdExceeded = false;
for (const actionConfig of ups.actions) {
if (actionConfig.thresholds) {
if (
@@ -575,7 +587,7 @@ export class NupstDaemon {
*/
private logAllUpsStatus(): void {
const timestamp = new Date().toISOString();
logger.log('');
logger.logBoxTitle('Periodic Status Update', 70, 'info');
logger.logBoxLine(`Timestamp: ${timestamp}`);
@@ -583,7 +595,9 @@ export class NupstDaemon {
logger.log('');
// Build table data
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const columns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'ID', key: 'id', align: 'left', color: theme.dim },
{ header: 'Power Status', key: 'powerStatus', align: 'left' },
@@ -595,7 +609,7 @@ export class NupstDaemon {
for (const [id, status] of this.upsStatus.entries()) {
const batteryColor = getBatteryColor(status.batteryCapacity);
const runtimeColor = getRuntimeColor(status.batteryRuntime);
rows.push({
name: status.name,
id: id,
@@ -609,10 +623,6 @@ export class NupstDaemon {
logger.log('');
}
/**
* Build action context from UPS state
* @param ups UPS configuration
@@ -796,7 +806,9 @@ export class NupstDaemon {
logger.log('');
logger.logBoxTitle('Shutdown Monitoring Active', UI.WIDE_BOX_WIDTH, 'warning');
logger.logBoxLine(`Emergency threshold: ${THRESHOLDS.EMERGENCY_RUNTIME_MINUTES} minutes runtime`);
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();
@@ -808,7 +820,9 @@ export class NupstDaemon {
logger.info('Checking UPS status during shutdown...');
// Build table for UPS status during shutdown
const columns: Array<{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }> = [
const columns: Array<
{ header: string; key: string; align?: 'left' | 'right'; color?: (val: string) => string }
> = [
{ header: 'UPS Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Battery', key: 'battery', align: 'right' },
{ header: 'Runtime', key: 'runtime', align: 'right' },
@@ -828,7 +842,7 @@ export class NupstDaemon {
const runtimeColor = getRuntimeColor(status.batteryRuntime);
const isCritical = status.batteryRuntime < THRESHOLDS.EMERGENCY_RUNTIME_MINUTES;
rows.push({
name: ups.name,
battery: batteryColor(status.batteryCapacity + '%'),
@@ -848,7 +862,7 @@ export class NupstDaemon {
runtime: theme.error('N/A'),
status: theme.error('ERROR'),
});
logger.error(
`Error checking UPS ${ups.name} during shutdown: ${
upsError instanceof Error ? upsError.message : String(upsError)
@@ -991,7 +1005,9 @@ export class NupstDaemon {
let lastConfigCheck = Date.now();
logger.log('Entering idle monitoring mode...');
logger.log(`Daemon will check for config changes every ${TIMING.IDLE_CHECK_INTERVAL_MS / 1000} 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();

View File

@@ -25,7 +25,7 @@ export class NupstHttpServer {
port: number,
path: string,
authToken: string,
getUpsStatus: () => Map<string, IUpsStatus>
getUpsStatus: () => Map<string, IUpsStatus>,
) {
this.port = port;
this.path = path;
@@ -70,7 +70,7 @@ export class NupstHttpServer {
if (!this.isAuthenticated(req)) {
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': 'Bearer'
'WWW-Authenticate': 'Bearer',
});
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
@@ -82,7 +82,7 @@ export class NupstHttpServer {
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
'Cache-Control': 'no-cache',
});
res.end(JSON.stringify(statusArray, null, 2));
} else {
@@ -95,7 +95,7 @@ export class NupstHttpServer {
logger.success(`HTTP server started on port ${this.port} at ${this.path}`);
});
this.server.on('error', (error: any) => {
this.server.on('error', (error: Error) => {
logger.error(`HTTP server error: ${error.message}`);
});
}

View File

@@ -1,4 +1,4 @@
import { theme, symbols } from './colors.ts';
import { symbols, theme } from './colors.ts';
/**
* Table column alignment options
@@ -230,7 +230,8 @@ export class Logger {
* Strip ANSI color codes from string for accurate length calculation
*/
private stripAnsi(text: string): string {
// Remove ANSI escape codes
// Remove ANSI escape codes (intentional control character regex)
// deno-lint-ignore no-control-regex
return text.replace(/\x1b\[[0-9;]*m/g, '');
}

View File

@@ -31,7 +31,9 @@ export abstract class BaseMigration {
* @param config - Raw configuration object to check (unknown schema for migrations)
* @returns True if migration should run, false otherwise
*/
abstract shouldRun(config: Record<string, unknown>): Promise<boolean>;
abstract shouldRun(
config: Record<string, unknown>,
): boolean | Promise<boolean>;
/**
* Perform the migration on the given config
@@ -39,7 +41,9 @@ export abstract class BaseMigration {
* @param config - Raw configuration object to migrate (unknown schema for migrations)
* @returns Migrated configuration object
*/
abstract migrate(config: Record<string, unknown>): Promise<Record<string, unknown>>;
abstract migrate(
config: Record<string, unknown>,
): Record<string, unknown> | Promise<Record<string, unknown>>;
/**
* Get human-readable name for this migration

View File

@@ -23,12 +23,12 @@ export class MigrationV1ToV2 extends BaseMigration {
readonly fromVersion = '1.x';
readonly toVersion = '2.0';
async shouldRun(config: any): Promise<boolean> {
shouldRun(config: Record<string, unknown>): boolean {
// V1 format has snmp field directly at root, no upsDevices or upsList
return !!config.snmp && !config.upsDevices && !config.upsList;
}
async migrate(config: any): Promise<any> {
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`);
const migrated = {

View File

@@ -42,15 +42,16 @@ export class MigrationV3ToV4 extends BaseMigration {
readonly fromVersion = '3.x';
readonly toVersion = '4.0';
async shouldRun(config: any): Promise<boolean> {
shouldRun(config: Record<string, unknown>): boolean {
// V3 format has upsList OR has upsDevices with flat structure (host at top level)
if (config.upsList && !config.upsDevices) {
return true; // Classic v3 with upsList
}
// Check if upsDevices exists but has flat structure (v3 format)
if (config.upsDevices && config.upsDevices.length > 0) {
const firstDevice = config.upsDevices[0];
const upsDevices = config.upsDevices as Array<Record<string, unknown>> | undefined;
if (upsDevices && upsDevices.length > 0) {
const firstDevice = upsDevices[0];
// V3 has host at top level, v4 has it nested in snmp object
return !!firstDevice.host && !firstDevice.snmp;
}
@@ -58,17 +59,17 @@ export class MigrationV3ToV4 extends BaseMigration {
return false;
}
async migrate(config: any): Promise<any> {
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Migrating v3 config to v4 format...`);
logger.dim(` - Restructuring UPS devices (flat → nested snmp config)`);
// Get devices from either upsList or upsDevices (for partially migrated configs)
const sourceDevices = config.upsList || config.upsDevices;
const sourceDevices = (config.upsList || config.upsDevices) as Array<Record<string, unknown>>;
// Transform each UPS device from v3 flat structure to v4 nested structure
const transformedDevices = sourceDevices.map((device: any) => {
const transformedDevices = sourceDevices.map((device: Record<string, unknown>) => {
// Build SNMP config object
const snmpConfig: any = {
const snmpConfig: Record<string, unknown> = {
host: device.host,
port: device.port || 161,
version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version,
@@ -112,7 +113,9 @@ export class MigrationV3ToV4 extends BaseMigration {
checkInterval: config.checkInterval || 30000,
};
logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`);
logger.success(
`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`,
);
return migrated;
}
}

View File

@@ -49,7 +49,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
readonly fromVersion = '4.0';
readonly toVersion = '4.1';
async shouldRun(config: Record<string, unknown>): Promise<boolean> {
shouldRun(config: Record<string, unknown>): boolean {
// Run if config is version 4.0
if (config.version === '4.0') {
return true;
@@ -65,7 +65,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
return false;
}
async migrate(config: Record<string, unknown>): Promise<Record<string, unknown>> {
migrate(config: Record<string, unknown>): Record<string, unknown> {
logger.info(`${this.getName()}: Migrating v4.0 config to v4.1 format...`);
logger.dim(` - Moving thresholds from UPS level to action level`);
logger.dim(` - Creating default shutdown actions from existing thresholds`);
@@ -81,7 +81,9 @@ export class MigrationV4_0ToV4_1 extends BaseMigration {
};
// If device has thresholds at UPS level, convert to shutdown action
const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined;
const deviceThresholds = device.thresholds as
| { battery: number; runtime: number }
| undefined;
if (deviceThresholds) {
migrated.actions = [
{

View File

@@ -171,8 +171,8 @@ export class Nupst implements INupstAccessor {
const response = JSON.parse(data);
if (response.tag_name) {
// Strip 'v' prefix from tag name (e.g., "v5.1.7" -> "5.1.7")
const version = response.tag_name.startsWith('v')
? response.tag_name.substring(1)
const version = response.tag_name.startsWith('v')
? response.tag_name.substring(1)
: response.tag_name;
resolve(version);
} else {

View File

@@ -94,7 +94,8 @@ export class NupstSnmp {
public snmpGet(
oid: string,
config = this.DEFAULT_CONFIG,
retryCount = 0,
_retryCount = 0,
// deno-lint-ignore no-explicit-any
): Promise<any> {
return new Promise((resolve, reject) => {
if (this.debug) {
@@ -105,6 +106,7 @@ export class NupstSnmp {
}
// Create SNMP options based on configuration
// deno-lint-ignore no-explicit-any
const options: any = {
port: config.port,
retries: SNMP.RETRIES, // Number of retries
@@ -132,6 +134,7 @@ export class NupstSnmp {
const securityLevel = config.securityLevel || 'noAuthNoPriv';
// Create the user object with required structure for net-snmp
// deno-lint-ignore no-explicit-any
const user: any = {
name: config.username || '',
};
@@ -197,7 +200,11 @@ export class NupstSnmp {
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'}`);
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);
@@ -210,7 +217,8 @@ export class NupstSnmp {
const oids = [oid];
// Send the GET request
session.get(oids, (error: any, varbinds: any[]) => {
// deno-lint-ignore no-explicit-any
session.get(oids, (error: Error | null, varbinds: any[]) => {
// Close the session to release resources
session.close();
@@ -259,7 +267,9 @@ export class NupstSnmp {
}
if (this.debug) {
logger.dim(`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);
@@ -422,6 +432,7 @@ export class NupstSnmp {
oid: string,
description: string,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
if (oid === '') {
if (this.debug) {
@@ -476,6 +487,7 @@ export class NupstSnmp {
oid: string,
description: string,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
if (this.debug) {
logger.dim(`Retrying ${description} with fallback security level...`);
@@ -483,7 +495,7 @@ export class NupstSnmp {
// Try with authNoPriv if current level is authPriv
if (config.securityLevel === 'authPriv') {
const retryConfig = { ...config, securityLevel: 'authNoPriv' as 'authNoPriv' };
const retryConfig = { ...config, securityLevel: 'authNoPriv' as const };
try {
if (this.debug) {
logger.dim(`Retrying with authNoPriv security level`);
@@ -496,7 +508,9 @@ export class NupstSnmp {
} catch (retryError) {
if (this.debug) {
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
`Retry failed for ${description}: ${
retryError instanceof Error ? retryError.message : String(retryError)
}`,
);
}
}
@@ -504,7 +518,7 @@ export class NupstSnmp {
// Try with noAuthNoPriv as a last resort
if (config.securityLevel === 'authPriv' || config.securityLevel === 'authNoPriv') {
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as 'noAuthNoPriv' };
const retryConfig = { ...config, securityLevel: 'noAuthNoPriv' as const };
try {
if (this.debug) {
logger.dim(`Retrying with noAuthNoPriv security level`);
@@ -517,7 +531,9 @@ export class NupstSnmp {
} catch (retryError) {
if (this.debug) {
logger.error(
`Retry failed for ${description}: ${retryError instanceof Error ? retryError.message : String(retryError)}`,
`Retry failed for ${description}: ${
retryError instanceof Error ? retryError.message : String(retryError)
}`,
);
}
}
@@ -528,15 +544,16 @@ export class NupstSnmp {
/**
* Try standard OIDs as fallback
* @param oid OID to query
* @param _oid Original OID (unused, kept for method signature consistency)
* @param description Description of the value for logging
* @param config SNMP configuration
* @returns Promise resolving to the SNMP value
*/
private async tryStandardOids(
oid: string,
_oid: string,
description: string,
config: ISnmpConfig,
// deno-lint-ignore no-explicit-any
): Promise<any> {
try {
// Try RFC 1628 standard UPS MIB OIDs
@@ -556,7 +573,9 @@ export class NupstSnmp {
} catch (stdError) {
if (this.debug) {
logger.error(
`Standard OID retry failed for ${description}: ${stdError instanceof Error ? stdError.message : String(stdError)}`,
`Standard OID retry failed for ${description}: ${
stdError instanceof Error ? stdError.message : String(stdError)
}`,
);
}
}

View File

@@ -23,7 +23,7 @@ export interface IUpsStatus {
/** Output current in amps */
outputCurrent: number;
/** Raw values from SNMP responses */
raw: Record<string, any>;
raw: Record<string, unknown>;
}
/**

View File

@@ -1,10 +1,10 @@
import process from 'node:process';
import { promises as fs } from 'node:fs';
import { execSync } from 'node:child_process';
import { NupstDaemon, type IUpsConfig } from './daemon.ts';
import { type IUpsConfig, NupstDaemon } from './daemon.ts';
import { NupstSnmp } from './snmp/manager.ts';
import { logger } from './logger.ts';
import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts';
import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, theme } from './colors.ts';
/**
* Class for managing systemd service
@@ -54,7 +54,11 @@ WantedBy=multi-user.target
logger.log('');
logger.error('No configuration found');
logger.log(` ${theme.dim('Config file:')} ${configPath}`);
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to create a configuration')}`);
logger.log(
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${
theme.dim('to create a configuration')
}`,
);
logger.log('');
throw new Error('Configuration not found');
}
@@ -155,16 +159,22 @@ WantedBy=multi-user.target
const updateStatus = nupst.getUpdateStatus();
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)}`,
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.warning} ${
theme.statusWarning(`Update available: v${updateStatus.latestVersion}`)
}`,
);
logger.log(
` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`,
);
logger.log(` ${theme.dim('Run')} ${theme.command('sudo nupst update')} ${theme.dim('to upgrade')}`);
} else {
logger.log('');
logger.log(
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${theme.success('Up to date')}`,
`${theme.dim('NUPST')} ${theme.dim('v' + version)} ${symbols.success} ${
theme.success('Up to date')
}`,
);
}
} catch (_error) {
} catch (error) {
// If version check fails, show at least the current version
try {
const nupst = this.daemon.getNupstSnmp().getNupst();
@@ -242,9 +252,15 @@ WantedBy=multi-user.target
// Display beautiful status
logger.log('');
if (isActive) {
logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`);
logger.log(
`${symbols.running} ${theme.success('Service:')} ${
theme.statusActive('active (running)')
}`,
);
} else {
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`);
logger.log(
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`,
);
}
if (pid || memory || cpu) {
@@ -255,10 +271,11 @@ WantedBy=multi-user.target
logger.log(` ${details.join(' ')}`);
}
logger.log('');
} catch (error) {
logger.log('');
logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`);
logger.log(
`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`,
);
logger.log('');
}
}
@@ -295,13 +312,13 @@ WantedBy=multi-user.target
groups: [],
actions: config.thresholds
? [
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
{
type: 'shutdown',
thresholds: config.thresholds,
triggerMode: 'onlyThresholds',
shutdownDelay: 5,
},
]
: [],
};
@@ -309,7 +326,9 @@ WantedBy=multi-user.target
} else {
logger.log('');
logger.warn('No UPS devices configured');
logger.log(` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`);
logger.log(
` ${theme.dim('Run')} ${theme.command('nupst ups add')} ${theme.dim('to add a device')}`,
);
logger.log('');
}
} catch (error) {
@@ -344,7 +363,9 @@ WantedBy=multi-user.target
}
// Display UPS name and power status
logger.log(` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`);
logger.log(
` ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`,
);
// Display battery with color coding
const batteryColor = getBatteryColor(status.batteryCapacity);
@@ -352,16 +373,27 @@ WantedBy=multi-user.target
// Get threshold from actions (if any action has thresholds defined)
const actionWithThresholds = ups.actions?.find((action) => action.thresholds);
const batteryThreshold = actionWithThresholds?.thresholds?.battery;
const batterySymbol = batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
? symbols.success
: batteryThreshold !== undefined
? symbols.warning
: '';
const batterySymbol =
batteryThreshold !== undefined && status.batteryCapacity >= batteryThreshold
? symbols.success
: batteryThreshold !== undefined
? symbols.warning
: '';
logger.log(` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`);
logger.log(
` Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol} Runtime: ${
getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')
}`,
);
// Display power metrics
logger.log(` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${theme.highlight(status.outputPower + 'W')} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${theme.highlight(status.outputCurrent + 'A')}`);
logger.log(
` Load: ${theme.highlight(status.outputLoad + '%')} Power: ${
theme.highlight(status.outputPower + 'W')
} Voltage: ${theme.highlight(status.outputVoltage + 'V')} Current: ${
theme.highlight(status.outputCurrent + 'A')
}`,
);
// Display host info
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
@@ -381,7 +413,9 @@ WantedBy=multi-user.target
for (const action of ups.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}
@@ -398,10 +432,11 @@ WantedBy=multi-user.target
}
logger.log('');
} catch (error) {
// Display error for this UPS
logger.log(` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`);
logger.log(
` ${symbols.error} ${theme.highlight(ups.name)} - ${theme.error('Connection failed')}`,
);
logger.log(` ${theme.dim(error instanceof Error ? error.message : String(error))}`);
logger.log(` ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`);
logger.log('');
@@ -426,7 +461,9 @@ WantedBy=multi-user.target
// Display group name and mode
const modeColor = group.mode === 'redundant' ? theme.success : theme.warning;
logger.log(
` ${symbols.info} ${theme.highlight(group.name)} ${theme.dim(`(${modeColor(group.mode)})`)}`,
` ${symbols.info} ${theme.highlight(group.name)} ${
theme.dim(`(${modeColor(group.mode)})`)
}`,
);
// Display description if present
@@ -451,7 +488,9 @@ WantedBy=multi-user.target
for (const action of group.actions) {
let actionDesc = `${action.type}`;
if (action.thresholds) {
actionDesc += ` (${action.triggerMode || 'onlyThresholds'}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
actionDesc += ` (${
action.triggerMode || 'onlyThresholds'
}: battery<${action.thresholds.battery}%, runtime<${action.thresholds.runtime}min`;
if (action.shutdownDelay) {
actionDesc += `, delay=${action.shutdownDelay}s`;
}