From ff2dc00f312dba4733ff598e62aeaf5f78cf9cc2 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 29 Jan 2026 17:10:17 +0000 Subject: [PATCH] fix(core): tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging --- .gitea/release-template.md | 6 + bin/nupst-wrapper.js | 12 +- changelog.md | 105 +++++++++---- npmextra.json | 2 +- readme.hints.md | 12 +- readme.md | 54 ++++--- scripts/install-binary.js | 24 +-- test/manualdocker/README.md | 11 +- test/test.showcase.ts | 83 +++++++---- test/test.ts | 47 +++--- ts/00_commitinfo_data.ts | 2 +- ts/actions/script-action.ts | 6 +- ts/actions/shutdown-action.ts | 27 +++- ts/actions/webhook-action.ts | 8 +- ts/cli.ts | 187 ++++++++++++++---------- ts/cli/action-handler.ts | 50 +++++-- ts/cli/feature-handler.ts | 11 +- ts/cli/group-handler.ts | 51 +++++-- ts/cli/service-handler.ts | 8 +- ts/cli/ups-handler.ts | 97 +++++++----- ts/daemon.ts | 74 ++++++---- ts/http-server.ts | 8 +- ts/logger.ts | 2 +- ts/migrations/base-migration.ts | 8 +- ts/migrations/migration-v1-to-v2.ts | 4 +- ts/migrations/migration-v3-to-v4.ts | 19 ++- ts/migrations/migration-v4.0-to-v4.1.ts | 8 +- ts/nupst.ts | 4 +- ts/snmp/manager.ts | 22 ++- ts/snmp/types.ts | 2 +- ts/systemd.ts | 101 +++++++++---- 31 files changed, 693 insertions(+), 362 deletions(-) diff --git a/.gitea/release-template.md b/.gitea/release-template.md index 7b9f724..69ee5eb 100644 --- a/.gitea/release-template.md +++ b/.gitea/release-template.md @@ -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. diff --git a/bin/nupst-wrapper.js b/bin/nupst-wrapper.js index 3f3353b..e11dfd1 100644 --- a/bin/nupst-wrapper.js +++ b/bin/nupst-wrapper.js @@ -9,7 +9,7 @@ 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'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -25,12 +25,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 +76,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 +95,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 +105,4 @@ function executeBinary() { } // Execute -executeBinary(); \ No newline at end of file +executeBinary(); diff --git a/changelog.md b/changelog.md index 397443d..a0d40a0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,69 +1,116 @@ # 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.2 - fix(core) +tidy formatting and minor fixes across CLI, SNMP, HTTP server, migrations and packaging -- 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. +- 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 diff --git a/npmextra.json b/npmextra.json index da4e80d..6cfb8de 100644 --- a/npmextra.json +++ b/npmextra.json @@ -17,4 +17,4 @@ } }, "@ship.zone/szci": {} -} \ No newline at end of file +} diff --git a/readme.hints.md b/readme.hints.md index c0d96ba..40aa557 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -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 diff --git a/readme.md b/readme.md index cf0bb9a..bccf36c 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/scripts/install-binary.js b/scripts/install-binary.js index f495923..d92cef9 100644 --- a/scripts/install-binary.js +++ b/scripts/install-binary.js @@ -5,9 +5,9 @@ * 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, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import https from 'https'; import { pipeline } from 'stream'; @@ -29,12 +29,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 +54,7 @@ function getBinaryInfo() { platform: mappedPlatform, arch: mappedArch, binaryName, - originalPlatform: plat + originalPlatform: plat, }; } @@ -122,7 +122,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 +187,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 +229,7 @@ async function main() { } // Run the installation -main().catch(err => { +main().catch((err) => { console.error(`❌ Installation failed: ${err.message}`); process.exit(1); -}); \ No newline at end of file +}); diff --git a/test/manualdocker/README.md b/test/manualdocker/README.md index d724109..57c9dfc 100644 --- a/test/manualdocker/README.md +++ b/test/manualdocker/README.md @@ -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` diff --git a/test/test.showcase.ts b/test/test.showcase.ts index a28bb43..73c6def 100644 --- a/test/test.showcase.ts +++ b/test/test.showcase.ts @@ -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' }, ]; diff --git a/test/test.ts b/test/test.ts index 07eb5a9..e556e49 100644 --- a/test/test.ts +++ b/test/test.ts @@ -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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 782f990..cc3c519 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/nupst', - version: '5.2.1', + version: '5.2.2', description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' } diff --git a/ts/actions/script-action.ts b/ts/actions/script-action.ts index 5191253..695c442 100644 --- a/ts/actions/script-action.ts +++ b/ts/actions/script-action.ts @@ -26,7 +26,11 @@ export class ScriptAction extends Action { async execute(context: IActionContext): Promise { // 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; } diff --git a/ts/actions/shutdown-action.ts b/ts/actions/shutdown-action.ts index e586267..5f8e7e2 100644 --- a/ts/actions/shutdown-action.ts +++ b/ts/actions/shutdown-action.ts @@ -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 { // 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; } diff --git a/ts/actions/webhook-action.ts b/ts/actions/webhook-action.ts index 50b599f..5840695 100644 --- a/ts/actions/webhook-action.ts +++ b/ts/actions/webhook-action.ts @@ -46,7 +46,11 @@ export class WebhookAction extends Action { async execute(context: IActionContext): Promise { // 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; } @@ -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)); } diff --git a/ts/cli.ts b/ts/cli.ts index f6c2aef..3b7f106 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -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 { symbols, 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 ', 'Add a new action to a UPS or group'); this.printCommand('nupst action remove ', '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 diff --git a/ts/cli/action-handler.ts b/ts/cli/action-handler.ts index 78c11d1..6f55ec7 100644 --- a/ts/cli/action-handler.ts +++ b/ts/cli/action-handler.ts @@ -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 ')}`, + ` ${theme.dim('Usage:')} ${ + theme.command('nupst action remove ') + }`, ); 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 ')}`, + ` ${theme.dim('Add an action:')} ${ + theme.command('nupst action add ') + }`, ); 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(''); diff --git a/ts/cli/feature-handler.ts b/ts/cli/feature-handler.ts index f04471d..49aba77 100644 --- a/ts/cli/feature-handler.ts +++ b/ts/cli/feature-handler.ts @@ -29,7 +29,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 +151,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 +169,8 @@ export class FeatureHandler { */ private async restartServiceIfRunning(): Promise { 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(''); diff --git a/ts/cli/group-handler.ts b/ts/cli/group-handler.ts index ed3d7c7..a5781db 100644 --- a/ts/cli/group-handler.ts +++ b/ts/cli/group-handler.ts @@ -1,9 +1,9 @@ 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 +29,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 +46,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; } diff --git a/ts/cli/service-handler.ts b/ts/cli/service-handler.ts index d812777..5888ddf 100644 --- a/ts/cli/service-handler.ts +++ b/ts/cli/service-handler.ts @@ -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}`); diff --git a/ts/cli/ups-handler.ts b/ts/cli/ups-handler.ts index 808aadc..8efedaa 100644 --- a/ts/cli/ups-handler.ts +++ b/ts/cli/ups-handler.ts @@ -1,10 +1,10 @@ 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 { ISnmpConfig, IUpsStatus as ISnmpUpsStatus, TUpsModel } from '../snmp/types.ts'; import type { INupstConfig, IUpsConfig, IUpsStatus } 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 { diff --git a/ts/daemon.ts b/ts/daemon.ts index 4d50fa9..3329bdf 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -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 { type ITableColumn, logger } from './logger.ts'; import { MigrationRunner } from './migrations/index.ts'; -import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; +import { formatPowerStatus, getBatteryColor, getRuntimeColor, symbols, 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(); diff --git a/ts/http-server.ts b/ts/http-server.ts index 1aecc98..0b26ec0 100644 --- a/ts/http-server.ts +++ b/ts/http-server.ts @@ -25,7 +25,7 @@ export class NupstHttpServer { port: number, path: string, authToken: string, - getUpsStatus: () => Map + getUpsStatus: () => Map, ) { 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}`); }); } diff --git a/ts/logger.ts b/ts/logger.ts index b727fb2..55c029e 100644 --- a/ts/logger.ts +++ b/ts/logger.ts @@ -1,4 +1,4 @@ -import { theme, symbols } from './colors.ts'; +import { symbols, theme } from './colors.ts'; /** * Table column alignment options diff --git a/ts/migrations/base-migration.ts b/ts/migrations/base-migration.ts index 1bcfb08..c7d3fc1 100644 --- a/ts/migrations/base-migration.ts +++ b/ts/migrations/base-migration.ts @@ -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): Promise; + abstract shouldRun( + config: Record, + ): boolean | Promise; /** * 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): Promise>; + abstract migrate( + config: Record, + ): Record | Promise>; /** * Get human-readable name for this migration diff --git a/ts/migrations/migration-v1-to-v2.ts b/ts/migrations/migration-v1-to-v2.ts index 914caf8..d5667f6 100644 --- a/ts/migrations/migration-v1-to-v2.ts +++ b/ts/migrations/migration-v1-to-v2.ts @@ -23,12 +23,12 @@ export class MigrationV1ToV2 extends BaseMigration { readonly fromVersion = '1.x'; readonly toVersion = '2.0'; - async shouldRun(config: any): Promise { + shouldRun(config: Record): 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 { + migrate(config: Record): Record { logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`); const migrated = { diff --git a/ts/migrations/migration-v3-to-v4.ts b/ts/migrations/migration-v3-to-v4.ts index e662f90..d81f25b 100644 --- a/ts/migrations/migration-v3-to-v4.ts +++ b/ts/migrations/migration-v3-to-v4.ts @@ -42,15 +42,16 @@ export class MigrationV3ToV4 extends BaseMigration { readonly fromVersion = '3.x'; readonly toVersion = '4.0'; - async shouldRun(config: any): Promise { + shouldRun(config: Record): 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> | 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 { + migrate(config: Record): Record { 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>; // Transform each UPS device from v3 flat structure to v4 nested structure - const transformedDevices = sourceDevices.map((device: any) => { + const transformedDevices = sourceDevices.map((device: Record) => { // Build SNMP config object - const snmpConfig: any = { + const snmpConfig: Record = { 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; } } diff --git a/ts/migrations/migration-v4.0-to-v4.1.ts b/ts/migrations/migration-v4.0-to-v4.1.ts index 33b6c2c..778df2c 100644 --- a/ts/migrations/migration-v4.0-to-v4.1.ts +++ b/ts/migrations/migration-v4.0-to-v4.1.ts @@ -49,7 +49,7 @@ export class MigrationV4_0ToV4_1 extends BaseMigration { readonly fromVersion = '4.0'; readonly toVersion = '4.1'; - async shouldRun(config: Record): Promise { + shouldRun(config: Record): 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): Promise> { + migrate(config: Record): Record { 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 = [ { diff --git a/ts/nupst.ts b/ts/nupst.ts index 7ee5233..c6e9547 100644 --- a/ts/nupst.ts +++ b/ts/nupst.ts @@ -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 { diff --git a/ts/snmp/manager.ts b/ts/snmp/manager.ts index 901ba28..3473e1e 100644 --- a/ts/snmp/manager.ts +++ b/ts/snmp/manager.ts @@ -197,7 +197,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); @@ -259,7 +263,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); @@ -496,7 +502,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) + }`, ); } } @@ -517,7 +525,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) + }`, ); } } @@ -556,7 +566,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) + }`, ); } } diff --git a/ts/snmp/types.ts b/ts/snmp/types.ts index cdee78c..97a23f7 100644 --- a/ts/snmp/types.ts +++ b/ts/snmp/types.ts @@ -23,7 +23,7 @@ export interface IUpsStatus { /** Output current in amps */ outputCurrent: number; /** Raw values from SNMP responses */ - raw: Record; + raw: Record; } /** diff --git a/ts/systemd.ts b/ts/systemd.ts index 2f97208..997e84c 100644 --- a/ts/systemd.ts +++ b/ts/systemd.ts @@ -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,13 +159,19 @@ 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) { @@ -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`; }