Compare commits
	
		
			154 Commits
		
	
	
		
			e68654a022
			...
			v5.1.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 68c4eb6480 | |||
| 6c8f6ac33f | |||
| ffa491c7a1 | |||
| 777d48d82e | |||
| b7a0bbcf6d | |||
| fbe1cd64cb | |||
| 9ba50da73c | |||
| 684319983d | |||
| 18bd9f6cda | |||
| f03c683d02 | |||
| f750299780 | |||
| ca1039408d | |||
| df3e0b9424 | |||
| c8e5960abd | |||
| 7304a62357 | |||
| a5a88e53ba | |||
| 73bc271c59 | |||
| 1e98181e71 | |||
| eb5a8185ae | |||
| ef3d3f3fa3 | |||
| 34e6e850ad | |||
| 992a776fd2 | |||
| 3e15a2d52f | |||
| d1a3576d31 | |||
| 1ca05e879b | |||
| 9c6fa37eb8 | |||
| ff433b2256 | |||
| 263d69aef1 | |||
| b6b7b43161 | |||
| 316c66c344 | |||
| 4debda856b | |||
| 0e7bcab499 | |||
| 7bf65d8495 | |||
| f2ce0180d3 | |||
| 8c1be6555f | |||
| 1a5558e91f | |||
| 611a9ddd19 | |||
| afd026d08c | |||
| 2c8ea44d40 | |||
| 32bd27b849 | |||
| a7113d0387 | |||
| 61d4e9037a | |||
| caced2718f | |||
| 8516056f84 | |||
| 07ec9d7595 | |||
| d14ba1dd65 | |||
| 7d595fa175 | |||
| df417432b0 | |||
| e5f1ebf343 | |||
| 3ff0dd7ac8 | |||
| bb87316dd3 | |||
| d6e0a1a274 | |||
| 95fa4f8b0b | |||
| c2f2f1e2ee | |||
| 936f86c346 | |||
| 7ff1a7da36 | |||
| a87710144c | |||
| 23fd5cc5cd | |||
| fb4d776bdd | |||
| 88ad16c638 | |||
| 016681b77b | |||
| 49f7a7da8b | |||
| f8269a1cb7 | |||
| b37e1aae6c | |||
| 7076829747 | |||
| 1387ca262b | |||
| 684f034aee | |||
| a63ec16d63 | |||
| 85f34cf96a | |||
| 4d28614e08 | |||
| 567c7be7c5 | |||
| a897a7c780 | |||
| accf137216 | |||
| c3441946cb | |||
| 37ccbf58fd | |||
| 071ded9c41 | |||
| b935087d50 | |||
| e1383097b2 | |||
| dff0ea610b | |||
| 4faa10c494 | |||
| c2d39cc19a | |||
| 9ccbbbdc37 | |||
| 1705ffe2be | |||
| 968cbbd8fc | |||
| a2ae9960b6 | |||
| df6a44d5d9 | |||
| 9efcc4b437 | |||
| 5903ae71be | |||
| a649c598ad | |||
| 5f4f3ecbc3 | |||
| 806f81c6a0 | |||
| 88e353eec6 | |||
| 80ff1b1230 | |||
| 1075335497 | |||
| eafb5207a4 | |||
| 9969e0f703 | |||
| ac4b2c95f3 | |||
| c593d76ead | |||
| 01ccf2d080 | |||
| 0e55f22dad | |||
| bd3042de25 | |||
| 456351ca34 | |||
| 00afa317ef | |||
| 45ee8208b5 | |||
| 39bf3e2239 | |||
| f3de3f0618 | |||
| 03056d279d | |||
| f860f39e59 | |||
| fa4516de3b | |||
| 539547beb8 | |||
| 6eb92959ec | |||
| 4af9af0845 | |||
| f7e12cdcbb | |||
| 002498b91b | |||
| 459911fe5f | |||
| 9859a02ea2 | |||
| 65444b6d25 | |||
| d049e8741f | |||
| 1123a99aea | |||
| d01e878310 | |||
| 588aeabf4b | |||
| 87005e72f1 | |||
| f799c2ee66 | |||
| 1a029ba493 | |||
| 5b756dd223 | |||
| 4cac599a58 | |||
| be6a7314c3 | |||
| 83ba9c2611 | |||
| 22ab472e58 | |||
| 9a77030377 | |||
| ceff285ff5 | |||
| d8bfbf0be3 | |||
| 3e6b883b38 | |||
| 47ef918128 | |||
| 5951638967 | |||
| b06e2b2273 | |||
| cc1cfe894c | |||
| da49b7a5bf | |||
| 4de6081a74 | |||
| 5a13e49803 | |||
| 2737fca294 | |||
| 896233914f | |||
| 5bb775b17d | |||
| ae8219acf7 | |||
| 4ad383884c | |||
| 65a9d1c798 | |||
| f583e1466f | |||
| 9d893a97b6 | |||
| aa52d5e9f6 | |||
| 623b7ee51f | |||
| 897e86ad60 | |||
| ed78db20e2 | |||
| bd00dfe02c | |||
| 55c040df82 | 
							
								
								
									
										179
									
								
								.gitea/workflows/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								.gitea/workflows/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| # Gitea Actions Workflows | ||||
|  | ||||
| This directory contains automated workflows for NUPST's CI/CD pipeline. | ||||
|  | ||||
| ## Workflows | ||||
|  | ||||
| ### CI Workflow (`ci.yml`) | ||||
|  | ||||
| **Triggers:** | ||||
|  | ||||
| - Push to `main` branch | ||||
| - Push to `migration/**` branches | ||||
| - Pull requests to `main` | ||||
|  | ||||
| **Jobs:** | ||||
|  | ||||
| 1. **Type Check & Lint** | ||||
|    - Runs `deno check` for TypeScript validation | ||||
|    - Runs `deno lint` (continues on error) | ||||
|    - Runs `deno fmt --check` (continues on error) | ||||
|  | ||||
| 2. **Build Test (Current Platform)** | ||||
|    - Compiles for Linux x86_64 (host platform) | ||||
|    - Tests binary execution (`--version` and `help`) | ||||
|  | ||||
| 3. **Build All Platforms** (Main/Tags only) | ||||
|    - Compiles all 5 platform binaries | ||||
|    - Uploads as artifacts (30-day retention) | ||||
|    - Only runs on `main` branch or tags | ||||
|  | ||||
| ### Release Workflow (`release.yml`) | ||||
|  | ||||
| **Triggers:** | ||||
|  | ||||
| - Push tags matching `v*` (e.g., `v4.0.0`) | ||||
|  | ||||
| **Jobs:** | ||||
|  | ||||
| 1. **Version Verification** | ||||
|    - Extracts version from tag | ||||
|    - Verifies `deno.json` version matches tag | ||||
|    - Fails if mismatch detected | ||||
|  | ||||
| 2. **Compilation** | ||||
|    - Compiles binaries for all 5 platforms: | ||||
|      - `nupst-linux-x64` (Linux x86_64) | ||||
|      - `nupst-linux-arm64` (Linux ARM64) | ||||
|      - `nupst-macos-x64` (macOS Intel) | ||||
|      - `nupst-macos-arm64` (macOS Apple Silicon) | ||||
|      - `nupst-windows-x64.exe` (Windows x64) | ||||
|  | ||||
| 3. **Checksums** | ||||
|    - Generates SHA256 checksums for all binaries | ||||
|    - Creates `SHA256SUMS.txt` | ||||
|  | ||||
| 4. **Release Creation** | ||||
|    - Creates Gitea release with tag | ||||
|    - Extracts release notes from CHANGELOG.md (if exists) | ||||
|    - Uploads all binaries + checksums as release assets | ||||
|  | ||||
| ## Creating a Release | ||||
|  | ||||
| ### Prerequisites | ||||
|  | ||||
| 1. Update version in `deno.json`: | ||||
|    ```json | ||||
|    { | ||||
|      "version": "4.0.0" | ||||
|    } | ||||
|    ``` | ||||
|  | ||||
| 2. Update `CHANGELOG.md` with release notes (optional but recommended) | ||||
|  | ||||
| 3. Commit all changes: | ||||
|    ```bash | ||||
|    git add . | ||||
|    git commit -m "chore: bump version to 4.0.0" | ||||
|    ``` | ||||
|  | ||||
| ### Release Process | ||||
|  | ||||
| 1. Create and push a tag matching the version: | ||||
|    ```bash | ||||
|    git tag v4.0.0 | ||||
|    git push origin v4.0.0 | ||||
|    ``` | ||||
|  | ||||
| 2. Gitea Actions will automatically: | ||||
|    - Verify version consistency | ||||
|    - Compile all platform binaries | ||||
|    - Generate checksums | ||||
|    - Create release with binaries attached | ||||
|  | ||||
| 3. Monitor the workflow: | ||||
|    - Go to: `https://code.foss.global/serve.zone/nupst/actions` | ||||
|    - Check the "Release" workflow run | ||||
|  | ||||
| ### Manual Release (Fallback) | ||||
|  | ||||
| If workflows fail, you can create a release manually: | ||||
|  | ||||
| ```bash | ||||
| # Compile all binaries | ||||
| bash scripts/compile-all.sh | ||||
|  | ||||
| # Generate checksums | ||||
| cd dist/binaries | ||||
| sha256sum * > SHA256SUMS.txt | ||||
| cd ../.. | ||||
|  | ||||
| # Create release on Gitea UI | ||||
| # Upload binaries manually | ||||
| ``` | ||||
|  | ||||
| ## Troubleshooting | ||||
|  | ||||
| ### Version Mismatch Error | ||||
|  | ||||
| If the release workflow fails with "Version mismatch": | ||||
|  | ||||
| - Ensure `deno.json` version matches the git tag | ||||
| - Example: tag `v4.0.0` requires `"version": "4.0.0"` in deno.json | ||||
|  | ||||
| ### Compilation Errors | ||||
|  | ||||
| If compilation fails: | ||||
|  | ||||
| 1. Test locally: `bash scripts/compile-all.sh` | ||||
| 2. Check Deno version compatibility | ||||
| 3. Review TypeScript errors: `deno check mod.ts` | ||||
|  | ||||
| ### Upload Failures | ||||
|  | ||||
| If binary upload fails: | ||||
|  | ||||
| 1. Check Gitea Actions permissions | ||||
| 2. Verify `GITHUB_TOKEN` secret exists (auto-provided by Gitea) | ||||
| 3. Try manual release creation | ||||
|  | ||||
| ## Workflow Secrets | ||||
|  | ||||
| The workflows use the following secrets: | ||||
|  | ||||
| - `GITHUB_TOKEN` - Auto-provided by Gitea Actions (no setup needed) | ||||
|  | ||||
| ## Development | ||||
|  | ||||
| ### Testing Workflows Locally | ||||
|  | ||||
| You can test compilation locally: | ||||
|  | ||||
| ```bash | ||||
| # Install Deno | ||||
| curl -fsSL https://deno.land/install.sh | sh | ||||
|  | ||||
| # Test type checking | ||||
| deno check mod.ts | ||||
|  | ||||
| # Test compilation | ||||
| bash scripts/compile-all.sh | ||||
|  | ||||
| # Test binary | ||||
| ./dist/binaries/nupst-linux-x64 --version | ||||
| ``` | ||||
|  | ||||
| ### Modifying Workflows | ||||
|  | ||||
| After modifying workflows: | ||||
|  | ||||
| 1. Test syntax: Use a YAML validator | ||||
| 2. Commit changes: `git add .gitea/workflows/` | ||||
| 3. Push to feature branch first to test CI | ||||
| 4. Merge to main once verified | ||||
|  | ||||
| ## Links | ||||
|  | ||||
| - Gitea Actions Documentation: https://docs.gitea.com/usage/actions/overview | ||||
| - Deno Compile Documentation: https://docs.deno.com/runtime/manual/tools/compiler | ||||
| - NUPST Repository: https://code.foss.global/serve.zone/nupst | ||||
							
								
								
									
										84
									
								
								.gitea/workflows/ci.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								.gitea/workflows/ci.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| name: CI | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|       - 'migration/**' | ||||
|   pull_request: | ||||
|     branches: | ||||
|       - main | ||||
|  | ||||
| jobs: | ||||
|   check: | ||||
|     name: Type Check & Lint | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Set up Deno | ||||
|         uses: denoland/setup-deno@v1 | ||||
|         with: | ||||
|           deno-version: v1.x | ||||
|  | ||||
|       - name: Check TypeScript types | ||||
|         run: deno check mod.ts | ||||
|  | ||||
|       - name: Lint code | ||||
|         run: deno lint | ||||
|         continue-on-error: true | ||||
|  | ||||
|       - name: Format check | ||||
|         run: deno fmt --check | ||||
|         continue-on-error: true | ||||
|  | ||||
|   build: | ||||
|     name: Build Test (Current Platform) | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Set up Deno | ||||
|         uses: denoland/setup-deno@v1 | ||||
|         with: | ||||
|           deno-version: v1.x | ||||
|  | ||||
|       - name: Compile for current platform | ||||
|         run: | | ||||
|           echo "Testing compilation for Linux x86_64..." | ||||
|           deno compile --allow-all --no-check \ | ||||
|             --output nupst-test \ | ||||
|             --target x86_64-unknown-linux-gnu mod.ts | ||||
|  | ||||
|       - name: Test binary execution | ||||
|         run: | | ||||
|           chmod +x nupst-test | ||||
|           ./nupst-test --version | ||||
|           ./nupst-test help | ||||
|  | ||||
|   build-all: | ||||
|     name: Build All Platforms | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Set up Deno | ||||
|         uses: denoland/setup-deno@v1 | ||||
|         with: | ||||
|           deno-version: v1.x | ||||
|  | ||||
|       - name: Compile all platform binaries | ||||
|         run: bash scripts/compile-all.sh | ||||
|  | ||||
|       - name: Upload all binaries as artifact | ||||
|         uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           name: nupst-binaries.zip | ||||
|           path: dist/binaries/* | ||||
|           retention-days: 30 | ||||
							
								
								
									
										249
									
								
								.gitea/workflows/release.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								.gitea/workflows/release.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,249 @@ | ||||
| name: Release | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - 'v*' | ||||
|  | ||||
| jobs: | ||||
|   build-and-release: | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|  | ||||
|       - name: Set up Deno | ||||
|         uses: denoland/setup-deno@v1 | ||||
|         with: | ||||
|           deno-version: v1.x | ||||
|  | ||||
|       - name: Get version from tag | ||||
|         id: version | ||||
|         run: | | ||||
|           VERSION=${GITHUB_REF#refs/tags/} | ||||
|           echo "version=$VERSION" >> $GITHUB_OUTPUT | ||||
|           echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT | ||||
|           echo "Building version: $VERSION" | ||||
|  | ||||
|       - name: Verify deno.json version matches tag | ||||
|         run: | | ||||
|           DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4) | ||||
|           TAG_VERSION="${{ steps.version.outputs.version_number }}" | ||||
|           echo "deno.json version: $DENO_VERSION" | ||||
|           echo "Tag version: $TAG_VERSION" | ||||
|           if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then | ||||
|             echo "ERROR: Version mismatch!" | ||||
|             echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION" | ||||
|             exit 1 | ||||
|           fi | ||||
|  | ||||
|       - name: Compile binaries for all platforms | ||||
|         run: | | ||||
|           echo "================================================" | ||||
|           echo "  NUPST Release Compilation" | ||||
|           echo "  Version: ${{ steps.version.outputs.version }}" | ||||
|           echo "================================================" | ||||
|           echo "" | ||||
|  | ||||
|           # Clean up old binaries and create fresh directory | ||||
|           rm -rf dist/binaries | ||||
|           mkdir -p dist/binaries | ||||
|           echo "→ Cleaned old binaries from dist/binaries" | ||||
|           echo "" | ||||
|  | ||||
|           # Linux x86_64 | ||||
|           echo "→ Compiling for Linux x86_64..." | ||||
|           deno compile --allow-all --no-check \ | ||||
|             --output dist/binaries/nupst-linux-x64 \ | ||||
|             --target x86_64-unknown-linux-gnu mod.ts | ||||
|           echo "  ✓ Linux x86_64 complete" | ||||
|  | ||||
|           # Linux ARM64 | ||||
|           echo "→ Compiling for Linux ARM64..." | ||||
|           deno compile --allow-all --no-check \ | ||||
|             --output dist/binaries/nupst-linux-arm64 \ | ||||
|             --target aarch64-unknown-linux-gnu mod.ts | ||||
|           echo "  ✓ Linux ARM64 complete" | ||||
|  | ||||
|           # macOS x86_64 | ||||
|           echo "→ Compiling for macOS x86_64..." | ||||
|           deno compile --allow-all --no-check \ | ||||
|             --output dist/binaries/nupst-macos-x64 \ | ||||
|             --target x86_64-apple-darwin mod.ts | ||||
|           echo "  ✓ macOS x86_64 complete" | ||||
|  | ||||
|           # macOS ARM64 | ||||
|           echo "→ Compiling for macOS ARM64..." | ||||
|           deno compile --allow-all --no-check \ | ||||
|             --output dist/binaries/nupst-macos-arm64 \ | ||||
|             --target aarch64-apple-darwin mod.ts | ||||
|           echo "  ✓ macOS ARM64 complete" | ||||
|  | ||||
|           # Windows x86_64 | ||||
|           echo "→ Compiling for Windows x86_64..." | ||||
|           deno compile --allow-all --no-check \ | ||||
|             --output dist/binaries/nupst-windows-x64.exe \ | ||||
|             --target x86_64-pc-windows-msvc mod.ts | ||||
|           echo "  ✓ Windows x86_64 complete" | ||||
|  | ||||
|           echo "" | ||||
|           echo "All binaries compiled successfully!" | ||||
|           ls -lh dist/binaries/ | ||||
|  | ||||
|       - name: Generate SHA256 checksums | ||||
|         run: | | ||||
|           cd dist/binaries | ||||
|           sha256sum * > SHA256SUMS.txt | ||||
|           cat SHA256SUMS.txt | ||||
|           cd ../.. | ||||
|  | ||||
|       - name: Extract changelog for this version | ||||
|         id: changelog | ||||
|         run: | | ||||
|           VERSION="${{ steps.version.outputs.version }}" | ||||
|  | ||||
|           # Check if CHANGELOG.md exists | ||||
|           if [ ! -f CHANGELOG.md ]; then | ||||
|             echo "No CHANGELOG.md found, using default release notes" | ||||
|             cat > /tmp/release_notes.md << EOF | ||||
|           ## NUPST $VERSION | ||||
|  | ||||
|           Pre-compiled binaries for multiple platforms. | ||||
|  | ||||
|           ### Installation | ||||
|  | ||||
|           Use the installation script: | ||||
|           \`\`\`bash | ||||
|           curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash | ||||
|           \`\`\` | ||||
|  | ||||
|           Or download the binary for your platform and make it executable. | ||||
|  | ||||
|           ### Supported Platforms | ||||
|           - Linux x86_64 (x64) | ||||
|           - Linux ARM64 (aarch64) | ||||
|           - macOS x86_64 (Intel) | ||||
|           - macOS ARM64 (Apple Silicon) | ||||
|           - Windows x86_64 | ||||
|  | ||||
|           ### Checksums | ||||
|           SHA256 checksums are provided in SHA256SUMS.txt | ||||
|           EOF | ||||
|           else | ||||
|             # Try to extract section for this version from CHANGELOG.md | ||||
|             # This is a simple extraction - adjust based on your CHANGELOG format | ||||
|             awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF | ||||
|           ## NUPST $VERSION | ||||
|  | ||||
|           See CHANGELOG.md for full details. | ||||
|  | ||||
|           ### Installation | ||||
|  | ||||
|           Use the installation script: | ||||
|           \`\`\`bash | ||||
|           curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash | ||||
|           \`\`\` | ||||
|           EOF | ||||
|           fi | ||||
|  | ||||
|           echo "Release notes:" | ||||
|           cat /tmp/release_notes.md | ||||
|  | ||||
|       - name: Delete existing release if it exists | ||||
|         run: | | ||||
|           VERSION="${{ steps.version.outputs.version }}" | ||||
|  | ||||
|           echo "Checking for existing release $VERSION..." | ||||
|  | ||||
|           # Try to get existing release by tag | ||||
|           EXISTING_RELEASE_ID=$(curl -s \ | ||||
|             -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|             "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \ | ||||
|             | jq -r '.id // empty') | ||||
|  | ||||
|           if [ -n "$EXISTING_RELEASE_ID" ]; then | ||||
|             echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..." | ||||
|             curl -X DELETE -s \ | ||||
|               -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|               "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$EXISTING_RELEASE_ID" | ||||
|             echo "Existing release deleted" | ||||
|             sleep 2 | ||||
|           else | ||||
|             echo "No existing release found, proceeding with creation" | ||||
|           fi | ||||
|  | ||||
|       - name: Create Gitea Release | ||||
|         run: | | ||||
|           VERSION="${{ steps.version.outputs.version }}" | ||||
|           RELEASE_NOTES=$(cat /tmp/release_notes.md) | ||||
|  | ||||
|           # Create the release | ||||
|           echo "Creating release for $VERSION..." | ||||
|           RELEASE_ID=$(curl -X POST -s \ | ||||
|             -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|             -H "Content-Type: application/json" \ | ||||
|             "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" \ | ||||
|             -d "{ | ||||
|               \"tag_name\": \"$VERSION\", | ||||
|               \"name\": \"NUPST $VERSION\", | ||||
|               \"body\": $(jq -Rs . /tmp/release_notes.md), | ||||
|               \"draft\": false, | ||||
|               \"prerelease\": false | ||||
|             }" | jq -r '.id') | ||||
|  | ||||
|           echo "Release created with ID: $RELEASE_ID" | ||||
|  | ||||
|           # Upload binaries as release assets | ||||
|           for binary in dist/binaries/*; do | ||||
|             filename=$(basename "$binary") | ||||
|             echo "Uploading $filename..." | ||||
|             curl -X POST -s \ | ||||
|               -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|               -H "Content-Type: application/octet-stream" \ | ||||
|               --data-binary "@$binary" \ | ||||
|               "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$RELEASE_ID/assets?name=$filename" | ||||
|           done | ||||
|  | ||||
|           echo "All assets uploaded successfully" | ||||
|  | ||||
|       - name: Clean up old releases | ||||
|         run: | | ||||
|           echo "Cleaning up old releases (keeping only last 3)..." | ||||
|  | ||||
|           # Fetch all releases sorted by creation date | ||||
|           RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|             "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases" | \ | ||||
|             jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id') | ||||
|  | ||||
|           # Delete old releases | ||||
|           if [ -n "$RELEASES" ]; then | ||||
|             echo "Found releases to delete:" | ||||
|             for release_id in $RELEASES; do | ||||
|               echo "  Deleting release ID: $release_id" | ||||
|               curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|                 "https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$release_id" | ||||
|             done | ||||
|             echo "Old releases deleted successfully" | ||||
|           else | ||||
|             echo "No old releases to delete (less than 4 releases total)" | ||||
|           fi | ||||
|           echo "" | ||||
|  | ||||
|       - name: Release Summary | ||||
|         run: | | ||||
|           echo "================================================" | ||||
|           echo "  Release ${{ steps.version.outputs.version }} Complete!" | ||||
|           echo "================================================" | ||||
|           echo "" | ||||
|           echo "Binaries published:" | ||||
|           ls -lh dist/binaries/ | ||||
|           echo "" | ||||
|           echo "Release URL:" | ||||
|           echo "https://code.foss.global/serve.zone/nupst/releases/tag/${{ steps.version.outputs.version }}" | ||||
|           echo "" | ||||
|           echo "Installation command:" | ||||
|           echo "curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash" | ||||
|           echo "" | ||||
							
								
								
									
										183
									
								
								.github/workflows/npm-publish.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								.github/workflows/npm-publish.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,183 @@ | ||||
| name: Publish to npm | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - 'v*.*.*' | ||||
|   workflow_dispatch: | ||||
|     inputs: | ||||
|       version: | ||||
|         description: 'Version to publish (e.g., 5.0.6)' | ||||
|         required: true | ||||
|         type: string | ||||
|  | ||||
| jobs: | ||||
|   build-and-publish: | ||||
|     runs-on: ubuntu-latest | ||||
|  | ||||
|     steps: | ||||
|       # Checkout the repository | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       # Setup Deno | ||||
|       - name: Setup Deno | ||||
|         uses: denoland/setup-deno@v1 | ||||
|         with: | ||||
|           deno-version: v1.x | ||||
|  | ||||
|       # Setup Node.js for npm publishing | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: '18.x' | ||||
|           registry-url: 'https://registry.npmjs.org/' | ||||
|  | ||||
|       # Compile binaries for all platforms | ||||
|       - name: Compile binaries | ||||
|         run: | | ||||
|           echo "Compiling binaries for all platforms..." | ||||
|           deno task compile | ||||
|           echo "" | ||||
|           echo "Binary sizes:" | ||||
|           ls -lh dist/binaries/ | ||||
|  | ||||
|       # Update version in package.json if triggered manually | ||||
|       - name: Update version in package.json | ||||
|         if: github.event_name == 'workflow_dispatch' | ||||
|         run: | | ||||
|           VERSION=${{ github.event.inputs.version }} | ||||
|           echo "Updating package.json to version ${VERSION}" | ||||
|           npm version ${VERSION} --no-git-tag-version | ||||
|  | ||||
|       # Extract version from tag if triggered by tag push | ||||
|       - name: Extract version from tag | ||||
|         if: startsWith(github.ref, 'refs/tags/') | ||||
|         run: | | ||||
|           VERSION=${GITHUB_REF#refs/tags/v} | ||||
|           echo "VERSION=${VERSION}" >> $GITHUB_ENV | ||||
|           echo "Extracted version: ${VERSION}" | ||||
|  | ||||
|       # Ensure versions are synchronized | ||||
|       - name: Sync versions | ||||
|         run: | | ||||
|           if [ -n "${VERSION}" ]; then | ||||
|             echo "Syncing version ${VERSION} across files..." | ||||
|  | ||||
|             # Update deno.json | ||||
|             sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" deno.json | ||||
|  | ||||
|             # Update package.json | ||||
|             npm version ${VERSION} --no-git-tag-version --allow-same-version | ||||
|  | ||||
|             echo "Updated versions:" | ||||
|             echo "deno.json: $(grep '"version"' deno.json)" | ||||
|             echo "package.json: $(grep '"version"' package.json | head -1)" | ||||
|           fi | ||||
|  | ||||
|       # Generate SHA256 checksums for binaries | ||||
|       - name: Generate checksums | ||||
|         run: | | ||||
|           cd dist/binaries | ||||
|           sha256sum * > SHA256SUMS | ||||
|           echo "Checksums generated:" | ||||
|           cat SHA256SUMS | ||||
|           cd ../.. | ||||
|  | ||||
|       # Create npm package | ||||
|       - name: Create npm package | ||||
|         run: | | ||||
|           echo "Creating npm package..." | ||||
|           npm pack | ||||
|           echo "" | ||||
|           echo "Package created:" | ||||
|           ls -lh *.tgz | ||||
|  | ||||
|       # Test package installation locally | ||||
|       - name: Test local installation | ||||
|         run: | | ||||
|           echo "Testing local package installation..." | ||||
|           PACKAGE_FILE=$(ls *.tgz) | ||||
|           npm install -g ${PACKAGE_FILE} | ||||
|  | ||||
|           echo "" | ||||
|           echo "Testing nupst command:" | ||||
|           nupst --version || echo "Note: Binary execution may fail in CI environment" | ||||
|  | ||||
|           echo "" | ||||
|           echo "Checking installed files:" | ||||
|           npm ls -g @serve.zone/nupst | ||||
|  | ||||
|       # Publish to npm (only on tag push or manual trigger) | ||||
|       - name: Publish to npm | ||||
|         if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' | ||||
|         env: | ||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | ||||
|         run: | | ||||
|           echo "Publishing to npm registry..." | ||||
|           npm publish --access public | ||||
|  | ||||
|           echo "" | ||||
|           echo "✅ Successfully published @serve.zone/nupst to npm!" | ||||
|           echo "" | ||||
|           echo "Package info:" | ||||
|           npm view @serve.zone/nupst | ||||
|  | ||||
|       # Create GitHub Release (only on tag push) | ||||
|       - name: Create GitHub Release | ||||
|         if: startsWith(github.ref, 'refs/tags/') | ||||
|         uses: softprops/action-gh-release@v1 | ||||
|         with: | ||||
|           files: | | ||||
|             dist/binaries/nupst-* | ||||
|             dist/binaries/SHA256SUMS | ||||
|             *.tgz | ||||
|           generate_release_notes: true | ||||
|           body: | | ||||
|             ## NUPST ${{ env.VERSION }} | ||||
|  | ||||
|             ### Installation | ||||
|  | ||||
|             #### Via npm (recommended) | ||||
|             ```bash | ||||
|             npm install -g @serve.zone/nupst | ||||
|             ``` | ||||
|  | ||||
|             #### Direct download | ||||
|             Download the appropriate binary for your platform from the assets below. | ||||
|  | ||||
|             ### Platform Support | ||||
|             - Linux x64 / ARM64 | ||||
|             - macOS x64 / ARM64 (Apple Silicon) | ||||
|             - Windows x64 | ||||
|  | ||||
|             ### Checksums | ||||
|             SHA256 checksums are available in `SHA256SUMS` file. | ||||
|  | ||||
|   # Verify the published package | ||||
|   verify: | ||||
|     needs: build-and-publish | ||||
|     runs-on: ubuntu-latest | ||||
|     if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' | ||||
|  | ||||
|     steps: | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: '18.x' | ||||
|  | ||||
|       - name: Wait for npm propagation | ||||
|         run: sleep 30 | ||||
|  | ||||
|       - name: Verify npm package | ||||
|         run: | | ||||
|           echo "Verifying published package..." | ||||
|           npm view @serve.zone/nupst | ||||
|  | ||||
|           echo "" | ||||
|           echo "Testing installation from npm:" | ||||
|           npm install -g @serve.zone/nupst | ||||
|  | ||||
|           echo "" | ||||
|           echo "Package installed successfully!" | ||||
|           which nupst || echo "Binary location check skipped" | ||||
							
								
								
									
										18
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,15 +1,18 @@ | ||||
| # Build | ||||
| dist*/ | ||||
| # Compiled Deno binaries (built by scripts/compile-all.sh) | ||||
| dist/binaries/ | ||||
|  | ||||
| # Dependencies | ||||
| # Deno cache and lock file | ||||
| .deno/ | ||||
| deno.lock | ||||
|  | ||||
| # Legacy Node.js artifacts (v3.x and earlier - kept for safety) | ||||
| node_modules/ | ||||
|  | ||||
| # Bundled Node.js binaries | ||||
| vendor/ | ||||
| dist_ts/ | ||||
| npm-debug.log* | ||||
|  | ||||
| # Logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
|  | ||||
| # Environment | ||||
| .env | ||||
| @@ -18,4 +21,5 @@ npm-debug.log* | ||||
| .DS_Store | ||||
| Thumbs.db | ||||
|  | ||||
| .nogit/ | ||||
| # Development | ||||
| .nogit/ | ||||
|   | ||||
							
								
								
									
										54
									
								
								.npmignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								.npmignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| # Source code (not needed for binary distribution) | ||||
| /ts/ | ||||
| /test/ | ||||
| mod.ts | ||||
| *.ts | ||||
|  | ||||
| # Development files | ||||
| .git/ | ||||
| .gitea/ | ||||
| .claude/ | ||||
| .serena/ | ||||
| .nogit/ | ||||
| .github/ | ||||
| deno.json | ||||
| deno.lock | ||||
| tsconfig.json | ||||
|  | ||||
| # Scripts not needed for npm | ||||
| /scripts/compile-all.sh | ||||
| install.sh | ||||
| uninstall.sh | ||||
| example-action.sh | ||||
|  | ||||
| # Documentation files not needed for npm package | ||||
| readme.plan.md | ||||
| readme.hints.md | ||||
| npm-publish-instructions.md | ||||
| docs/ | ||||
|  | ||||
| # IDE and editor files | ||||
| .vscode/ | ||||
| .idea/ | ||||
| *.swp | ||||
| *.swo | ||||
| *~ | ||||
| .DS_Store | ||||
|  | ||||
| # Keep only the install-binary.js in scripts/ | ||||
| /scripts/* | ||||
| !/scripts/install-binary.js | ||||
|  | ||||
| # Exclude all dist directory (binaries will be downloaded during install) | ||||
| /dist/ | ||||
|  | ||||
| # Logs and temporary files | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
|  | ||||
| # Other | ||||
| node_modules/ | ||||
| .env | ||||
| .env.* | ||||
							
								
								
									
										1
									
								
								.serena/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.serena/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| /cache | ||||
							
								
								
									
										71
									
								
								.serena/project.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								.serena/project.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| # language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) | ||||
| #  * For C, use cpp | ||||
| #  * For JavaScript, use typescript | ||||
| # Special requirements: | ||||
| #  * csharp: Requires the presence of a .sln file in the project folder. | ||||
| language: typescript | ||||
|  | ||||
| # the encoding used by text files in the project | ||||
| # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings | ||||
| encoding: 'utf-8' | ||||
|  | ||||
| # whether to use the project's gitignore file to ignore files | ||||
| # Added on 2025-04-07 | ||||
| ignore_all_files_in_gitignore: true | ||||
| # list of additional paths to ignore | ||||
| # same syntax as gitignore, so you can use * and ** | ||||
| # Was previously called `ignored_dirs`, please update your config if you are using that. | ||||
| # Added (renamed) on 2025-04-07 | ||||
| ignored_paths: [] | ||||
|  | ||||
| # whether the project is in read-only mode | ||||
| # If set to true, all editing tools will be disabled and attempts to use them will result in an error | ||||
| # Added on 2025-04-18 | ||||
| read_only: false | ||||
|  | ||||
| # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. | ||||
| # Below is the complete list of tools for convenience. | ||||
| # To make sure you have the latest list of tools, and to view their descriptions, | ||||
| # execute `uv run scripts/print_tool_overview.py`. | ||||
| # | ||||
| #  * `activate_project`: Activates a project by name. | ||||
| #  * `check_onboarding_performed`: Checks whether project onboarding was already performed. | ||||
| #  * `create_text_file`: Creates/overwrites a file in the project directory. | ||||
| #  * `delete_lines`: Deletes a range of lines within a file. | ||||
| #  * `delete_memory`: Deletes a memory from Serena's project-specific memory store. | ||||
| #  * `execute_shell_command`: Executes a shell command. | ||||
| #  * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. | ||||
| #  * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). | ||||
| #  * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). | ||||
| #  * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. | ||||
| #  * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. | ||||
| #  * `initial_instructions`: Gets the initial instructions for the current project. | ||||
| #     Should only be used in settings where the system prompt cannot be set, | ||||
| #     e.g. in clients you have no control over, like Claude Desktop. | ||||
| #  * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. | ||||
| #  * `insert_at_line`: Inserts content at a given line in a file. | ||||
| #  * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. | ||||
| #  * `list_dir`: Lists files and directories in the given directory (optionally with recursion). | ||||
| #  * `list_memories`: Lists memories in Serena's project-specific memory store. | ||||
| #  * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). | ||||
| #  * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). | ||||
| #  * `read_file`: Reads a file within the project directory. | ||||
| #  * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. | ||||
| #  * `remove_project`: Removes a project from the Serena configuration. | ||||
| #  * `replace_lines`: Replaces a range of lines within a file with new content. | ||||
| #  * `replace_symbol_body`: Replaces the full definition of a symbol. | ||||
| #  * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. | ||||
| #  * `search_for_pattern`: Performs a search for a pattern in the project. | ||||
| #  * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. | ||||
| #  * `switch_modes`: Activates modes by providing a list of their names | ||||
| #  * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. | ||||
| #  * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. | ||||
| #  * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. | ||||
| #  * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. | ||||
| excluded_tools: [] | ||||
|  | ||||
| # initial prompt for the project. It will always be given to the LLM upon activating the project | ||||
| # (contrary to the memories, which are loaded on demand). | ||||
| initial_prompt: '' | ||||
|  | ||||
| project_name: 'nupst' | ||||
							
								
								
									
										49
									
								
								bin/nupst
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								bin/nupst
									
									
									
									
									
								
							| @@ -1,49 +0,0 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # NUPST Launcher Script | ||||
| # This script detects architecture and OS, then runs NUPST with the appropriate Node.js binary | ||||
|  | ||||
| # First, handle symlinks correctly | ||||
| REAL_SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}") | ||||
| SCRIPT_DIR=$(dirname "$REAL_SCRIPT_PATH") | ||||
|  | ||||
| # For debugging | ||||
| # echo "Script path: $REAL_SCRIPT_PATH" | ||||
| # echo "Script dir: $SCRIPT_DIR" | ||||
|  | ||||
| # If we're run via symlink from /usr/local/bin, use the hardcoded installation path | ||||
| if [[ "$SCRIPT_DIR" == "/usr/local/bin" ]]; then | ||||
|   PROJECT_ROOT="/opt/nupst" | ||||
| else | ||||
|   # Otherwise, use relative path from script location | ||||
|   PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )" | ||||
| fi | ||||
|  | ||||
| # For debugging | ||||
| # echo "Project root: $PROJECT_ROOT" | ||||
|  | ||||
| # Set Node.js binary path directly | ||||
| NODE_BIN="$PROJECT_ROOT/vendor/node-linux-x64/bin/node" | ||||
|  | ||||
| # If binary doesn't exist, try system Node as fallback | ||||
| if [ ! -f "$NODE_BIN" ]; then | ||||
|   if command -v node &> /dev/null; then | ||||
|     NODE_BIN="node" | ||||
|     echo "Using system Node.js installation" | ||||
|   else   | ||||
|     echo "Error: Node.js binary not found at $NODE_BIN" | ||||
|     echo "Please run the setup script or install Node.js manually." | ||||
|     exit 1 | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| # Run NUPST with the Node.js binary | ||||
| if [ -f "$PROJECT_ROOT/dist_ts/index.js" ]; then | ||||
|   exec "$NODE_BIN" "$PROJECT_ROOT/dist_ts/index.js" "$@" | ||||
| elif [ -f "$PROJECT_ROOT/dist/index.js" ]; then | ||||
|   exec "$NODE_BIN" "$PROJECT_ROOT/dist/index.js" "$@" | ||||
| else | ||||
|   echo "Error: Could not find NUPST's index.js file." | ||||
|   echo "Please run the setup script to download the required files." | ||||
|   exit 1 | ||||
| fi | ||||
							
								
								
									
										108
									
								
								bin/nupst-wrapper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								bin/nupst-wrapper.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| #!/usr/bin/env node | ||||
|  | ||||
| /** | ||||
|  * NUPST npm wrapper | ||||
|  * This script executes the appropriate pre-compiled binary based on the current platform | ||||
|  */ | ||||
|  | ||||
| import { spawn } from 'child_process'; | ||||
| import { fileURLToPath } from 'url'; | ||||
| import { dirname, join } from 'path'; | ||||
| import { existsSync } from 'fs'; | ||||
| import { platform, arch } from 'os'; | ||||
|  | ||||
| const __filename = fileURLToPath(import.meta.url); | ||||
| const __dirname = dirname(__filename); | ||||
|  | ||||
| /** | ||||
|  * Get the binary name for the current platform | ||||
|  */ | ||||
| function getBinaryName() { | ||||
|   const plat = platform(); | ||||
|   const architecture = arch(); | ||||
|  | ||||
|   // Map Node's platform/arch to our binary naming | ||||
|   const platformMap = { | ||||
|     'darwin': 'macos', | ||||
|     'linux': 'linux', | ||||
|     'win32': 'windows' | ||||
|   }; | ||||
|  | ||||
|   const archMap = { | ||||
|     'x64': 'x64', | ||||
|     'arm64': 'arm64' | ||||
|   }; | ||||
|  | ||||
|   const mappedPlatform = platformMap[plat]; | ||||
|   const mappedArch = archMap[architecture]; | ||||
|  | ||||
|   if (!mappedPlatform || !mappedArch) { | ||||
|     console.error(`Error: Unsupported platform/architecture: ${plat}/${architecture}`); | ||||
|     console.error('Supported platforms: Linux, macOS, Windows'); | ||||
|     console.error('Supported architectures: x64, arm64'); | ||||
|     process.exit(1); | ||||
|   } | ||||
|  | ||||
|   // Construct binary name | ||||
|   let binaryName = `nupst-${mappedPlatform}-${mappedArch}`; | ||||
|   if (plat === 'win32') { | ||||
|     binaryName += '.exe'; | ||||
|   } | ||||
|  | ||||
|   return binaryName; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Execute the binary | ||||
|  */ | ||||
| function executeBinary() { | ||||
|   const binaryName = getBinaryName(); | ||||
|   const binaryPath = join(__dirname, '..', 'dist', 'binaries', binaryName); | ||||
|  | ||||
|   // Check if binary exists | ||||
|   if (!existsSync(binaryPath)) { | ||||
|     console.error(`Error: Binary not found at ${binaryPath}`); | ||||
|     console.error('This might happen if:'); | ||||
|     console.error('1. The postinstall script failed to run'); | ||||
|     console.error('2. The platform is not supported'); | ||||
|     console.error('3. The package was not installed correctly'); | ||||
|     console.error(''); | ||||
|     console.error('Try reinstalling the package:'); | ||||
|     console.error('  npm uninstall -g @serve.zone/nupst'); | ||||
|     console.error('  npm install -g @serve.zone/nupst'); | ||||
|     process.exit(1); | ||||
|   } | ||||
|  | ||||
|   // Spawn the binary with all arguments passed through | ||||
|   const child = spawn(binaryPath, process.argv.slice(2), { | ||||
|     stdio: 'inherit', | ||||
|     shell: false | ||||
|   }); | ||||
|  | ||||
|   // Handle child process events | ||||
|   child.on('error', (err) => { | ||||
|     console.error(`Error executing nupst: ${err.message}`); | ||||
|     process.exit(1); | ||||
|   }); | ||||
|  | ||||
|   child.on('exit', (code, signal) => { | ||||
|     if (signal) { | ||||
|       process.kill(process.pid, signal); | ||||
|     } else { | ||||
|       process.exit(code || 0); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   // Forward signals to child process | ||||
|   const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; | ||||
|   signals.forEach(signal => { | ||||
|     process.on(signal, () => { | ||||
|       if (!child.killed) { | ||||
|         child.kill(signal); | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| // Execute | ||||
| executeBinary(); | ||||
							
								
								
									
										600
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										600
									
								
								changelog.md
									
									
									
									
									
								
							| @@ -1,86 +1,569 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 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) | ||||
|  | ||||
| ## 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) | ||||
| - 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 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 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 | ||||
|  | ||||
| **MAJOR RELEASE: NUPST v4.0 is a complete rewrite powered by Deno** | ||||
|  | ||||
| This release fundamentally changes NUPST's architecture from Node.js-based to Deno-based, | ||||
| distributed as pre-compiled binaries. This is a **breaking change** in terms of installation and | ||||
| distribution, but configuration files from v3.x are **fully compatible**. | ||||
|  | ||||
| ### Breaking Changes | ||||
|  | ||||
| **Installation & Distribution:** | ||||
|  | ||||
| - **Removed**: Node.js runtime dependency - NUPST no longer requires Node.js | ||||
| - **Removed**: npm package distribution (no longer published to npmjs.org) | ||||
| - **Removed**: `bin/nupst` wrapper script | ||||
| - **Removed**: `setup.sh` dependency installation | ||||
| - **Removed**: All Node.js-related files (package.json, tsconfig.json, pnpm-lock.yaml, | ||||
|   npmextra.json) | ||||
| - **Changed**: Installation now downloads pre-compiled binaries instead of cloning repository | ||||
| - **Changed**: Binary-based distribution (~340MB self-contained executables) | ||||
|  | ||||
| **CLI Structure (Backward Compatible):** | ||||
|  | ||||
| - **Changed**: Commands now use subcommand structure (e.g., `nupst service enable` instead of | ||||
|   `nupst enable`) | ||||
| - **Maintained**: Old command format still works with deprecation warnings for smooth migration | ||||
| - **Added**: Aliases for common commands (`nupst ls`, `nupst rm`) | ||||
|  | ||||
| ### New Features | ||||
|  | ||||
| **Distribution & Installation:** | ||||
|  | ||||
| - Pre-compiled binaries for 5 platforms: | ||||
|   - Linux x86_64 | ||||
|   - Linux ARM64 | ||||
|   - macOS x86_64 (Intel) | ||||
|   - macOS ARM64 (Apple Silicon) | ||||
|   - Windows x86_64 | ||||
| - Automated binary releases via Gitea Actions | ||||
| - SHA256 checksum verification for all releases | ||||
| - Installation from Gitea releases via updated `install.sh` | ||||
| - Zero dependencies - completely self-contained binaries | ||||
| - Cross-platform compilation from single codebase | ||||
|  | ||||
| **CI/CD Automation:** | ||||
|  | ||||
| - Gitea Actions workflows for continuous integration | ||||
| - Automated release workflow triggered by git tags | ||||
| - Automatic binary compilation for all platforms on release | ||||
| - Type checking and linting in CI pipeline | ||||
| - Build verification on every push | ||||
|  | ||||
| **CLI Improvements:** | ||||
|  | ||||
| - New hierarchical command structure with subcommands | ||||
| - `nupst service` - Service management (enable, disable, start, stop, restart, status, logs) | ||||
| - `nupst ups` - UPS device management (add, edit, remove, list, test) | ||||
| - `nupst group` - Group management (add, edit, remove, list) | ||||
| - `nupst config show` - Display configuration | ||||
| - `nupst --version` - Show version information | ||||
| - Better help messages organized by category | ||||
| - Backward compatibility maintained with deprecation warnings | ||||
|  | ||||
| **Technical Improvements:** | ||||
|  | ||||
| - Deno runtime for modern TypeScript/JavaScript execution | ||||
| - Native TypeScript support without compilation step | ||||
| - Faster startup and execution compared to Node.js | ||||
| - Smaller memory footprint | ||||
| - Built-in permissions system | ||||
| - No build step required for development | ||||
|  | ||||
| ### Migration Guide | ||||
|  | ||||
| **For Users:** | ||||
|  | ||||
| 1. Stop existing v3.x service: `sudo nupst disable` | ||||
| 2. Install v4.0 using new installer: | ||||
|    `curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y` | ||||
| 3. Your configuration at `/etc/nupst/config.json` is preserved and fully compatible | ||||
| 4. Enable service with new CLI: `sudo nupst service enable && sudo nupst service start` | ||||
| 5. Update systemd commands to use new syntax (old syntax still works with warnings) | ||||
|  | ||||
| **Configuration Compatibility:** | ||||
|  | ||||
| - All configuration files from v3.x work without modification | ||||
| - No changes to `/etc/nupst/config.json` format | ||||
| - All SNMP settings, thresholds, and group configurations preserved | ||||
|  | ||||
| **Command Mapping:** | ||||
|  | ||||
| ```bash | ||||
| # Old (v3.x)              → New (v4.0) | ||||
| nupst enable              → nupst service enable | ||||
| nupst disable             → nupst service disable | ||||
| nupst start               → nupst service start | ||||
| nupst stop                → nupst service stop | ||||
| nupst status              → nupst service status | ||||
| nupst logs                → nupst service logs | ||||
| nupst add                 → nupst ups add | ||||
| nupst edit [id]           → nupst ups edit [id] | ||||
| nupst delete <id>         → nupst ups remove <id> | ||||
| nupst list                → nupst ups list | ||||
| nupst test                → nupst ups test | ||||
| nupst group add           → nupst group add (unchanged) | ||||
| nupst group edit <id>     → nupst group edit <id> (unchanged) | ||||
| nupst group delete <id>   → nupst group remove <id> | ||||
| nupst group list          → nupst group list (unchanged) | ||||
| nupst config              → nupst config show | ||||
| ``` | ||||
|  | ||||
| ### Technical Details | ||||
|  | ||||
| **Commit History:** | ||||
|  | ||||
| - `df6a44d`: Complete migration with Gitea Actions workflows and install.sh updates | ||||
| - `9efcc4b`: CLI reorganization with subcommand structure | ||||
| - `5903ae7`: Cross-platform compilation scripts | ||||
| - `a649c59`: Deno migration with npm: and node: specifiers | ||||
| - `5f4f3ec`: Initial migration to Deno | ||||
|  | ||||
| **Files Changed:** | ||||
|  | ||||
| - Removed: 11 files (package.json, tsconfig.json, pnpm-lock.yaml, npmextra.json, bin/nupst, | ||||
|   setup.sh) | ||||
| - Added: 3 Gitea Actions workflows (ci.yml, release.yml, README.md) | ||||
| - Modified: 14 TypeScript files for Deno compatibility | ||||
| - Updated: install.sh, .gitignore, readme.md | ||||
| - Net reduction: -10,242 lines (93% reduction in repository size) | ||||
|  | ||||
| **Dependencies:** | ||||
|  | ||||
| - Runtime: Deno v1.x (bundled in binary, no installation required) | ||||
| - SNMP: npm:net-snmp@3.20.0 (bundled in binary via npm: specifier) | ||||
| - Node.js built-ins: Accessed via node: specifier (node:fs, node:child_process, etc.) | ||||
|  | ||||
| ### Benefits | ||||
|  | ||||
| **For Users:** | ||||
|  | ||||
| - **Faster Installation**: Download single binary instead of cloning repo + installing Node.js + npm | ||||
|   dependencies | ||||
| - **Zero Dependencies**: No Node.js or npm required on target system | ||||
| - **Smaller Footprint**: Single binary vs repo + Node.js + node_modules | ||||
| - **Easier Updates**: Download new binary instead of git pull + npm install | ||||
| - **Better Security**: No npm supply chain risks, binary checksums provided | ||||
| - **Platform Support**: Official binaries for all major platforms | ||||
|  | ||||
| **For Developers:** | ||||
|  | ||||
| - **Modern Tooling**: Native TypeScript support without build configuration | ||||
| - **Faster Development**: No compilation step during development | ||||
| - **CI/CD Automation**: Automated releases and testing | ||||
| - **Cleaner Codebase**: 93% reduction in configuration files | ||||
| - **Cross-Platform**: Compile for all platforms from any platform | ||||
|  | ||||
| ### Known Issues | ||||
|  | ||||
| - Windows ARM64 not supported (Deno limitation) | ||||
| - Binary sizes are larger (~340MB) due to bundled runtime (trade-off for zero dependencies) | ||||
| - First-time execution may be slower on some systems (binary extraction) | ||||
|  | ||||
| ### Acknowledgments | ||||
|  | ||||
| This release represents a complete modernization of NUPST's infrastructure while maintaining full | ||||
| backward compatibility for user configurations. Special thanks to the Deno team for creating an | ||||
| excellent runtime that made this migration possible. | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 2025-03-28 - 3.1.2 - fix(cli/ups-handler) | ||||
|  | ||||
| Improve UPS device listing table formatting for better column alignment | ||||
|  | ||||
| - Adjusted header spacing for the Host column and overall table alignment in the UPS handler output. | ||||
|  | ||||
| ## 2025-03-28 - 3.1.1 - fix(cli) | ||||
|  | ||||
| Improve table header formatting in group and UPS listings | ||||
|  | ||||
| - Adjusted column padding in group listing for proper alignment | ||||
| - Fixed UPS table header spacing for consistent CLI output | ||||
|  | ||||
| ## 2025-03-28 - 3.1.0 - feat(cli) | ||||
|  | ||||
| Refactor CLI commands to use dedicated handlers for UPS, group, and service management | ||||
|  | ||||
| - Extracted UPS-related CLI logic into a new UpsHandler | ||||
| - Introduced GroupHandler to manage UPS groups commands | ||||
| - Added ServiceHandler for systemd service operations | ||||
| - Updated CLI routing in cli.ts to delegate commands to the new handlers | ||||
| - Exposed getters for the new handlers in the Nupst class | ||||
|  | ||||
| ## 2025-03-28 - 3.0.1 - fix(cli) | ||||
|  | ||||
| Simplify UPS ID generation by removing the redundant promptForUniqueUpsId function in the CLI module | ||||
| and replacing it with the shortId helper. | ||||
|  | ||||
| - Deleted the unused promptForUniqueUpsId method from ts/cli.ts. | ||||
| - Updated UPS configuration to generate a unique ID directly using helpers.shortId(). | ||||
| - Improved code clarity by removing unnecessary interactive prompts for UPS IDs. | ||||
|  | ||||
| ## 2025-03-28 - 3.0.0 - BREAKING CHANGE(core) | ||||
|  | ||||
| Add multi-UPS support and group management; update CLI, configuration and documentation to support | ||||
| multiple UPS devices with group modes | ||||
|  | ||||
| - Implemented multi-UPS configuration with an array of UPS devices and groups in the configuration | ||||
|   file | ||||
| - Added group management commands (group add, edit, delete, list) with redundant and non-redundant | ||||
|   modes | ||||
| - Revamped CLI command parsing for UPS management (add, edit, delete, list, setup) and group | ||||
|   subcommands | ||||
| - Updated readme and documentation to reflect new configuration structure and features | ||||
| - Enhanced logging and status display for multiple UPS devices | ||||
|  | ||||
| ## 2025-03-26 - 2.6.17 - fix(logger) | ||||
|  | ||||
| Preserve logbox width after logBoxEnd so that subsequent logBoxLine calls continue using the set | ||||
| width. | ||||
|  | ||||
| - Removed the reset of currentBoxWidth in logBoxEnd to allow persistent width across logbox calls. | ||||
| - Ensures that logBoxLine uses the previously set width when no new width is provided. | ||||
|  | ||||
| ## 2025-03-26 - 2.6.16 - fix(cli) | ||||
|  | ||||
| Improve CLI logging consistency by replacing direct console output with unified logger calls. | ||||
|  | ||||
| - Replaced console.log and console.error with logger.log and logger.error in CLI commands | ||||
| - Standardized debug, error, and status messages using logger's logbox utilities | ||||
| - Enhanced consistency of log output throughout the ts/cli.ts file | ||||
|  | ||||
| ## 2025-03-26 - 2.6.15 - fix(logger) | ||||
|  | ||||
| Replace direct console logging with unified logger interface for consistent formatting | ||||
|  | ||||
| - Substitute console.log, console.error, and related calls with logger methods in cli, daemon, | ||||
|   systemd, nupst, and index modules | ||||
| - Integrate logBox formatting for structured output and consistent log presentation | ||||
| - Update test expectations in test.logger.ts to check for standardized error messages | ||||
| - Refactor logging calls throughout the codebase for improved clarity and maintainability | ||||
|  | ||||
| ## 2025-03-26 - 2.6.14 - fix(systemd) | ||||
|  | ||||
| Shorten closing log divider in systemd service installation output for consistent formatting. | ||||
|  | ||||
| - Replaced the overly long footer with a shorter one in ts/systemd.ts. | ||||
| - This change improves log readability without affecting functionality. | ||||
|  | ||||
| ## 2025-03-26 - 2.6.13 - fix(cli) | ||||
|  | ||||
| Fix CLI update output box formatting | ||||
|  | ||||
| - Adjusted the closing box line in the update process log messages for consistent visual formatting | ||||
|  | ||||
| ## 2025-03-26 - 2.6.12 - fix(systemd) | ||||
|  | ||||
| Adjust logging border in systemd service installation output | ||||
|  | ||||
| - Updated the closing border line for consistent output formatting in ts/systemd.ts | ||||
|  | ||||
| ## 2025-03-26 - 2.6.11 - fix(cli, systemd) | ||||
|  | ||||
| Adjust log formatting for consistent output in CLI and systemd commands | ||||
|  | ||||
| - Fixed spacing issues in service installation and status log messages in the systemd module. | ||||
| - Revised output formatting in the CLI to improve message clarity. | ||||
|  | ||||
| ## 2025-03-26 - 2.6.10 - fix(daemon) | ||||
|  | ||||
| Adjust console log box formatting for consistent output in daemon status messages | ||||
|  | ||||
| - Updated closing box borders to align properly in configuration error, periodic updates, and UPS | ||||
|   status logs | ||||
| - Improved visual consistency in log messages | ||||
|  | ||||
| ## 2025-03-26 - 2.6.9 - fix(cli) | ||||
|  | ||||
| Improve console output formatting for status banners and logging messages | ||||
|  | ||||
| - Standardize banner messages in daemon status updates | ||||
| - Refine version information banner in nupst logging | ||||
| - Update UPS connection and status banners in systemd | ||||
|  | ||||
| ## 2025-03-26 - 2.6.8 - fix(cli) | ||||
|  | ||||
| Improve CLI formatting, refine debug option filtering, and remove unused dgram import in SNMP | ||||
| manager | ||||
|  | ||||
| - Standardize whitespace and formatting in ts/cli.ts for consistency | ||||
| - Refine argument filtering for debug mode and prompt messages | ||||
| - Remove unused 'dgram' import from ts/snmp/manager.ts | ||||
|  | ||||
| ## 2025-03-26 - 2.6.7 - fix(setup.sh) | ||||
|  | ||||
| Clarify net-snmp dependency installation message in setup.sh | ||||
|  | ||||
| - Updated echo statement to indicate installation of net-snmp along with 2 subdependencies | ||||
| - Improves clarity on dependency installation during setup | ||||
|  | ||||
| ## 2025-03-26 - 2.6.6 - fix(setup.sh) | ||||
|  | ||||
| Improve setup script to detect and execute npm-cli.js directly using the Node.js binary | ||||
|  | ||||
| - Replace use of the npm binary with direct execution of npm-cli.js | ||||
| - Add fallback logic to locate npm-cli.js when not found at the expected path | ||||
| - Simplify cleanup by removing unnecessary PATH modifications | ||||
|  | ||||
| ## 2025-03-26 - 2.6.5 - fix(daemon, setup) | ||||
|  | ||||
| Improve shutdown command detection and fallback logic; update setup script to use absolute Node/npm | ||||
| paths | ||||
|  | ||||
| - Use execFileAsync to execute shutdown commands reliably | ||||
| - Add multiple fallback alternatives for shutdown and emergency shutdown handling | ||||
| - Update setup.sh to log the Node and NPM versions using absolute paths without modifying PATH | ||||
|  | ||||
| ## 2025-03-26 - 2.6.4 - fix(setup) | ||||
|  | ||||
| Improve installation process in setup script by cleaning up package files and ensuring a minimal | ||||
| net-snmp dependency installation. | ||||
|  | ||||
| - Remove existing package-lock.json along with node_modules to prevent stale artifacts. | ||||
| - Back up the original package.json before modifying it. | ||||
| - Create a minimal package.json with only the net-snmp dependency based on the backed-up version. | ||||
| - Use a clean install to guarantee that only net-snmp is installed. | ||||
| - Restore the original package.json if the installation fails. | ||||
|  | ||||
| ## 2025-03-26 - 2.6.3 - fix(setup) | ||||
|  | ||||
| Update setup script to install only net-snmp dependency and create a minimal package-lock.json for | ||||
| better dependency control. | ||||
|  | ||||
| - Removed full production dependency install in favor of installing only net-snmp@3.20.0 | ||||
| - Added verification step to confirm net-snmp installation | ||||
| - Generate a minimal package-lock.json if one does not exist | ||||
|  | ||||
| ## 2025-03-26 - 2.6.2 - fix(setup/readme) | ||||
|  | ||||
| Improve force update instructions and dependency installation process in setup.sh and readme.md | ||||
|  | ||||
| - Clarify force update commands with explicit paths in readme.md | ||||
| - Remove existing node_modules before installing dependencies in setup.sh | ||||
| - Switch from 'npm ci --only=production' to 'npm install --omit=dev' with updated error instructions | ||||
|  | ||||
| ## 2025-03-26 - 2.6.1 - fix(setup) | ||||
|  | ||||
| Update setup.sh to temporarily add vendor Node.js binary to PATH for dependency installation, log | ||||
| Node and npm versions, and restore the original PATH afterwards. | ||||
|  | ||||
| - Temporarily prepend vendor Node.js binary directory to PATH to ensure proper npm execution. | ||||
| - Log Node.js and npm versions for debugging purposes. | ||||
| - Restore the original PATH after installing dependencies. | ||||
|  | ||||
| ## 2025-03-26 - 2.6.0 - feat(setup) | ||||
|  | ||||
| Add --force update flag to setup script and update installation instructions | ||||
|  | ||||
| - Implemented --force option in setup.sh to force-update Node.js binary and dependencies | ||||
| - Updated readme.md to document the --force flag and revised update steps | ||||
| - Modified ts/cli.ts update command to pass the --force flag to setup.sh | ||||
|  | ||||
| ## 2025-03-26 - 2.5.2 - fix(installer) | ||||
|  | ||||
| Improve Node.js binary detection, dependency management, and SNMPv3 fallback logic | ||||
|  | ||||
| - Enhanced bin/nupst to detect OS and architecture (Linux and Darwin) and fall back to system | ||||
|   Node.js for unsupported platforms | ||||
| - Moved net-snmp from devDependencies to dependencies in package.json | ||||
| - Updated setup.sh to install production dependencies and handle installation errors gracefully | ||||
| - Refined SNMPv3 user configuration and fallback mechanism in ts/snmp/manager.ts | ||||
| - Revised README to clarify minimal runtime dependencies and secure SNMP features | ||||
|  | ||||
| ## 2025-03-25 - 2.5.1 - fix(snmp) | ||||
|  | ||||
| Fix Eaton UPS support by updating power status OID and adjusting battery runtime conversion. | ||||
|  | ||||
| - Updated Eaton UPS power status OID to '1.3.6.1.4.1.534.1.4.4.0' to correctly detect online/battery | ||||
|   status. | ||||
| - Added conversion for Eaton UPS battery runtime from seconds to minutes in SNMP manager. | ||||
|  | ||||
| ## 2025-03-25 - 2.5.0 - feat(cli) | ||||
|  | ||||
| Automatically restart running NUPST service after configuration changes in interactive setup | ||||
|  | ||||
| - Added restartServiceIfRunning() to check and restart the service if it's active. | ||||
| - Invoked the restart function post-setup to apply configuration changes immediately. | ||||
|  | ||||
| ## 2025-03-25 - 2.4.8 - fix(installer) | ||||
|  | ||||
| Improve Git dependency handling and repository cloning in install.sh | ||||
|  | ||||
| - Add explicit check for git installation and prompt the user interactively if git is missing. | ||||
| - Auto-install git when '-y' flag is provided in non-interactive mode. | ||||
| - Ensure proper cloning of the repository when running the installer outside the repo. | ||||
|  | ||||
| ## 2025-03-25 - 2.4.7 - fix(readme) | ||||
|  | ||||
| Update installation instructions to combine download and execution into a single command for clarity | ||||
|  | ||||
| - Method 1 now uses a unified one-line command to download and run the install script | ||||
|  | ||||
| ## 2025-03-25 - 2.4.6 - fix(installer) | ||||
|  | ||||
| Improve installation instructions for interactive and non-interactive setups | ||||
|  | ||||
| - Changed install.sh to require explicit download of the install script and updated error messages | ||||
|   for non-interactive modes | ||||
| - Updated readme.md to include three distinct installation methods with clear command examples | ||||
|  | ||||
| ## 2025-03-25 - 2.4.5 - fix(install) | ||||
|  | ||||
| Improve interactive terminal detection and update installation instructions | ||||
|  | ||||
| - Enhanced install.sh to better detect non-interactive environments and provide clearer guidance for | ||||
|   both interactive and non-interactive installations | ||||
| - Updated README.md quick install instructions to recommend process substitution and clarify | ||||
|   auto-yes usage | ||||
|  | ||||
| ## 2025-03-25 - 2.4.4 - fix(install) | ||||
|  | ||||
| Improve interactive mode detection and non-interactive installation handling in install.sh | ||||
|  | ||||
| - Detect and warn when running without a controlling terminal | ||||
| - Attempt to use /dev/tty for user input when possible | ||||
| - Update prompts and error messages for auto-installation of dependencies | ||||
| - Clarify installation instructions in readme for interactive and non-interactive modes | ||||
|  | ||||
| ## 2025-03-25 - 2.4.3 - fix(readme) | ||||
|  | ||||
| Update Quick Install command syntax in readme for auto-yes installation | ||||
|  | ||||
| - Changed installation command to use: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -c "bash -s -- -y" | ||||
| - Changed installation command to use: curl -sSL | ||||
|   https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -c "bash -s -- | ||||
|   -y" | ||||
|  | ||||
| ## 2025-03-25 - 2.4.2 - fix(daemon) | ||||
| Refactor shutdown initiation logic in daemon by moving the initiateShutdown and monitorDuringShutdown methods from the SNMP manager to the daemon, and update calls accordingly | ||||
|  | ||||
| Refactor shutdown initiation logic in daemon by moving the initiateShutdown and | ||||
| monitorDuringShutdown methods from the SNMP manager to the daemon, and update calls accordingly | ||||
|  | ||||
| - Moved initiateShutdown and monitorDuringShutdown to the daemon class for improved cohesion | ||||
| - Updated references in the daemon to call its own shutdown method instead of the SNMP manager | ||||
| - Removed redundant initiateShutdown method from the SNMP manager | ||||
|  | ||||
| ## 2025-03-25 - 2.4.1 - fix(docs) | ||||
|  | ||||
| Update readme with detailed legal and trademark guidance | ||||
|  | ||||
| - Clarified legal section by adding trademark and company information | ||||
| - Ensured users understand that licensing terms do not imply endorsement by the company | ||||
|  | ||||
| ## 2025-03-25 - 2.4.0 - feat(installer) | ||||
|  | ||||
| Add auto-yes flag to installer and update installation documentation | ||||
|  | ||||
| - Enhance install.sh to parse -y/--yes and -h/--help options, automating git installation when auto-yes is provided | ||||
| - Enhance install.sh to parse -y/--yes and -h/--help options, automating git installation when | ||||
|   auto-yes is provided | ||||
| - Improve user prompts for dependency installation and provide clearer instructions | ||||
| - Update readme.md to document new installer options and enhanced file system and service changes details | ||||
| - Update readme.md to document new installer options and enhanced file system and service changes | ||||
|   details | ||||
|  | ||||
| ## 2025-03-25 - 2.3.0 - feat(installer/cli) | ||||
| Add OS detection and git auto-installation support to install.sh and improve service setup prompt in CLI | ||||
|  | ||||
| - Implemented helper functions in install.sh to detect OS type and automatically install git if missing | ||||
| Add OS detection and git auto-installation support to install.sh and improve service setup prompt in | ||||
| CLI | ||||
|  | ||||
| - Implemented helper functions in install.sh to detect OS type and automatically install git if | ||||
|   missing | ||||
| - Prompt user for git installation if not present before cloning the repository | ||||
| - Enhanced CLI service setup flow to offer starting the NUPST service immediately after installation | ||||
|  | ||||
| ## 2025-03-25 - 2.2.0 - feat(cli) | ||||
|  | ||||
| Add 'config' command to display current configuration and update CLI help | ||||
|  | ||||
| - Introduce new 'config' command to show SNMP settings, thresholds, and configuration file location | ||||
| - Update help text to include details for 'nupst config' command | ||||
|  | ||||
| ## 2025-03-25 - 2.1.0 - feat(cli) | ||||
|  | ||||
| Add uninstall command to CLI and update shutdown delay for graceful VM shutdown | ||||
|  | ||||
| - Implement uninstall command in ts/cli.ts that locates and executes uninstall.sh with user prompts | ||||
| - Update uninstall.sh to support environment variables for configuration and repository removal | ||||
| - Increase shutdown delay in ts/snmp/manager.ts from 1 minute to 5 minutes to allow VMs more time to shut down | ||||
| - Increase shutdown delay in ts/snmp/manager.ts from 1 minute to 5 minutes to allow VMs more time to | ||||
|   shut down | ||||
|  | ||||
| ## 2025-03-25 - 2.0.1 - fix(cli/systemd) | ||||
|  | ||||
| Fix status command to pass debug flag and improve systemd status logging output | ||||
|  | ||||
| - ts/cli.ts: Now extracts debug options from process arguments and passes debug mode to getStatus. | ||||
| - ts/systemd.ts: Updated getStatus to accept a debugMode parameter, enabling detailed SNMP debug logging, explicitly reloading configuration, and printing connection details. | ||||
| - ts/systemd.ts: Updated getStatus to accept a debugMode parameter, enabling detailed SNMP debug | ||||
|   logging, explicitly reloading configuration, and printing connection details. | ||||
|  | ||||
| ## 2025-03-25 - 2.0.0 - BREAKING CHANGE(snmp) | ||||
|  | ||||
| refactor: update SNMP type definitions and interface names for consistency | ||||
|  | ||||
| - Renamed SnmpConfig to ISnmpConfig, OIDSet to IOidSet, UpsStatus to IUpsStatus, and UpsModel to TUpsModel in ts/snmp/types.ts. | ||||
| - Updated internal references in ts/daemon.ts, ts/snmp/index.ts, ts/snmp/manager.ts, ts/snmp/oid-sets.ts, ts/snmp/packet-creator.ts, and ts/snmp/packet-parser.ts to use the new interface names. | ||||
| - Renamed SnmpConfig to ISnmpConfig, OIDSet to IOidSet, UpsStatus to IUpsStatus, and UpsModel to | ||||
|   TUpsModel in ts/snmp/types.ts. | ||||
| - Updated internal references in ts/daemon.ts, ts/snmp/index.ts, ts/snmp/manager.ts, | ||||
|   ts/snmp/oid-sets.ts, ts/snmp/packet-creator.ts, and ts/snmp/packet-parser.ts to use the new | ||||
|   interface names. | ||||
|  | ||||
| ## 2025-03-25 - 1.10.1 - fix(systemd/readme) | ||||
|  | ||||
| Improve README documentation and fix UPS status retrieval in systemd service | ||||
|  | ||||
| - Updated README features and installation instructions to clarify SNMP version support, UPS models, and configuration | ||||
| - Modified default SNMP host to '192.168.1.100' and added 'upsModel' property in configuration examples | ||||
| - Updated README features and installation instructions to clarify SNMP version support, UPS models, | ||||
|   and configuration | ||||
| - Modified default SNMP host to '192.168.1.100' and added 'upsModel' property in configuration | ||||
|   examples | ||||
| - Enhanced instructions for real-time log viewing and update process in README | ||||
| - Fixed systemd.ts to use a test configuration with an appropriate timeout when fetching UPS status | ||||
|  | ||||
| ## 2025-03-25 - 1.10.0 - feat(core) | ||||
|  | ||||
| Add update checking and version logging across startup components | ||||
|  | ||||
| - In daemon.ts, log version info on startup and check for updates in the background using npm registry response | ||||
| - In nupst.ts, implement getVersion, checkForUpdates, getUpdateStatus, and compareVersions functions with update notifications | ||||
| - In daemon.ts, log version info on startup and check for updates in the background using npm | ||||
|   registry response | ||||
| - In nupst.ts, implement getVersion, checkForUpdates, getUpdateStatus, and compareVersions functions | ||||
|   with update notifications | ||||
| - Establish bidirectional reference between Nupst and NupstSnmp to support version logging | ||||
| - Update systemd service status output to include version information | ||||
|  | ||||
| ## 2025-03-25 - 1.9.0 - feat(cli) | ||||
|  | ||||
| Add update command to CLI to update NUPST from repository and refresh the systemd service | ||||
|  | ||||
| - Integrate 'update' subcommand in CLI command parser | ||||
| - Update documentation and help output to include new command | ||||
| - Implement update process that fetches changes from git, runs install.sh/setup.sh, and refreshes systemd service if installed | ||||
| - Implement update process that fetches changes from git, runs install.sh/setup.sh, and refreshes | ||||
|   systemd service if installed | ||||
|  | ||||
| ## 2025-03-25 - 1.8.2 - fix(cli) | ||||
|  | ||||
| Refactor logs command to use child_process spawn for real-time log tailing | ||||
|  | ||||
| - Replaced execSync call with spawn to properly follow logs | ||||
| @@ -88,12 +571,15 @@ Refactor logs command to use child_process spawn for real-time log tailing | ||||
| - Await the child process exit to ensure clean shutdown of the CLI log command | ||||
|  | ||||
| ## 2025-03-25 - 1.8.1 - fix(systemd) | ||||
|  | ||||
| Update ExecStart in systemd service template to use /opt/nupst/bin/nupst for daemon startup | ||||
|  | ||||
| - Changed ExecStart from '/usr/bin/nupst daemon-start' to '/opt/nupst/bin/nupst daemon-start' in the systemd service file | ||||
| - Changed ExecStart from '/usr/bin/nupst daemon-start' to '/opt/nupst/bin/nupst daemon-start' in the | ||||
|   systemd service file | ||||
| - Ensures the service uses the correct binary installation path | ||||
|  | ||||
| ## 2025-03-25 - 1.8.0 - feat(core) | ||||
|  | ||||
| Enhance SNMP module and interactive CLI setup for UPS shutdown | ||||
|  | ||||
| - Refactored SNMP packet parsing and encoding utilities for clearer error handling and debugging | ||||
| @@ -102,22 +588,28 @@ Enhance SNMP module and interactive CLI setup for UPS shutdown | ||||
| - Expanded test coverage with simulated SNMP responses for various response types | ||||
|  | ||||
| ## 2025-03-25 - 1.7.6 - fix(core) | ||||
|  | ||||
| Refactor SNMP, systemd, and CLI modules to improve error handling, logging, and code clarity | ||||
|  | ||||
| - Removed unused dependency 'net-snmp' from package.json | ||||
| - Extracted helper functions for SNMP packet creation and parsing (using SnmpEncoder, SnmpPacketCreator and SnmpPacketParser) | ||||
| - Improved debug logging and added detailed documentation comments across SNMP, systemd, CLI and daemon modules | ||||
| - Extracted helper functions for SNMP packet creation and parsing (using SnmpEncoder, | ||||
|   SnmpPacketCreator and SnmpPacketParser) | ||||
| - Improved debug logging and added detailed documentation comments across SNMP, systemd, CLI and | ||||
|   daemon modules | ||||
| - Refactored systemd service management to extract status display and service disabling logic | ||||
| - Updated test suite to use proper modular methods from the new SNMP utilities | ||||
|  | ||||
| ## 2025-03-25 - 1.7.5 - fix(cli) | ||||
| Enable SNMP debug mode in CLI commands and update debug flag handling in daemon-start and test; bump version to 1.7.4 | ||||
|  | ||||
| Enable SNMP debug mode in CLI commands and update debug flag handling in daemon-start and test; bump | ||||
| version to 1.7.4 | ||||
|  | ||||
| - Call enableDebug() on SNMP client earlier in command parsing | ||||
| - Pass debug flag to 'daemon-start' and 'test' commands for consistent debug output | ||||
| - Update package version from 1.7.3 to 1.7.4 | ||||
|  | ||||
| ## 2025-03-25 - 1.7.3 - fix(SNMP) | ||||
|  | ||||
| Refine SNMP packet creation and response parsing for more reliable UPS status monitoring | ||||
|  | ||||
| - Improve error handling and fallback logic when parsing SNMP responses | ||||
| @@ -125,13 +617,16 @@ Refine SNMP packet creation and response parsing for more reliable UPS status mo | ||||
| - Enhance test coverage for various UPS scenarios | ||||
|  | ||||
| ## 2025-03-25 - 1.7.2 - fix(core) | ||||
| Refactor internal SNMP response parsing and enhance daemon logging for improved error reporting and clarity. | ||||
|  | ||||
| Refactor internal SNMP response parsing and enhance daemon logging for improved error reporting and | ||||
| clarity. | ||||
|  | ||||
| - Improved fallback and error handling in SNMP response parsing | ||||
| - Enhanced logging messages in daemon and systemd service management | ||||
| - Minor refactoring for better maintainability without functional changes | ||||
|  | ||||
| ## 2025-03-25 - 1.7.1 - fix(snmp-cli) | ||||
|  | ||||
| Improve SNMP response parsing and CLI UPS connection timeout handling | ||||
|  | ||||
| - Expand parsing loop in SNMP responses to capture Gauge32 and Timeticks values | ||||
| @@ -139,14 +634,17 @@ Improve SNMP response parsing and CLI UPS connection timeout handling | ||||
| - Configure CLI test commands to use a shortened timeout for UPS connection tests | ||||
|  | ||||
| ## 2025-03-25 - 1.7.0 - feat(SNMP/UPS) | ||||
|  | ||||
| Add UPS model selection and custom OIDs support to handle different UPS brands | ||||
|  | ||||
| - Introduce distinct OID sets for CyberPower, APC, Eaton, TrippLite, Liebert, and a custom option | ||||
| - Update interactive setup to prompt for UPS model selection and custom OID entry when needed | ||||
| - Refactor SNMP status retrieval to dynamically select the appropriate OIDs based on the configured UPS model | ||||
| - Refactor SNMP status retrieval to dynamically select the appropriate OIDs based on the configured | ||||
|   UPS model | ||||
| - Extend default configuration with an upsModel property for consistent behavior | ||||
|  | ||||
| ## 2025-03-25 - 1.6.0 - feat(cli,snmp) | ||||
|  | ||||
| Enhance debug logging and add debug mode support in CLI and SNMP modules | ||||
|  | ||||
| - Enable debug flags (--debug, -d) in CLI to trigger detailed SNMP logging | ||||
| @@ -155,6 +653,7 @@ Enhance debug logging and add debug mode support in CLI and SNMP modules | ||||
| - Improve timeout and discovery logging details for streamlined troubleshooting | ||||
|  | ||||
| ## 2025-03-25 - 1.5.0 - feat(cli) | ||||
|  | ||||
| Enhance CLI output: display SNMPv3 auth/priv details and support timeout customization during setup | ||||
|  | ||||
| - Display authentication and privacy protocol details when SNMP version is 3 | ||||
| @@ -163,10 +662,11 @@ Enhance CLI output: display SNMPv3 auth/priv details and support timeout customi | ||||
| - Allow users to customize SNMP timeout during interactive setup | ||||
|  | ||||
| ## 2025-03-25 - 1.4.1 - fix(version) | ||||
|  | ||||
| Bump patch version for consistency with commit info | ||||
|  | ||||
|  | ||||
| ## 2025-03-25 - 1.4.0 - feat(snmp) | ||||
|  | ||||
| Implement native SNMPv3 support with simulated encryption and enhanced authentication handling. | ||||
|  | ||||
| - Add fully native SNMPv3 GET request implementation replacing the snmpwalk fallback | ||||
| @@ -175,12 +675,14 @@ Implement native SNMPv3 support with simulated encryption and enhanced authentic | ||||
| - Introduce detailed security parameter management for SNMPv3 | ||||
|  | ||||
| ## 2025-03-25 - 1.3.1 - fix(cli) | ||||
|  | ||||
| Remove redundant SNMP tools checks in CLI and Systemd modules | ||||
|  | ||||
| - Eliminate unnecessary snmpwalk dependency checks in the test command and interactive setup flow. | ||||
| - Adjust systemd configuration file check to avoid external dependency verification. | ||||
|  | ||||
| ## 2025-03-25 - 1.3.0 - feat(cli) | ||||
|  | ||||
| add test command to verify UPS SNMP configuration and connectivity | ||||
|  | ||||
| - Introduce a new 'test' command in the CLI to check the SNMP configuration and UPS connection. | ||||
| @@ -188,6 +690,7 @@ add test command to verify UPS SNMP configuration and connectivity | ||||
| - Output UPS status details and compare against defined shutdown thresholds. | ||||
|  | ||||
| ## 2025-03-25 - 1.2.6 - fix(cli) | ||||
|  | ||||
| Refactor interactive setup to use dynamic import for readline and ensure proper cleanup | ||||
|  | ||||
| - Replaced synchronous require() with async import for ESM compatibility | ||||
| @@ -195,13 +698,16 @@ Refactor interactive setup to use dynamic import for readline and ensure proper | ||||
| - Enhanced error logging by outputting error.message | ||||
|  | ||||
| ## 2025-03-25 - 1.2.5 - fix(error-handling) | ||||
| Improve error handling in CLI, daemon, and systemd lifecycle management with enhanced logging for configuration issues | ||||
|  | ||||
| Improve error handling in CLI, daemon, and systemd lifecycle management with enhanced logging for | ||||
| configuration issues | ||||
|  | ||||
| - Wrap daemon and service start commands in try-catch blocks to properly handle and log errors | ||||
| - Throw explicit errors when configuration file is missing instead of silently defaulting | ||||
| - Enhance log messages for service installation, startup, and status retrieval for clearer debugging | ||||
|  | ||||
| ## 2025-03-25 - 1.2.4 - fix(cli/daemon) | ||||
|  | ||||
| Improve logging and user feedback in interactive setup and UPS monitoring | ||||
|  | ||||
| - Refactor configuration summary output in the interactive setup for clearer display | ||||
| @@ -209,17 +715,20 @@ Improve logging and user feedback in interactive setup and UPS monitoring | ||||
| - Improve error messages and user guidance during configuration and monitoring | ||||
|  | ||||
| ## 2025-03-24 - 1.2.3 - fix(nupst) | ||||
|  | ||||
| No changes | ||||
|  | ||||
|  | ||||
| ## 2025-03-24 - 1.2.2 - fix(bin/nupst) | ||||
| Improve symlink resolution in launcher script to correctly determine project root based on execution path. | ||||
|  | ||||
| Improve symlink resolution in launcher script to correctly determine project root based on execution | ||||
| path. | ||||
|  | ||||
| - Replace directory determination with readlink for accurate symlink resolution | ||||
| - Set project root to '/opt/nupst' when script is run via symlink from /usr/local/bin | ||||
| - Add debugging comments to assist with path resolution | ||||
|  | ||||
| ## 2025-03-24 - 1.2.1 - fix(bin) | ||||
|  | ||||
| Simplify Node.js binary detection in installation script | ||||
|  | ||||
| - Directly set Node binary path to vendor/node-linux-x64/bin/node | ||||
| @@ -227,59 +736,78 @@ Simplify Node.js binary detection in installation script | ||||
| - Fallback to system Node if vendor binary is not found | ||||
|  | ||||
| ## 2025-03-24 - 1.2.0 - feat(installer) | ||||
|  | ||||
| Improve Node.js binary detection and dynamic LTS version retrieval in setup scripts | ||||
|  | ||||
| - Enhanced bin/nupst to search multiple possible locations for the Node.js binary and fallback to system node if necessary | ||||
| - Updated setup.sh to fetch the latest LTS Node.js version from nodejs.org and use a fallback version when the request fails | ||||
| - Enhanced bin/nupst to search multiple possible locations for the Node.js binary and fallback to | ||||
|   system node if necessary | ||||
| - Updated setup.sh to fetch the latest LTS Node.js version from nodejs.org and use a fallback | ||||
|   version when the request fails | ||||
|  | ||||
| ## 2025-03-24 - 1.1.2 - fix(setup.sh) | ||||
| Improve error handling in setup.sh: exit immediately when the downloaded npm package lacks the dist_ts directory, removing the fallback build-from-source mechanism. | ||||
|  | ||||
| Improve error handling in setup.sh: exit immediately when the downloaded npm package lacks the | ||||
| dist_ts directory, removing the fallback build-from-source mechanism. | ||||
|  | ||||
| - Removed BUILD_FROM_SOURCE logic that attempted to build from source on missing dist_ts directory | ||||
| - Updated error messages to clearly indicate failure in downloading a valid package | ||||
| - Ensured installation halts if essential files are missing | ||||
|  | ||||
| ## 2025-03-24 - 1.1.1 - fix(package.json) | ||||
|  | ||||
| Remove unused prepublishOnly script and update files field in package.json | ||||
|  | ||||
| - Removed prepublishOnly build trigger | ||||
| - Updated files list to accurately include intended directories and files | ||||
|  | ||||
| ## 2025-03-24 - 1.1.0 - feat(installer-setup) | ||||
|  | ||||
| Enhance installer and setup scripts for improved global installation and artifact management | ||||
|  | ||||
| - Detect piped installation in install.sh, clone repository automatically, and clean up previous installations | ||||
| - Detect piped installation in install.sh, clone repository automatically, and clean up previous | ||||
|   installations | ||||
| - Update readme.md with correct repository URL and clearer installation instructions | ||||
| - Improve setup.sh to remove existing dist_ts, download build artifacts from the npm registry, and simplify dependency installation | ||||
| - Improve setup.sh to remove existing dist_ts, download build artifacts from the npm registry, and | ||||
|   simplify dependency installation | ||||
|  | ||||
| ## 2025-03-24 - 1.0.1 - fix(version) | ||||
|  | ||||
| Bump version to 1.0.1 | ||||
|  | ||||
| - Updated commitinfo data to reflect the new patch version. | ||||
| - Synchronized version information between commitinfo file and package metadata. | ||||
|  | ||||
| ## 2025-03-24 - 1.0.1 - fix(build) | ||||
| Update build script to use 'tsbuild tsfolders --allowimplicitany' and adjust distribution paths in .gitignore | ||||
|  | ||||
| Update build script to use 'tsbuild tsfolders --allowimplicitany' and adjust distribution paths in | ||||
| .gitignore | ||||
|  | ||||
| - Replaced 'tsc' with 'tsbuild tsfolders --allowimplicitany' in package.json | ||||
| - Updated .gitignore to reflect new compiled distribution folder pattern | ||||
| - Updated changelog to document build improvements and regenerated type definitions | ||||
|  | ||||
| ## 2025-03-24 - 1.0.1 - fix(build) | ||||
| Update build script to use 'tsbuild tsfolders --allowimplicitany' and regenerate distribution type definitions for CLI, daemon, index, nupst, snmp, and systemd modules | ||||
|  | ||||
| Update build script to use 'tsbuild tsfolders --allowimplicitany' and regenerate distribution type | ||||
| definitions for CLI, daemon, index, nupst, snmp, and systemd modules | ||||
|  | ||||
| - Replaced 'tsc' command with tsbuild in package.json | ||||
| - Updated .gitignore to reflect new compiled distribution folder pattern | ||||
| - Added new dist_ts files including .d.ts type definitions and compiled JavaScript for multiple modules | ||||
| - Added new dist_ts files including .d.ts type definitions and compiled JavaScript for multiple | ||||
|   modules | ||||
|  | ||||
| ## 2025-03-24 - 1.0.1 - fix(build) | ||||
| Update build script to use 'tsbuild tsfolders --allowimplicitany' and regenerate distribution type definitions for CLI, daemon, nupst, snmp, and systemd modules. | ||||
|  | ||||
| Update build script to use 'tsbuild tsfolders --allowimplicitany' and regenerate distribution type | ||||
| definitions for CLI, daemon, nupst, snmp, and systemd modules. | ||||
|  | ||||
| - Replaced the 'tsc' command with 'tsbuild tsfolders --allowimplicitany' in package.json. | ||||
| - Added new dist_ts files including type definitions (d.ts) and compiled JavaScript for CLI, daemon, index, nupst, snmp, and systemd. | ||||
| - Added new dist_ts files including type definitions (d.ts) and compiled JavaScript for CLI, daemon, | ||||
|   index, nupst, snmp, and systemd. | ||||
| - Improved the generated CLI declarations and overall distribution build. | ||||
|  | ||||
| ## 2025-03-23 - 1.0.0 - initial setup | ||||
|  | ||||
| This range covers the early commits that mainly established the repository structure. | ||||
|  | ||||
| - Initial repository commit with basic project initialization. | ||||
| - Initial repository commit with basic project initialization. | ||||
|   | ||||
							
								
								
									
										37
									
								
								deno.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								deno.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| { | ||||
|   "name": "@serve.zone/nupst", | ||||
|   "version": "5.0.5", | ||||
|   "exports": "./mod.ts", | ||||
|   "nodeModulesDir": "auto", | ||||
|   "tasks": { | ||||
|     "dev": "deno run --allow-all mod.ts", | ||||
|     "compile": "deno task compile:all", | ||||
|     "compile:all": "bash scripts/compile-all.sh", | ||||
|     "test": "deno test --allow-all test/", | ||||
|     "test:watch": "deno test --allow-all --watch test/", | ||||
|     "check": "deno check mod.ts", | ||||
|     "fmt": "deno fmt", | ||||
|     "lint": "deno lint" | ||||
|   }, | ||||
|   "lint": { | ||||
|     "rules": { | ||||
|       "tags": ["recommended"] | ||||
|     } | ||||
|   }, | ||||
|   "fmt": { | ||||
|     "useTabs": false, | ||||
|     "lineWidth": 100, | ||||
|     "indentWidth": 2, | ||||
|     "semiColons": true, | ||||
|     "singleQuote": true | ||||
|   }, | ||||
|   "compilerOptions": { | ||||
|     "lib": ["deno.window"], | ||||
|     "strict": true | ||||
|   }, | ||||
|   "imports": { | ||||
|     "@std/cli": "jsr:@std/cli@^1.0.0", | ||||
|     "@std/fmt": "jsr:@std/fmt@^1.0.0", | ||||
|     "@std/path": "jsr:@std/path@^1.0.0" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										122
									
								
								docs/example-action.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								docs/example-action.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| #!/bin/bash | ||||
| # NUPST Action Script Example | ||||
| # Copy this to /etc/nupst/ and customize for your needs | ||||
| # | ||||
| # This script is called by NUPST when power events or threshold violations occur. | ||||
| # It receives UPS state information via environment variables and command-line arguments. | ||||
|  | ||||
| # ============================================================================== | ||||
| # ARGUMENTS (positional parameters) | ||||
| # ============================================================================== | ||||
| # $1 = Power Status (online|onBattery|unknown) | ||||
| # $2 = Battery Capacity (percentage, 0-100) | ||||
| # $3 = Battery Runtime (estimated minutes remaining) | ||||
|  | ||||
| POWER_STATUS=$1 | ||||
| BATTERY_CAPACITY=$2 | ||||
| BATTERY_RUNTIME=$3 | ||||
|  | ||||
| # ============================================================================== | ||||
| # ENVIRONMENT VARIABLES | ||||
| # ============================================================================== | ||||
| # NUPST_UPS_ID               - Unique UPS identifier | ||||
| # NUPST_UPS_NAME             - Human-readable UPS name | ||||
| # NUPST_POWER_STATUS         - Current power status | ||||
| # NUPST_BATTERY_CAPACITY     - Battery percentage (0-100) | ||||
| # NUPST_BATTERY_RUNTIME      - Estimated runtime in minutes | ||||
| # NUPST_THRESHOLDS_EXCEEDED  - "true" if below configured thresholds | ||||
| # NUPST_TRIGGER_REASON       - "powerStatusChange" or "thresholdViolation" | ||||
| # NUPST_BATTERY_THRESHOLD    - Configured battery threshold percentage | ||||
| # NUPST_RUNTIME_THRESHOLD    - Configured runtime threshold in minutes | ||||
| # NUPST_TIMESTAMP            - Unix timestamp (milliseconds since epoch) | ||||
|  | ||||
| # ============================================================================== | ||||
| # EXAMPLE: Log the event | ||||
| # ============================================================================== | ||||
| LOG_FILE="/var/log/nupst-actions.log" | ||||
|  | ||||
| echo "========================================" >> "$LOG_FILE" | ||||
| echo "NUPST Action Triggered: $(date)" >> "$LOG_FILE" | ||||
| echo "----------------------------------------" >> "$LOG_FILE" | ||||
| echo "UPS: $NUPST_UPS_NAME ($NUPST_UPS_ID)" >> "$LOG_FILE" | ||||
| echo "Power Status: $POWER_STATUS" >> "$LOG_FILE" | ||||
| echo "Battery: $BATTERY_CAPACITY%" >> "$LOG_FILE" | ||||
| echo "Runtime: $BATTERY_RUNTIME minutes" >> "$LOG_FILE" | ||||
| echo "Trigger Reason: $NUPST_TRIGGER_REASON" >> "$LOG_FILE" | ||||
| echo "Thresholds Exceeded: $NUPST_THRESHOLDS_EXCEEDED" >> "$LOG_FILE" | ||||
| echo "========================================" >> "$LOG_FILE" | ||||
|  | ||||
| # ============================================================================== | ||||
| # EXAMPLE: Send email notification | ||||
| # ============================================================================== | ||||
| # if [ "$NUPST_TRIGGER_REASON" = "thresholdViolation" ]; then | ||||
| #   echo "ALERT: UPS $NUPST_UPS_NAME battery critical!" | \ | ||||
| #     mail -s "UPS Battery Critical" admin@example.com | ||||
| # fi | ||||
|  | ||||
| # ============================================================================== | ||||
| # EXAMPLE: Gracefully shutdown virtual machines | ||||
| # ============================================================================== | ||||
| # if [ "$NUPST_POWER_STATUS" = "onBattery" ] && [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then | ||||
| #   echo "Shutting down VMs..." >> "$LOG_FILE" | ||||
| #   # virsh shutdown vm1 | ||||
| #   # virsh shutdown vm2 | ||||
| #   # Wait for VMs to shutdown | ||||
| #   # sleep 120 | ||||
| # fi | ||||
|  | ||||
| # ============================================================================== | ||||
| # EXAMPLE: Call external API/service | ||||
| # ============================================================================== | ||||
| # curl -X POST https://monitoring.example.com/ups-alert \ | ||||
| #   -H "Content-Type: application/json" \ | ||||
| #   -d "{ | ||||
| #     \"upsId\": \"$NUPST_UPS_ID\", | ||||
| #     \"upsName\": \"$NUPST_UPS_NAME\", | ||||
| #     \"powerStatus\": \"$POWER_STATUS\", | ||||
| #     \"batteryCapacity\": $BATTERY_CAPACITY, | ||||
| #     \"batteryRuntime\": $BATTERY_RUNTIME, | ||||
| #     \"triggerReason\": \"$NUPST_TRIGGER_REASON\" | ||||
| #   }" | ||||
|  | ||||
| # ============================================================================== | ||||
| # EXAMPLE: Remote shutdown via SSH with password | ||||
| # ============================================================================== | ||||
| # You can implement custom shutdown logic for remote systems | ||||
| # that require password authentication or webhooks | ||||
| # | ||||
| # if [ "$NUPST_THRESHOLDS_EXCEEDED" = "true" ]; then | ||||
| #   # Call a webhook with a secret password/token | ||||
| #   curl -X POST "https://remote-server.local/shutdown?token=YOUR_SECRET_TOKEN" | ||||
| # | ||||
| #   # Or use SSH with password (requires sshpass) | ||||
| #   # sshpass -p 'your-password' ssh user@remote-server 'sudo shutdown -h +5' | ||||
| # fi | ||||
|  | ||||
| # ============================================================================== | ||||
| # EXAMPLE: Conditional logic based on battery level | ||||
| # ============================================================================== | ||||
| # if [ "$BATTERY_CAPACITY" -lt 20 ]; then | ||||
| #   echo "Battery critically low! Immediate action needed." >> "$LOG_FILE" | ||||
| # elif [ "$BATTERY_CAPACITY" -lt 50 ]; then | ||||
| #   echo "Battery low. Preparing for shutdown." >> "$LOG_FILE" | ||||
| # else | ||||
| #   echo "Battery acceptable. Monitoring." >> "$LOG_FILE" | ||||
| # fi | ||||
|  | ||||
| # ============================================================================== | ||||
| # EXAMPLE: Different actions for different trigger reasons | ||||
| # ============================================================================== | ||||
| # case "$NUPST_TRIGGER_REASON" in | ||||
| #   powerStatusChange) | ||||
| #     echo "Power status changed to: $POWER_STATUS" >> "$LOG_FILE" | ||||
| #     # Send notification but don't take drastic action yet | ||||
| #     ;; | ||||
| #   thresholdViolation) | ||||
| #     echo "Thresholds violated! Taking emergency action." >> "$LOG_FILE" | ||||
| #     # Initiate graceful shutdowns, save data, etc. | ||||
| #     ;; | ||||
| # esac | ||||
|  | ||||
| # Exit with success | ||||
| exit 0 | ||||
							
								
								
									
										414
									
								
								install.sh
									
									
									
									
									
								
							
							
						
						
									
										414
									
								
								install.sh
									
									
									
									
									
								
							| @@ -1,44 +1,69 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # NUPST Installer Script | ||||
| # Downloads and installs NUPST globally on the system | ||||
| # Can be used directly with curl: | ||||
| # Without auto-installing dependencies: | ||||
| #   curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash | ||||
| # With auto-installing dependencies: | ||||
| #   curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- -y | ||||
| #  | ||||
| # NUPST Installer Script (v5.0+) | ||||
| # Downloads and installs pre-compiled NUPST binary from Gitea releases | ||||
| # | ||||
| # Usage: | ||||
| #   Direct piped installation (recommended): | ||||
| #     curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash | ||||
| # | ||||
| #   With version specification: | ||||
| #     curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0 | ||||
| # | ||||
| # Options: | ||||
| #   -y, --yes     Automatically answer yes to all prompts | ||||
| #   -h, --help    Show this help message | ||||
| #   -h, --help             Show this help message | ||||
| #   --version VERSION      Install specific version (e.g., v4.0.0) | ||||
| #   --install-dir DIR      Installation directory (default: /opt/nupst) | ||||
|  | ||||
| set -e | ||||
|  | ||||
| # Default values | ||||
| SHOW_HELP=0 | ||||
| SPECIFIED_VERSION="" | ||||
| INSTALL_DIR="/opt/nupst" | ||||
| GITEA_BASE_URL="https://code.foss.global" | ||||
| GITEA_REPO="serve.zone/nupst" | ||||
|  | ||||
| # Parse command line arguments | ||||
| AUTO_YES=0 | ||||
| SHOW_HELP=0 | ||||
|  | ||||
| for arg in "$@"; do | ||||
|   case $arg in | ||||
|     -y|--yes) | ||||
|       AUTO_YES=1 | ||||
|       shift | ||||
|       ;; | ||||
| while [[ $# -gt 0 ]]; do | ||||
|   case $1 in | ||||
|     -h|--help) | ||||
|       SHOW_HELP=1 | ||||
|       shift | ||||
|       ;; | ||||
|     --version) | ||||
|       SPECIFIED_VERSION="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     --install-dir) | ||||
|       INSTALL_DIR="$2" | ||||
|       shift 2 | ||||
|       ;; | ||||
|     *) | ||||
|       # Unknown option | ||||
|       echo "Unknown option: $1" | ||||
|       echo "Use -h or --help for usage information" | ||||
|       exit 1 | ||||
|       ;; | ||||
|   esac | ||||
| done | ||||
|  | ||||
| if [ $SHOW_HELP -eq 1 ]; then | ||||
|   echo "NUPST Installer Script" | ||||
|   echo "NUPST Installer Script (v5.0+)" | ||||
|   echo "Downloads and installs pre-compiled NUPST binary" | ||||
|   echo "" | ||||
|   echo "Usage: $0 [options]" | ||||
|   echo "" | ||||
|   echo "Options:" | ||||
|   echo "  -y, --yes     Automatically answer yes to all prompts" | ||||
|   echo "  -h, --help    Show this help message" | ||||
|   echo "  -h, --help             Show this help message" | ||||
|   echo "  --version VERSION      Install specific version (e.g., v5.0.0)" | ||||
|   echo "  --install-dir DIR      Installation directory (default: /opt/nupst)" | ||||
|   echo "" | ||||
|   echo "Examples:" | ||||
|   echo "  # Install latest version" | ||||
|   echo "  curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash" | ||||
|   echo "" | ||||
|   echo "  # Install specific version" | ||||
|   echo "  curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash -s -- --version v5.0.0" | ||||
|   exit 0 | ||||
| fi | ||||
|  | ||||
| @@ -48,175 +73,212 @@ if [ "$EUID" -ne 0 ]; then | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Detect if script is being piped or run directly | ||||
| PIPED=0 | ||||
| if [ ! -t 0 ]; then | ||||
|   # Being piped, need to clone the repo | ||||
|   PIPED=1 | ||||
| fi | ||||
| # Helper function to detect OS and architecture | ||||
| detect_platform() { | ||||
|   local os=$(uname -s) | ||||
|   local arch=$(uname -m) | ||||
|  | ||||
| # Helper function to detect OS type | ||||
| detect_os() { | ||||
|   if [ -f /etc/os-release ]; then | ||||
|     . /etc/os-release | ||||
|     OS=$ID | ||||
|   elif type lsb_release >/dev/null 2>&1; then | ||||
|     OS=$(lsb_release -si | tr '[:upper:]' '[:lower:]') | ||||
|   elif [ -f /etc/lsb-release ]; then | ||||
|     . /etc/lsb-release | ||||
|     OS=$DISTRIB_ID | ||||
|   elif [ -f /etc/debian_version ]; then | ||||
|     OS="debian" | ||||
|   elif [ -f /etc/redhat-release ]; then | ||||
|     if grep -q "CentOS" /etc/redhat-release; then | ||||
|       OS="centos" | ||||
|     elif grep -q "Fedora" /etc/redhat-release; then | ||||
|       OS="fedora" | ||||
|     else | ||||
|       OS="rhel" | ||||
|     fi | ||||
|   else | ||||
|     OS=$(uname -s) | ||||
|   fi | ||||
|   echo $OS | ||||
| } | ||||
|  | ||||
| # Helper function to install git | ||||
| install_git() { | ||||
|   OS=$(detect_os) | ||||
|   echo "Detected OS: $OS" | ||||
|    | ||||
|   case "$OS" in | ||||
|     ubuntu|debian|pop|mint|elementary|kali|zorin) | ||||
|       echo "Installing git using apt..." | ||||
|       apt-get update && apt-get install -y git | ||||
|   # Map OS | ||||
|   case "$os" in | ||||
|     Linux) | ||||
|       os_name="linux" | ||||
|       ;; | ||||
|     fedora|rhel|centos|almalinux|rocky) | ||||
|       echo "Installing git using dnf/yum..." | ||||
|       if command -v dnf &> /dev/null; then | ||||
|         dnf install -y git | ||||
|       else | ||||
|         yum install -y git | ||||
|       fi | ||||
|     Darwin) | ||||
|       os_name="macos" | ||||
|       ;; | ||||
|     arch|manjaro|endeavouros|garuda) | ||||
|       echo "Installing git using pacman..." | ||||
|       pacman -Sy --noconfirm git | ||||
|       ;; | ||||
|     opensuse*|suse|sles) | ||||
|       echo "Installing git using zypper..." | ||||
|       zypper install -y git | ||||
|       ;; | ||||
|     alpine) | ||||
|       echo "Installing git using apk..." | ||||
|       apk add git | ||||
|     MINGW*|MSYS*|CYGWIN*) | ||||
|       os_name="windows" | ||||
|       ;; | ||||
|     *) | ||||
|       echo "Unsupported OS: $OS" | ||||
|       echo "Please install git manually and run the installer again." | ||||
|       echo "Error: Unsupported operating system: $os" | ||||
|       echo "Supported: Linux, macOS, Windows" | ||||
|       exit 1 | ||||
|       ;; | ||||
|   esac | ||||
|    | ||||
|   # Check if git was installed successfully | ||||
|   if ! command -v git &> /dev/null; then | ||||
|     echo "Failed to install git. Please install git manually and run the installer again." | ||||
|     exit 1 | ||||
|  | ||||
|   # Map architecture | ||||
|   case "$arch" in | ||||
|     x86_64|amd64) | ||||
|       arch_name="x64" | ||||
|       ;; | ||||
|     aarch64|arm64) | ||||
|       arch_name="arm64" | ||||
|       ;; | ||||
|     *) | ||||
|       echo "Error: Unsupported architecture: $arch" | ||||
|       echo "Supported: x86_64/amd64 (x64), aarch64/arm64 (arm64)" | ||||
|       exit 1 | ||||
|       ;; | ||||
|   esac | ||||
|  | ||||
|   # Construct binary name | ||||
|   if [ "$os_name" = "windows" ]; then | ||||
|     echo "nupst-${os_name}-${arch_name}.exe" | ||||
|   else | ||||
|     echo "nupst-${os_name}-${arch_name}" | ||||
|   fi | ||||
|    | ||||
|   echo "Git installed successfully." | ||||
| } | ||||
|  | ||||
| # Define installation directory | ||||
| INSTALL_DIR="/opt/nupst" | ||||
| REPO_URL="https://code.foss.global/serve.zone/nupst.git" | ||||
| # Get latest release version from Gitea API | ||||
| get_latest_version() { | ||||
|   echo "Fetching latest release version from Gitea..." >&2 | ||||
|  | ||||
| if [ $PIPED -eq 1 ]; then | ||||
|   echo "Installing NUPST from remote repository..." | ||||
|    | ||||
|   # Check if git is installed | ||||
|   if ! command -v git &> /dev/null; then | ||||
|     echo "Git is required but not installed." | ||||
|      | ||||
|     if [ $AUTO_YES -eq 1 ]; then | ||||
|       echo "Auto-installing git (-y flag provided)..." | ||||
|       install_git | ||||
|     else | ||||
|       read -p "Would you like to install git now? (y/N): " install_git_prompt | ||||
|        | ||||
|       if [[ "$install_git_prompt" =~ ^[Yy]$ ]]; then | ||||
|         install_git | ||||
|       else | ||||
|         echo "Git installation skipped. Please install git manually and run the installer again." | ||||
|         echo "Alternatively, you can run the installer with -y flag to automatically install git:" | ||||
|         echo "  sudo bash install.sh -y" | ||||
|         exit 1 | ||||
|       fi | ||||
|     fi | ||||
|   fi | ||||
|    | ||||
|   # Check if installation directory exists | ||||
|   if [ -d "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR/.git" ]; then | ||||
|     echo "Existing installation found at $INSTALL_DIR. Updating..." | ||||
|     cd "$INSTALL_DIR" | ||||
|      | ||||
|     # Try to update the repository | ||||
|     git fetch origin | ||||
|     git reset --hard origin/main | ||||
|      | ||||
|     if [ $? -ne 0 ]; then | ||||
|       echo "Failed to update repository. Reinstalling..." | ||||
|       cd / | ||||
|       rm -rf "$INSTALL_DIR" | ||||
|       mkdir -p "$INSTALL_DIR" | ||||
|       git clone --depth 1 $REPO_URL "$INSTALL_DIR" | ||||
|     else | ||||
|       echo "Repository updated successfully." | ||||
|     fi | ||||
|   else | ||||
|     # Fresh installation | ||||
|     if [ -d "$INSTALL_DIR" ]; then | ||||
|       echo "Removing previous installation at $INSTALL_DIR..." | ||||
|       rm -rf "$INSTALL_DIR" | ||||
|     fi | ||||
|      | ||||
|     # Create installation directory | ||||
|     mkdir -p "$INSTALL_DIR" | ||||
|      | ||||
|     # Clone the repository | ||||
|     echo "Cloning NUPST repository to $INSTALL_DIR..." | ||||
|     git clone --depth 1 $REPO_URL "$INSTALL_DIR" | ||||
|   fi | ||||
|    | ||||
|   if [ $? -ne 0 ]; then | ||||
|     echo "Failed to clone/update repository. Please check your internet connection." | ||||
|   local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest" | ||||
|   local response=$(curl -sSL "$api_url" 2>/dev/null) | ||||
|  | ||||
|   if [ $? -ne 0 ] || [ -z "$response" ]; then | ||||
|     echo "Error: Failed to fetch latest release information from Gitea API" >&2 | ||||
|     echo "URL: $api_url" >&2 | ||||
|     exit 1 | ||||
|   fi | ||||
|    | ||||
|   # Set script directory to the cloned repo | ||||
|   SCRIPT_DIR="$INSTALL_DIR" | ||||
| else | ||||
|   # Running directly from within the repo | ||||
|   SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" | ||||
| fi | ||||
|  | ||||
| # Run setup script | ||||
| echo "Running setup script..." | ||||
| bash "$SCRIPT_DIR/setup.sh" | ||||
|   # Extract tag_name from JSON response | ||||
|   local version=$(echo "$response" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4) | ||||
|  | ||||
| # Install globally | ||||
| echo "Installing NUPST globally..." | ||||
| ln -sf "$SCRIPT_DIR/bin/nupst" /usr/local/bin/nupst | ||||
|   if [ -z "$version" ]; then | ||||
|     echo "Error: Could not determine latest version from API response" >&2 | ||||
|     exit 1 | ||||
|   fi | ||||
|  | ||||
| # Installation completed | ||||
| if [ $PIPED -eq 1 ]; then | ||||
|   echo "NUPST has been installed globally at $INSTALL_DIR" | ||||
| else | ||||
|   echo "NUPST has been installed globally." | ||||
| fi | ||||
|   echo "$version" | ||||
| } | ||||
|  | ||||
| echo "You can now run 'nupst' from anywhere." | ||||
| # Main installation process | ||||
| echo "================================================" | ||||
| echo "  NUPST Installation Script (v5.0+)" | ||||
| echo "================================================" | ||||
| echo "" | ||||
|  | ||||
| # Detect platform | ||||
| BINARY_NAME=$(detect_platform) | ||||
| echo "Detected platform: $BINARY_NAME" | ||||
| echo "" | ||||
|  | ||||
| # Determine version to install | ||||
| if [ -n "$SPECIFIED_VERSION" ]; then | ||||
|   VERSION="$SPECIFIED_VERSION" | ||||
|   echo "Installing specified version: $VERSION" | ||||
| else | ||||
|   VERSION=$(get_latest_version) | ||||
|   echo "Installing latest version: $VERSION" | ||||
| fi | ||||
| echo "" | ||||
|  | ||||
| # Construct download URL | ||||
| DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BINARY_NAME}" | ||||
| echo "Download URL: $DOWNLOAD_URL" | ||||
| echo "" | ||||
|  | ||||
| # Check if service is running and stop it | ||||
| SERVICE_WAS_RUNNING=0 | ||||
| if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then | ||||
|   SERVICE_WAS_RUNNING=1 | ||||
|   if systemctl is-active --quiet nupst 2>/dev/null; then | ||||
|     echo "Stopping NUPST service..." | ||||
|     systemctl stop nupst | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| # Clean installation directory - ensure only binary exists | ||||
| if [ -d "$INSTALL_DIR" ]; then | ||||
|   echo "Cleaning installation directory: $INSTALL_DIR" | ||||
|   rm -rf "$INSTALL_DIR" | ||||
| fi | ||||
|  | ||||
| # Create fresh installation directory | ||||
| echo "Creating installation directory: $INSTALL_DIR" | ||||
| mkdir -p "$INSTALL_DIR" | ||||
|  | ||||
| # Download binary | ||||
| echo "Downloading NUPST binary..." | ||||
| TEMP_FILE="$INSTALL_DIR/nupst.download" | ||||
| curl -sSL "$DOWNLOAD_URL" -o "$TEMP_FILE" | ||||
|  | ||||
| if [ $? -ne 0 ]; then | ||||
|   echo "Error: Failed to download binary from $DOWNLOAD_URL" | ||||
|   echo "" | ||||
|   echo "Please check:" | ||||
|   echo "  1. Your internet connection" | ||||
|   echo "  2. The specified version exists: ${GITEA_BASE_URL}/${GITEA_REPO}/releases" | ||||
|   echo "  3. The platform binary is available for this release" | ||||
|   rm -f "$TEMP_FILE" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Check if download was successful (file exists and not empty) | ||||
| if [ ! -s "$TEMP_FILE" ]; then | ||||
|   echo "Error: Downloaded file is empty or does not exist" | ||||
|   rm -f "$TEMP_FILE" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Move to final location | ||||
| BINARY_PATH="$INSTALL_DIR/nupst" | ||||
| mv "$TEMP_FILE" "$BINARY_PATH" | ||||
|  | ||||
| if [ $? -ne 0 ] || [ ! -f "$BINARY_PATH" ]; then | ||||
|   echo "Error: Failed to move binary to $BINARY_PATH" | ||||
|   rm -f "$TEMP_FILE" 2>/dev/null | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Make executable | ||||
| chmod +x "$BINARY_PATH" | ||||
|  | ||||
| if [ $? -ne 0 ]; then | ||||
|   echo "Error: Failed to make binary executable" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| echo "Binary installed successfully to: $BINARY_PATH" | ||||
| echo "" | ||||
|  | ||||
| # Check if /usr/local/bin is in PATH | ||||
| if [[ ":$PATH:" == *":/usr/local/bin:"* ]]; then | ||||
|   BIN_DIR="/usr/local/bin" | ||||
| else | ||||
|   BIN_DIR="/usr/bin" | ||||
| fi | ||||
|  | ||||
| # Create symlink for global access | ||||
| ln -sf "$BINARY_PATH" "$BIN_DIR/nupst" | ||||
| echo "Symlink created: $BIN_DIR/nupst -> $BINARY_PATH" | ||||
|  | ||||
| echo "" | ||||
|  | ||||
| # Restart service if it was running before update | ||||
| if [ $SERVICE_WAS_RUNNING -eq 1 ]; then | ||||
|   echo "Restarting NUPST service..." | ||||
|   systemctl start nupst | ||||
|   echo "Service restarted successfully." | ||||
|   echo "" | ||||
| fi | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  NUPST Installation Complete!" | ||||
| echo "================================================" | ||||
| echo "" | ||||
| echo "Installation details:" | ||||
| echo "  Binary location: $BINARY_PATH" | ||||
| echo "  Symlink location: $BIN_DIR/nupst" | ||||
| echo "  Version: $VERSION" | ||||
| echo "" | ||||
|  | ||||
| # Check if configuration exists | ||||
| if [ -f "/etc/nupst/config.json" ]; then | ||||
|   echo "Configuration: /etc/nupst/config.json (preserved)" | ||||
|   echo "" | ||||
|   echo "Your existing configuration has been preserved." | ||||
|   if [ $SERVICE_WAS_RUNNING -eq 1 ]; then | ||||
|     echo "The service has been restarted with your current settings." | ||||
|   else | ||||
|     echo "Start the service with: sudo nupst service start" | ||||
|   fi | ||||
| else | ||||
|   echo "Get started:" | ||||
|   echo "  nupst --version" | ||||
|   echo "  nupst help" | ||||
|   echo "  nupst ups add       # Add a UPS device" | ||||
|   echo "  nupst service enable # Enable systemd service" | ||||
| fi | ||||
| echo "" | ||||
| echo "To get started, try:" | ||||
| echo "  nupst help" | ||||
| echo "  nupst setup  # To configure your UPS connection" | ||||
|   | ||||
							
								
								
									
										44
									
								
								mod.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								mod.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| #!/usr/bin/env -S deno run --allow-all | ||||
|  | ||||
| /** | ||||
|  * NUPST - UPS Shutdown Tool | ||||
|  * | ||||
|  * A command-line tool for monitoring SNMP-enabled UPS devices and | ||||
|  * initiating system shutdown when power conditions are critical. | ||||
|  * | ||||
|  * Required Permissions: | ||||
|  * - --allow-net: SNMP communication with UPS devices | ||||
|  * - --allow-read: Read configuration files (/etc/nupst/config.json) | ||||
|  * - --allow-write: Write configuration files | ||||
|  * - --allow-run: Execute system commands (systemctl, shutdown, git, bash) | ||||
|  * - --allow-sys: Access system information (hostname, OS details) | ||||
|  * - --allow-env: Read environment variables | ||||
|  * | ||||
|  * @module | ||||
|  */ | ||||
|  | ||||
| import { NupstCli } from './ts/cli.ts'; | ||||
|  | ||||
| /** | ||||
|  * Main entry point for the NUPST application | ||||
|  * Parses command-line arguments and executes the requested command | ||||
|  */ | ||||
| async function main(): Promise<void> { | ||||
|   const cli = new NupstCli(); | ||||
|  | ||||
|   // Deno.args is already 0-indexed (unlike Node's process.argv which starts at index 2) | ||||
|   // We need to prepend placeholder args to match the existing CLI parser expectations | ||||
|   const args = ['deno', 'mod.ts', ...Deno.args]; | ||||
|  | ||||
|   await cli.parseAndExecute(args); | ||||
| } | ||||
|  | ||||
| // Execute main and handle errors | ||||
| if (import.meta.main) { | ||||
|   try { | ||||
|     await main(); | ||||
|   } catch (error) { | ||||
|     console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); | ||||
|     Deno.exit(1); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										98
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										98
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,58 +1,64 @@ | ||||
| { | ||||
|   "name": "@serve.zone/nupst", | ||||
|   "version": "2.4.3", | ||||
|   "description": "Node.js UPS Shutdown Tool for SNMP-enabled UPS devices", | ||||
|   "main": "dist/index.js", | ||||
|   "bin": { | ||||
|     "nupst": "bin/nupst" | ||||
|   }, | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "build": "tsbuild tsfolders --allowimplicitany", | ||||
|     "start": "bin/nupst", | ||||
|     "setup": "bash setup.sh", | ||||
|     "test": "tstest test/", | ||||
|     "install-global": "sudo bash install.sh", | ||||
|     "uninstall": "sudo bash uninstall.sh" | ||||
|   }, | ||||
|   "version": "5.1.2", | ||||
|   "description": "Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies", | ||||
|   "keywords": [ | ||||
|     "ups", | ||||
|     "snmp", | ||||
|     "power", | ||||
|     "shutdown", | ||||
|     "node", | ||||
|     "cli" | ||||
|     "monitoring", | ||||
|     "cyberpower", | ||||
|     "apc", | ||||
|     "eaton", | ||||
|     "tripplite", | ||||
|     "liebert", | ||||
|     "vertiv", | ||||
|     "battery", | ||||
|     "backup" | ||||
|   ], | ||||
|   "files": [ | ||||
|     "ts/**/*", | ||||
|     "ts_web/**/*", | ||||
|     "dist/**/*", | ||||
|     "dist_*/**/*", | ||||
|     "dist_ts/**/*", | ||||
|     "dist_ts_web/**/*", | ||||
|     "assets/**/*", | ||||
|     "cli.js", | ||||
|     "npmextra.json", | ||||
|     "readme.md" | ||||
|   ], | ||||
|   "author": "", | ||||
|   "homepage": "https://code.foss.global/serve.zone/nupst", | ||||
|   "bugs": { | ||||
|     "url": "https://code.foss.global/serve.zone/nupst/issues" | ||||
|   }, | ||||
|   "repository": { | ||||
|     "type": "git", | ||||
|     "url": "git+https://code.foss.global/serve.zone/nupst.git" | ||||
|   }, | ||||
|   "author": "Serve Zone", | ||||
|   "license": "MIT", | ||||
|   "dependencies": {}, | ||||
|   "devDependencies": { | ||||
|     "@git.zone/tsbuild": "^2.3.2", | ||||
|     "@git.zone/tsrun": "^1.3.3", | ||||
|     "@git.zone/tstest": "^1.0.96", | ||||
|     "@push.rocks/qenv": "^6.1.0", | ||||
|     "@push.rocks/tapbundle": "^5.6.0", | ||||
|     "@types/node": "^20.11.0" | ||||
|   "type": "module", | ||||
|   "bin": { | ||||
|     "nupst": "./bin/nupst-wrapper.js" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "postinstall": "node scripts/install-binary.js", | ||||
|     "prepublishOnly": "echo 'Publishing NUPST binaries to npm...'", | ||||
|     "test": "echo 'Tests are run with Deno: deno task test'", | ||||
|     "build": "echo 'no build needed'" | ||||
|   }, | ||||
|   "files": [ | ||||
|     "bin/", | ||||
|     "scripts/install-binary.js", | ||||
|     "readme.md", | ||||
|     "license", | ||||
|     "changelog.md" | ||||
|   ], | ||||
|   "engines": { | ||||
|     "node": ">=16.0.0" | ||||
|     "node": ">=14.0.0" | ||||
|   }, | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "esbuild", | ||||
|       "mongodb-memory-server", | ||||
|       "puppeteer" | ||||
|     ] | ||||
|   } | ||||
|   "os": [ | ||||
|     "darwin", | ||||
|     "linux", | ||||
|     "win32" | ||||
|   ], | ||||
|   "cpu": [ | ||||
|     "x64", | ||||
|     "arm64" | ||||
|   ], | ||||
|   "publishConfig": { | ||||
|     "access": "public", | ||||
|     "registry": "https://registry.npmjs.org/" | ||||
|   }, | ||||
|   "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" | ||||
| } | ||||
|   | ||||
							
								
								
									
										10187
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10187
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										613
									
								
								readme.plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										613
									
								
								readme.plan.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,613 @@ | ||||
| # NUPST Migration Plan: Node.js → Deno v4.0.0 | ||||
|  | ||||
| **Migration Goal**: Convert NUPST from Node.js to Deno with single-executable distribution | ||||
| **Version**: 3.1.2 → 4.0.0 (breaking changes) **Platforms**: Linux x64/ARM64, macOS x64/ARM64, | ||||
| Windows x64 | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 0: Planning & Preparation | ||||
|  | ||||
| - [x] Research Deno compilation targets and npm: specifier support | ||||
| - [x] Analyze current codebase structure and dependencies | ||||
| - [x] Define CLI command structure simplification | ||||
| - [x] Create detailed migration task list | ||||
| - [ ] Create feature branch: `migration/deno-v4` | ||||
| - [ ] Backup current working state with git tag: `v3.1.2-pre-deno-migration` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 1: Dependency Migration (4-6 hours) | ||||
|  | ||||
| ### 1.1 Analyze Current Dependencies | ||||
|  | ||||
| - [ ] List all production dependencies from `package.json` | ||||
|   - Current: `net-snmp@3.20.0` | ||||
| - [ ] List all dev dependencies to be removed | ||||
|   - `@git.zone/tsbuild`, `@git.zone/tsrun`, `@git.zone/tstest`, `@push.rocks/qenv`, | ||||
|     `@push.rocks/tapbundle`, `@types/node` | ||||
| - [ ] Identify Node.js built-in module usage | ||||
|   - `child_process` (execSync) | ||||
|   - `https` (for version checking) | ||||
|   - `fs` (readFileSync, writeFileSync, existsSync, mkdirSync) | ||||
|   - `path` (join, dirname, resolve) | ||||
|  | ||||
| ### 1.2 Create Deno Configuration | ||||
|  | ||||
| - [ ] Create `deno.json` with project configuration | ||||
|   ```json | ||||
|   { | ||||
|     "name": "@serve.zone/nupst", | ||||
|     "version": "4.0.0", | ||||
|     "exports": "./mod.ts", | ||||
|     "tasks": { | ||||
|       "dev": "deno run --allow-all mod.ts", | ||||
|       "compile": "deno task compile:all", | ||||
|       "compile:all": "bash scripts/compile-all.sh", | ||||
|       "test": "deno test --allow-all tests/", | ||||
|       "check": "deno check mod.ts" | ||||
|     }, | ||||
|     "lint": { | ||||
|       "rules": { | ||||
|         "tags": ["recommended"] | ||||
|       } | ||||
|     }, | ||||
|     "fmt": { | ||||
|       "useTabs": false, | ||||
|       "lineWidth": 100, | ||||
|       "indentWidth": 2, | ||||
|       "semiColons": true | ||||
|     }, | ||||
|     "compilerOptions": { | ||||
|       "lib": ["deno.window"], | ||||
|       "strict": true | ||||
|     }, | ||||
|     "imports": { | ||||
|       "@std/cli": "jsr:@std/cli@^1.0.0", | ||||
|       "@std/fmt": "jsr:@std/fmt@^1.0.0", | ||||
|       "@std/path": "jsr:@std/path@^1.0.0" | ||||
|     } | ||||
|   } | ||||
|   ``` | ||||
|  | ||||
| ### 1.3 Update Import Statements | ||||
|  | ||||
| - [ ] `ts/snmp/manager.ts`: Change `import * as snmp from 'net-snmp'` to | ||||
|       `import * as snmp from "npm:net-snmp@3.20.0"` | ||||
| - [ ] `ts/cli.ts`: Change `import { execSync } from 'child_process'` to | ||||
|       `import { execSync } from "node:child_process"` | ||||
| - [ ] `ts/nupst.ts`: Change `import * as https from 'https'` to | ||||
|       `import * as https from "node:https"` | ||||
| - [ ] Search for all `fs` imports and update to `node:fs` | ||||
| - [ ] Search for all `path` imports and update to `node:path` | ||||
| - [ ] Update all relative imports to use `.ts` extension instead of `.js` | ||||
|   - Example: `'./nupst.js'` → `'./nupst.ts'` | ||||
|  | ||||
| ### 1.4 Test npm: Specifier Compatibility | ||||
|  | ||||
| - [ ] Create test file: `tests/snmp_compatibility_test.ts` | ||||
| - [ ] Test SNMP v1 connection with npm:net-snmp | ||||
| - [ ] Test SNMP v2c connection with npm:net-snmp | ||||
| - [ ] Test SNMP v3 connection with npm:net-snmp | ||||
| - [ ] Verify native addon loading works in compiled binary | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 2: Code Structure Refactoring (3-4 hours) | ||||
|  | ||||
| ### 2.1 Create Main Entry Point | ||||
|  | ||||
| - [ ] Create `mod.ts` as main Deno entry point: | ||||
|   ```typescript | ||||
|   #!/usr/bin/env -S deno run --allow-all | ||||
|  | ||||
|   /** | ||||
|    * NUPST - UPS Shutdown Tool for Deno | ||||
|    * | ||||
|    * Required Permissions: | ||||
|    * --allow-net: SNMP communication with UPS devices | ||||
|    * --allow-read: Configuration file access (/etc/nupst/config.json) | ||||
|    * --allow-write: Configuration file updates | ||||
|    * --allow-run: System commands (systemctl, shutdown) | ||||
|    * --allow-sys: System information (hostname, OS info) | ||||
|    * --allow-env: Environment variables | ||||
|    */ | ||||
|  | ||||
|   import { NupstCli } from './ts/cli.ts'; | ||||
|  | ||||
|   const cli = new NupstCli(); | ||||
|   await cli.parseAndExecute(Deno.args); | ||||
|   ``` | ||||
|  | ||||
| ### 2.2 Update All Import Extensions | ||||
|  | ||||
| Files to update (change .js → .ts in imports): | ||||
|  | ||||
| - [ ] `ts/index.ts` | ||||
| - [ ] `ts/cli.ts` (imports from ./nupst.js, ./logger.js) | ||||
| - [ ] `ts/nupst.ts` (imports from ./snmp/manager.js, ./daemon.js, etc.) | ||||
| - [ ] `ts/daemon.ts` (imports from ./snmp/manager.js, ./logger.js, ./helpers/) | ||||
| - [ ] `ts/systemd.ts` (imports from ./daemon.js, ./logger.js) | ||||
| - [ ] `ts/cli/service-handler.ts` | ||||
| - [ ] `ts/cli/group-handler.ts` | ||||
| - [ ] `ts/cli/ups-handler.ts` | ||||
| - [ ] `ts/snmp/index.ts` | ||||
| - [ ] `ts/snmp/manager.ts` (imports from ./types.js, ./oid-sets.js) | ||||
| - [ ] `ts/snmp/oid-sets.ts` (imports from ./types.js) | ||||
| - [ ] `ts/helpers/index.ts` | ||||
| - [ ] `ts/logger.ts` | ||||
|  | ||||
| ### 2.3 Update process.argv References | ||||
|  | ||||
| - [ ] `ts/cli.ts`: Replace `process.argv` with `Deno.args` (adjust indexing: process.argv[2] → | ||||
|       Deno.args[0]) | ||||
| - [ ] Update parseAndExecute method to work with Deno.args (0-indexed vs 2-indexed) | ||||
|  | ||||
| ### 2.4 Update File System Operations | ||||
|  | ||||
| - [ ] Search for `fs.readFileSync()` → Consider using `Deno.readTextFile()` or keep node:fs | ||||
| - [ ] Search for `fs.writeFileSync()` → Consider using `Deno.writeTextFile()` or keep node:fs | ||||
| - [ ] Search for `fs.existsSync()` → Keep node:fs or use Deno.stat | ||||
| - [ ] Search for `fs.mkdirSync()` → Keep node:fs or use Deno.mkdir | ||||
| - [ ] Decision: Keep node:fs for consistency or migrate to Deno APIs? | ||||
|  | ||||
| ### 2.5 Update Path Operations | ||||
|  | ||||
| - [ ] Verify all `path.join()`, `path.resolve()`, `path.dirname()` work with node:path | ||||
| - [ ] Consider using `@std/path` from JSR for better Deno integration | ||||
|  | ||||
| ### 2.6 Handle __dirname and __filename | ||||
|  | ||||
| - [ ] Find all `__dirname` usage | ||||
| - [ ] Replace with `import.meta.dirname` (Deno) or `dirname(fromFileUrl(import.meta.url))` | ||||
| - [ ] Find all `__filename` usage | ||||
| - [ ] Replace with `import.meta.filename` or `fromFileUrl(import.meta.url)` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 3: CLI Command Simplification (3-4 hours) | ||||
|  | ||||
| ### 3.1 Design New Command Structure | ||||
|  | ||||
| Current → New mapping: | ||||
|  | ||||
| ``` | ||||
| OLD                          NEW | ||||
| ===                          === | ||||
| nupst enable                 → nupst service enable | ||||
| nupst disable                → nupst service disable | ||||
| nupst daemon-start           → nupst service start-daemon | ||||
| nupst logs                   → nupst service logs | ||||
| nupst stop                   → nupst service stop | ||||
| nupst start                  → nupst service start | ||||
| nupst status                 → nupst service status | ||||
|  | ||||
| nupst add                    → nupst ups add | ||||
| nupst edit [id]              → nupst ups edit [id] | ||||
| nupst delete <id>            → nupst ups remove <id> | ||||
| nupst list                   → nupst ups list | ||||
| nupst setup                  → nupst ups edit (removed alias) | ||||
| nupst test                   → nupst ups test | ||||
|  | ||||
| nupst group list             → nupst group list | ||||
| nupst group add              → nupst group add | ||||
| nupst group edit <id>        → nupst group edit <id> | ||||
| nupst group delete <id>      → nupst group remove <id> | ||||
|  | ||||
| nupst config                 → nupst config show | ||||
| nupst update                 → nupst update | ||||
| nupst uninstall              → nupst uninstall | ||||
| nupst help                   → nupst help / nupst --help | ||||
| (new)                        → nupst --version | ||||
| ``` | ||||
|  | ||||
| ### 3.2 Update CLI Parser (ts/cli.ts) | ||||
|  | ||||
| - [ ] Refactor `parseAndExecute()` to handle new command structure | ||||
| - [ ] Add `service` subcommand handler | ||||
| - [ ] Add `ups` subcommand handler | ||||
| - [ ] Keep `group` subcommand handler (already exists, just update delete→remove) | ||||
| - [ ] Add `config` subcommand handler with `show` default | ||||
| - [ ] Add `--version` flag handler | ||||
| - [ ] Update `help` command to show new structure | ||||
| - [ ] Add command aliases: `rm` → `remove`, `ls` → `list` | ||||
| - [ ] Add `--json` flag for machine-readable output (future enhancement) | ||||
|  | ||||
| ### 3.3 Update Command Handlers | ||||
|  | ||||
| - [ ] `ts/cli/service-handler.ts`: Update method names if needed | ||||
| - [ ] `ts/cli/ups-handler.ts`: Rename `delete()` → `remove()`, remove `setup` method | ||||
| - [ ] `ts/cli/group-handler.ts`: Rename `delete()` → `remove()` | ||||
|  | ||||
| ### 3.4 Improve Help Messages | ||||
|  | ||||
| - [ ] Update `showHelp()` in ts/cli.ts with new command structure | ||||
| - [ ] Update `showGroupHelp()` in ts/cli.ts | ||||
| - [ ] Add `showServiceHelp()` method | ||||
| - [ ] Add `showUpsHelp()` method | ||||
| - [ ] Add `showConfigHelp()` method | ||||
| - [ ] Include usage examples in help text | ||||
|  | ||||
| ### 3.5 Add Version Command | ||||
|  | ||||
| - [ ] Read version from deno.json | ||||
| - [ ] Create `--version` handler in CLI | ||||
| - [ ] Display version with build info | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 4: Compilation & Distribution (2-3 hours) | ||||
|  | ||||
| ### 4.1 Create Compilation Script | ||||
|  | ||||
| - [ ] Create directory: `scripts/` | ||||
| - [ ] Create `scripts/compile-all.sh`: | ||||
|   ```bash | ||||
|   #!/bin/bash | ||||
|   set -e | ||||
|  | ||||
|   VERSION=$(cat deno.json | jq -r '.version') | ||||
|   BINARY_DIR="dist/binaries" | ||||
|  | ||||
|   echo "Compiling NUPST v${VERSION} for all platforms..." | ||||
|   mkdir -p "$BINARY_DIR" | ||||
|  | ||||
|   # Linux x86_64 | ||||
|   echo "→ Linux x86_64..." | ||||
|   deno compile --allow-all --output "$BINARY_DIR/nupst-linux-x64" \ | ||||
|     --target x86_64-unknown-linux-gnu mod.ts | ||||
|  | ||||
|   # Linux ARM64 | ||||
|   echo "→ Linux ARM64..." | ||||
|   deno compile --allow-all --output "$BINARY_DIR/nupst-linux-arm64" \ | ||||
|     --target aarch64-unknown-linux-gnu mod.ts | ||||
|  | ||||
|   # macOS x86_64 | ||||
|   echo "→ macOS x86_64..." | ||||
|   deno compile --allow-all --output "$BINARY_DIR/nupst-macos-x64" \ | ||||
|     --target x86_64-apple-darwin mod.ts | ||||
|  | ||||
|   # macOS ARM64 | ||||
|   echo "→ macOS ARM64..." | ||||
|   deno compile --allow-all --output "$BINARY_DIR/nupst-macos-arm64" \ | ||||
|     --target aarch64-apple-darwin mod.ts | ||||
|  | ||||
|   # Windows x86_64 | ||||
|   echo "→ Windows x86_64..." | ||||
|   deno compile --allow-all --output "$BINARY_DIR/nupst-windows-x64.exe" \ | ||||
|     --target x86_64-pc-windows-msvc mod.ts | ||||
|  | ||||
|   echo "" | ||||
|   echo "✓ Compilation complete!" | ||||
|   ls -lh "$BINARY_DIR/" | ||||
|   ``` | ||||
| - [ ] Make script executable: `chmod +x scripts/compile-all.sh` | ||||
|  | ||||
| ### 4.2 Test Local Compilation | ||||
|  | ||||
| - [ ] Run `deno task compile` to compile for all platforms | ||||
| - [ ] Verify all 5 binaries are created | ||||
| - [ ] Check binary sizes (should be reasonable, < 100MB each) | ||||
| - [ ] Test local binary on current platform: `./dist/binaries/nupst-linux-x64 --version` | ||||
|  | ||||
| ### 4.3 Update Installation Scripts | ||||
|  | ||||
| - [ ] Update `install.sh`: | ||||
|   - Remove Node.js download logic (lines dealing with vendor/node-*) | ||||
|   - Add detection for binary download from GitHub releases | ||||
|   - Simplify to download appropriate binary based on OS/arch | ||||
|   - Place binary in `/opt/nupst/bin/nupst` | ||||
|   - Create symlink: `/usr/local/bin/nupst → /opt/nupst/bin/nupst` | ||||
|   - Update to v4.0.0 in script | ||||
| - [ ] Simplify or remove `setup.sh` (no longer needed without Node.js) | ||||
| - [ ] Update `bin/nupst` launcher: | ||||
|   - Option A: Keep as simple wrapper | ||||
|   - Option B: Remove and symlink directly to binary | ||||
| - [ ] Update `uninstall.sh`: | ||||
|   - Remove vendor directory cleanup | ||||
|   - Update paths to new binary location | ||||
|  | ||||
| ### 4.4 Update Systemd Service | ||||
|  | ||||
| - [ ] Update systemd service file path in `ts/systemd.ts` | ||||
| - [ ] Verify ExecStart points to correct binary location: `/opt/nupst/bin/nupst daemon-start` | ||||
| - [ ] Remove Node.js environment variables if any | ||||
| - [ ] Test service installation and startup | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 5: Testing & Validation (4-6 hours) | ||||
|  | ||||
| ### 5.1 Create Deno Test Suite | ||||
|  | ||||
| - [ ] Create `tests/` directory (or migrate from existing `test/`) | ||||
| - [ ] Create `tests/snmp_test.ts`: Test SNMP manager functionality | ||||
| - [ ] Create `tests/config_test.ts`: Test configuration loading/saving | ||||
| - [ ] Create `tests/cli_test.ts`: Test CLI parsing and command routing | ||||
| - [ ] Create `tests/daemon_test.ts`: Test daemon logic | ||||
| - [ ] Remove dependency on @git.zone/tstest and @push.rocks/tapbundle | ||||
| - [ ] Use Deno's built-in test runner (`Deno.test()`) | ||||
|  | ||||
| ### 5.2 Unit Tests | ||||
|  | ||||
| - [ ] Test SNMP connection with mock responses | ||||
| - [ ] Test configuration validation | ||||
| - [ ] Test UPS status parsing for different models | ||||
| - [ ] Test group logic (redundant/non-redundant modes) | ||||
| - [ ] Test threshold checking | ||||
| - [ ] Test version comparison logic | ||||
|  | ||||
| ### 5.3 Integration Tests | ||||
|  | ||||
| - [ ] Test CLI command parsing for all commands | ||||
| - [ ] Test config file creation and updates | ||||
| - [ ] Test UPS add/edit/remove operations | ||||
| - [ ] Test group add/edit/remove operations | ||||
| - [ ] Mock systemd operations for testing | ||||
|  | ||||
| ### 5.4 Binary Testing | ||||
|  | ||||
| - [ ] Test compiled binary on Linux x64 | ||||
| - [ ] Test compiled binary on Linux ARM64 (if available) | ||||
| - [ ] Test compiled binary on macOS x64 (if available) | ||||
| - [ ] Test compiled binary on macOS ARM64 (if available) | ||||
| - [ ] Test compiled binary on Windows x64 (if available) | ||||
| - [ ] Verify SNMP functionality works in compiled binary | ||||
| - [ ] Verify config file operations work in compiled binary | ||||
| - [ ] Test systemd integration with compiled binary | ||||
|  | ||||
| ### 5.5 Performance Testing | ||||
|  | ||||
| - [ ] Measure binary size for each platform | ||||
| - [ ] Measure startup time: `time ./nupst-linux-x64 --version` | ||||
| - [ ] Measure memory footprint during daemon operation | ||||
| - [ ] Compare with Node.js version performance | ||||
| - [ ] Document performance metrics | ||||
|  | ||||
| ### 5.6 Upgrade Path Testing | ||||
|  | ||||
| - [ ] Create test with v3.x config | ||||
| - [ ] Verify v4.x can read existing config | ||||
| - [ ] Test migration from old commands to new commands | ||||
| - [ ] Verify systemd service upgrade path | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 6: Distribution Strategy (2-3 hours) | ||||
|  | ||||
| ### 6.1 GitHub Actions Workflow | ||||
|  | ||||
| - [ ] Create `.github/workflows/release.yml`: | ||||
|   ```yaml | ||||
|   name: Release | ||||
|   on: | ||||
|     push: | ||||
|       tags: | ||||
|         - 'v*' | ||||
|   jobs: | ||||
|     build: | ||||
|       runs-on: ubuntu-latest | ||||
|       steps: | ||||
|         - uses: actions/checkout@v4 | ||||
|         - uses: denoland/setup-deno@v1 | ||||
|           with: | ||||
|             deno-version: v1.x | ||||
|         - name: Compile binaries | ||||
|           run: deno task compile | ||||
|         - name: Generate checksums | ||||
|           run: | | ||||
|             cd dist/binaries | ||||
|             sha256sum * > SHA256SUMS | ||||
|         - name: Create Release | ||||
|           uses: softprops/action-gh-release@v1 | ||||
|           with: | ||||
|             files: dist/binaries/* | ||||
|             generate_release_notes: true | ||||
|   ``` | ||||
|  | ||||
| ### 6.2 Update package.json for npm | ||||
|  | ||||
| - [ ] Update version to 4.0.0 | ||||
| - [ ] Update description to mention Deno | ||||
| - [ ] Add postinstall script to symlink appropriate binary: | ||||
|   ```json | ||||
|   { | ||||
|     "name": "@serve.zone/nupst", | ||||
|     "version": "4.0.0", | ||||
|     "description": "UPS Shutdown Tool - Deno-based single executable", | ||||
|     "bin": { | ||||
|       "nupst": "bin/nupst-npm-wrapper.js" | ||||
|     }, | ||||
|     "type": "module", | ||||
|     "scripts": { | ||||
|       "postinstall": "node bin/setup-npm-binary.js" | ||||
|     }, | ||||
|     "files": [ | ||||
|       "dist/binaries/*", | ||||
|       "bin/*" | ||||
|     ] | ||||
|   } | ||||
|   ``` | ||||
| - [ ] Create `bin/setup-npm-binary.js` to symlink correct binary | ||||
| - [ ] Create `bin/nupst-npm-wrapper.js` as entry point | ||||
|  | ||||
| ### 6.3 Verify Distribution Methods | ||||
|  | ||||
| - [ ] Test GitHub release download and installation | ||||
| - [ ] Test npm install from tarball | ||||
| - [ ] Test direct install.sh script | ||||
| - [ ] Verify all methods create working installation | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 7: Documentation Updates (2-3 hours) | ||||
|  | ||||
| ### 7.1 Update README.md | ||||
|  | ||||
| - [ ] Remove Node.js requirements section | ||||
| - [ ] Update features list (mention Deno, single executable) | ||||
| - [ ] Update installation methods: | ||||
|   - Method 1: Quick install script (updated) | ||||
|   - Method 2: GitHub releases (new) | ||||
|   - Method 3: npm (updated with notes) | ||||
| - [ ] Update usage section with new command structure | ||||
| - [ ] Add command mapping table (v3 → v4) | ||||
| - [ ] Update platform support matrix (note: no Windows ARM) | ||||
| - [ ] Update "System Changes" section (no vendor directory) | ||||
| - [ ] Update security section (remove Node.js mentions) | ||||
| - [ ] Update uninstallation instructions | ||||
|  | ||||
| ### 7.2 Create MIGRATION.md | ||||
|  | ||||
| - [ ] Create detailed migration guide from v3.x to v4.x | ||||
| - [ ] List all breaking changes: | ||||
|   1. CLI command structure reorganization | ||||
|   2. No Node.js requirement | ||||
|   3. Windows ARM not supported | ||||
|   4. Installation path changes | ||||
| - [ ] Provide command mapping table | ||||
| - [ ] Explain config compatibility | ||||
| - [ ] Document upgrade procedure | ||||
| - [ ] Add rollback instructions | ||||
|  | ||||
| ### 7.3 Update CHANGELOG.md | ||||
|  | ||||
| - [ ] Add v4.0.0 section with all breaking changes | ||||
| - [ ] List new features (Deno, single executable) | ||||
| - [ ] List improvements (startup time, binary size) | ||||
| - [ ] List removed features (Windows ARM, setup command alias) | ||||
| - [ ] Migration guide reference | ||||
|  | ||||
| ### 7.4 Update Help Text | ||||
|  | ||||
| - [ ] Ensure all help commands show new structure | ||||
| - [ ] Add examples for common operations | ||||
| - [ ] Include migration notes in help output | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Phase 8: Cleanup & Finalization (1 hour) | ||||
|  | ||||
| ### 8.1 Remove Obsolete Files | ||||
|  | ||||
| - [ ] Delete `vendor/` directory (Node.js binaries) | ||||
| - [ ] Delete `dist/` directory (old compiled JS) | ||||
| - [ ] Delete `dist_ts/` directory (old compiled TS) | ||||
| - [ ] Delete `node_modules/` directory | ||||
| - [ ] Remove or update `tsconfig.json` (decide if needed for npm compatibility) | ||||
| - [ ] Remove `setup.sh` if no longer needed | ||||
| - [ ] Remove old test files in `test/` if migrated to `tests/` | ||||
| - [ ] Delete `pnpm-lock.yaml` | ||||
|  | ||||
| ### 8.2 Update Git Configuration | ||||
|  | ||||
| - [ ] Update `.gitignore`: | ||||
|   ``` | ||||
|   # Deno | ||||
|   .deno/ | ||||
|   deno.lock | ||||
|  | ||||
|   # Compiled binaries | ||||
|   dist/binaries/ | ||||
|  | ||||
|   # Old Node.js artifacts (to be removed) | ||||
|   node_modules/ | ||||
|   vendor/ | ||||
|   dist/ | ||||
|   dist_ts/ | ||||
|   pnpm-lock.yaml | ||||
|   ``` | ||||
| - [ ] Add `deno.lock` to version control | ||||
| - [ ] Create `.denoignore` if needed | ||||
|  | ||||
| ### 8.3 Final Validation | ||||
|  | ||||
| - [ ] Run `deno check mod.ts` - verify no type errors | ||||
| - [ ] Run `deno lint` - verify code quality | ||||
| - [ ] Run `deno fmt --check` - verify formatting | ||||
| - [ ] Run `deno task test` - verify all tests pass | ||||
| - [ ] Run `deno task compile` - verify all binaries compile | ||||
| - [ ] Test each binary manually | ||||
|  | ||||
| ### 8.4 Prepare for Release | ||||
|  | ||||
| - [ ] Create git tag: `v4.0.0` | ||||
| - [ ] Push to main branch | ||||
| - [ ] Push tags to trigger release workflow | ||||
| - [ ] Verify GitHub Actions workflow succeeds | ||||
| - [ ] Verify binaries are attached to release | ||||
| - [ ] Test installation from GitHub release | ||||
| - [ ] Publish to npm: `npm publish` | ||||
| - [ ] Test npm installation | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Rollback Strategy | ||||
|  | ||||
| If critical issues are discovered: | ||||
|  | ||||
| - [ ] Keep `v3.1.2` tag available for rollback | ||||
| - [ ] Create `v3-stable` branch for continued v3 maintenance | ||||
| - [ ] Update install.sh to offer v3/v4 choice | ||||
| - [ ] Document known issues in GitHub Issues | ||||
| - [ ] Provide downgrade instructions in docs | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Success Criteria Checklist | ||||
|  | ||||
| - [ ] ✅ All 5 platform binaries compile successfully | ||||
| - [ ] ✅ Binary sizes are reasonable (< 100MB per platform) | ||||
| - [ ] ✅ Startup time < 2 seconds | ||||
| - [ ] ✅ SNMP v1/v2c/v3 functionality verified on real UPS device | ||||
| - [ ] ✅ All CLI commands work with new structure | ||||
| - [ ] ✅ Config file compatibility maintained | ||||
| - [ ] ✅ Systemd integration works on Linux | ||||
| - [ ] ✅ Installation scripts work on fresh systems | ||||
| - [ ] ✅ npm package still installable and functional | ||||
| - [ ] ✅ All tests pass | ||||
| - [ ] ✅ Documentation is complete and accurate | ||||
| - [ ] ✅ GitHub release created with binaries | ||||
| - [ ] ✅ Migration guide tested by following it step-by-step | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Timeline | ||||
|  | ||||
| - **Phase 0**: 1 hour ✓ (in progress) | ||||
| - **Phase 1**: 4-6 hours | ||||
| - **Phase 2**: 3-4 hours | ||||
| - **Phase 3**: 3-4 hours | ||||
| - **Phase 4**: 2-3 hours | ||||
| - **Phase 5**: 4-6 hours | ||||
| - **Phase 6**: 2-3 hours | ||||
| - **Phase 7**: 2-3 hours | ||||
| - **Phase 8**: 1 hour | ||||
|  | ||||
| **Total Estimate**: 22-31 hours | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## Notes & Decisions | ||||
|  | ||||
| ### Key Decisions Made: | ||||
|  | ||||
| 1. ✅ Use npm:net-snmp (no pure Deno SNMP library available) | ||||
| 2. ✅ Major version bump to 4.0.0 (breaking changes) | ||||
| 3. ✅ CLI reorganization with subcommands | ||||
| 4. ✅ Keep npm publishing alongside binary distribution | ||||
| 5. ✅ 5 platform targets (Windows ARM not supported by Deno yet) | ||||
|  | ||||
| ### Open Questions: | ||||
|  | ||||
| - [ ] Should we keep tsconfig.json for npm package compatibility? | ||||
| - [ ] Should we fully migrate to Deno APIs (Deno.readFile) or keep node:fs? | ||||
| - [ ] Should we remove the `bin/nupst` wrapper or keep it? | ||||
| - [ ] Should setup.sh be completely removed or kept for dependencies? | ||||
|  | ||||
| ### Risk Areas: | ||||
|  | ||||
| - ⚠️ SNMP native addon compatibility in compiled binaries (HIGH PRIORITY TO TEST) | ||||
| - ⚠️ Systemd integration with new binary structure | ||||
| - ⚠️ Config migration from v3 to v4 | ||||
| - ⚠️ npm package installation with embedded binaries | ||||
							
								
								
									
										66
									
								
								scripts/compile-all.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										66
									
								
								scripts/compile-all.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| #!/bin/bash | ||||
| set -e | ||||
|  | ||||
| # Get version from deno.json | ||||
| VERSION=$(cat deno.json | grep -o '"version": *"[^"]*"' | cut -d'"' -f4) | ||||
| BINARY_DIR="dist/binaries" | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  NUPST Compilation Script" | ||||
| echo "  Version: ${VERSION}" | ||||
| echo "================================================" | ||||
| echo "" | ||||
| echo "Compiling for all supported platforms..." | ||||
| echo "" | ||||
|  | ||||
| # Clean up old binaries and create fresh directory | ||||
| rm -rf "$BINARY_DIR" | ||||
| mkdir -p "$BINARY_DIR" | ||||
| echo "→ Cleaned old binaries from $BINARY_DIR" | ||||
| echo "" | ||||
|  | ||||
| # Linux x86_64 | ||||
| echo "→ Compiling for Linux x86_64..." | ||||
| deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-linux-x64" \ | ||||
|   --target x86_64-unknown-linux-gnu mod.ts | ||||
| echo "  ✓ Linux x86_64 complete" | ||||
| echo "" | ||||
|  | ||||
| # Linux ARM64 | ||||
| echo "→ Compiling for Linux ARM64..." | ||||
| deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-linux-arm64" \ | ||||
|   --target aarch64-unknown-linux-gnu mod.ts | ||||
| echo "  ✓ Linux ARM64 complete" | ||||
| echo "" | ||||
|  | ||||
| # macOS x86_64 | ||||
| echo "→ Compiling for macOS x86_64..." | ||||
| deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-macos-x64" \ | ||||
|   --target x86_64-apple-darwin mod.ts | ||||
| echo "  ✓ macOS x86_64 complete" | ||||
| echo "" | ||||
|  | ||||
| # macOS ARM64 | ||||
| echo "→ Compiling for macOS ARM64..." | ||||
| deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-macos-arm64" \ | ||||
|   --target aarch64-apple-darwin mod.ts | ||||
| echo "  ✓ macOS ARM64 complete" | ||||
| echo "" | ||||
|  | ||||
| # Windows x86_64 | ||||
| echo "→ Compiling for Windows x86_64..." | ||||
| deno compile --allow-all --no-check --output "$BINARY_DIR/nupst-windows-x64.exe" \ | ||||
|   --target x86_64-pc-windows-msvc mod.ts | ||||
| echo "  ✓ Windows x86_64 complete" | ||||
| echo "" | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  Compilation Summary" | ||||
| echo "================================================" | ||||
| echo "" | ||||
| ls -lh "$BINARY_DIR/" | tail -n +2 | ||||
| echo "" | ||||
| echo "✓ All binaries compiled successfully!" | ||||
| echo "" | ||||
| echo "Binary location: $BINARY_DIR/" | ||||
| echo "" | ||||
							
								
								
									
										231
									
								
								scripts/install-binary.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								scripts/install-binary.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,231 @@ | ||||
| #!/usr/bin/env node | ||||
|  | ||||
| /** | ||||
|  * 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 { fileURLToPath } from 'url'; | ||||
| import https from 'https'; | ||||
| import { pipeline } from 'stream'; | ||||
| import { promisify } from 'util'; | ||||
| import { createWriteStream } from 'fs'; | ||||
|  | ||||
| const __filename = fileURLToPath(import.meta.url); | ||||
| const __dirname = dirname(__filename); | ||||
| const streamPipeline = promisify(pipeline); | ||||
|  | ||||
| // Configuration | ||||
| const REPO_BASE = 'https://code.foss.global/serve.zone/nupst'; | ||||
| const VERSION = process.env.npm_package_version || '5.0.5'; | ||||
|  | ||||
| function getBinaryInfo() { | ||||
|   const plat = platform(); | ||||
|   const architecture = arch(); | ||||
|  | ||||
|   const platformMap = { | ||||
|     'darwin': 'macos', | ||||
|     'linux': 'linux', | ||||
|     'win32': 'windows' | ||||
|   }; | ||||
|  | ||||
|   const archMap = { | ||||
|     'x64': 'x64', | ||||
|     'arm64': 'arm64' | ||||
|   }; | ||||
|  | ||||
|   const mappedPlatform = platformMap[plat]; | ||||
|   const mappedArch = archMap[architecture]; | ||||
|  | ||||
|   if (!mappedPlatform || !mappedArch) { | ||||
|     return { supported: false, platform: plat, arch: architecture }; | ||||
|   } | ||||
|  | ||||
|   let binaryName = `nupst-${mappedPlatform}-${mappedArch}`; | ||||
|   if (plat === 'win32') { | ||||
|     binaryName += '.exe'; | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     supported: true, | ||||
|     platform: mappedPlatform, | ||||
|     arch: mappedArch, | ||||
|     binaryName, | ||||
|     originalPlatform: plat | ||||
|   }; | ||||
| } | ||||
|  | ||||
| function downloadFile(url, destination) { | ||||
|   return new Promise((resolve, reject) => { | ||||
|     console.log(`Downloading from: ${url}`); | ||||
|  | ||||
|     // Follow redirects | ||||
|     const download = (url, redirectCount = 0) => { | ||||
|       if (redirectCount > 5) { | ||||
|         reject(new Error('Too many redirects')); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       https.get(url, (response) => { | ||||
|         if (response.statusCode === 301 || response.statusCode === 302) { | ||||
|           console.log(`Following redirect to: ${response.headers.location}`); | ||||
|           download(response.headers.location, redirectCount + 1); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         if (response.statusCode !== 200) { | ||||
|           reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`)); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         const totalSize = parseInt(response.headers['content-length'], 10); | ||||
|         let downloadedSize = 0; | ||||
|         let lastProgress = 0; | ||||
|  | ||||
|         response.on('data', (chunk) => { | ||||
|           downloadedSize += chunk.length; | ||||
|           const progress = Math.round((downloadedSize / totalSize) * 100); | ||||
|  | ||||
|           // Only log every 10% to reduce noise | ||||
|           if (progress >= lastProgress + 10) { | ||||
|             console.log(`Download progress: ${progress}%`); | ||||
|             lastProgress = progress; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|         const file = createWriteStream(destination); | ||||
|  | ||||
|         pipeline(response, file, (err) => { | ||||
|           if (err) { | ||||
|             reject(err); | ||||
|           } else { | ||||
|             console.log('Download complete!'); | ||||
|             resolve(); | ||||
|           } | ||||
|         }); | ||||
|       }).on('error', reject); | ||||
|     }; | ||||
|  | ||||
|     download(url); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| async function main() { | ||||
|   console.log('==========================================='); | ||||
|   console.log('  NUPST - Binary Installation'); | ||||
|   console.log('==========================================='); | ||||
|   console.log(''); | ||||
|  | ||||
|   const binaryInfo = getBinaryInfo(); | ||||
|  | ||||
|   if (!binaryInfo.supported) { | ||||
|     console.error(`❌ Error: Unsupported platform/architecture: ${binaryInfo.platform}/${binaryInfo.arch}`); | ||||
|     console.error(''); | ||||
|     console.error('Supported platforms:'); | ||||
|     console.error('  • Linux (x64, arm64)'); | ||||
|     console.error('  • macOS (x64, arm64)'); | ||||
|     console.error('  • Windows (x64)'); | ||||
|     console.error(''); | ||||
|     console.error('If you believe your platform should be supported, please file an issue:'); | ||||
|     console.error('  https://code.foss.global/serve.zone/nupst/issues'); | ||||
|     process.exit(1); | ||||
|   } | ||||
|  | ||||
|   console.log(`Platform: ${binaryInfo.platform} (${binaryInfo.originalPlatform})`); | ||||
|   console.log(`Architecture: ${binaryInfo.arch}`); | ||||
|   console.log(`Binary: ${binaryInfo.binaryName}`); | ||||
|   console.log(`Version: ${VERSION}`); | ||||
|   console.log(''); | ||||
|  | ||||
|   // Create dist/binaries directory if it doesn't exist | ||||
|   const binariesDir = join(__dirname, '..', 'dist', 'binaries'); | ||||
|   if (!existsSync(binariesDir)) { | ||||
|     console.log('Creating binaries directory...'); | ||||
|     mkdirSync(binariesDir, { recursive: true }); | ||||
|   } | ||||
|  | ||||
|   const binaryPath = join(binariesDir, binaryInfo.binaryName); | ||||
|  | ||||
|   // Check if binary already exists and skip download | ||||
|   if (existsSync(binaryPath)) { | ||||
|     console.log('✓ Binary already exists, skipping download'); | ||||
|   } else { | ||||
|     // Construct download URL | ||||
|     // Try release URL first, fall back to raw branch if needed | ||||
|     const releaseUrl = `${REPO_BASE}/releases/download/v${VERSION}/${binaryInfo.binaryName}`; | ||||
|     const fallbackUrl = `${REPO_BASE}/raw/branch/main/dist/binaries/${binaryInfo.binaryName}`; | ||||
|  | ||||
|     console.log('Downloading platform-specific binary...'); | ||||
|     console.log('This may take a moment depending on your connection speed.'); | ||||
|     console.log(''); | ||||
|  | ||||
|     try { | ||||
|       // Try downloading from release | ||||
|       await downloadFile(releaseUrl, binaryPath); | ||||
|     } catch (err) { | ||||
|       console.log(`Release download failed: ${err.message}`); | ||||
|       console.log('Trying fallback URL...'); | ||||
|  | ||||
|       try { | ||||
|         // Try fallback URL | ||||
|         await downloadFile(fallbackUrl, binaryPath); | ||||
|       } catch (fallbackErr) { | ||||
|         console.error(`❌ Error: Failed to download binary`); | ||||
|         console.error(`  Primary URL: ${releaseUrl}`); | ||||
|         console.error(`  Fallback URL: ${fallbackUrl}`); | ||||
|         console.error(''); | ||||
|         console.error('This might be because:'); | ||||
|         console.error('1. The release has not been created yet'); | ||||
|         console.error('2. Network connectivity issues'); | ||||
|         console.error('3. The version specified does not exist'); | ||||
|         console.error(''); | ||||
|         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'); | ||||
|  | ||||
|         // Clean up partial download | ||||
|         if (existsSync(binaryPath)) { | ||||
|           unlinkSync(binaryPath); | ||||
|         } | ||||
|  | ||||
|         process.exit(1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     console.log(`✓ Binary downloaded successfully`); | ||||
|   } | ||||
|  | ||||
|   // On Unix-like systems, ensure the binary is executable | ||||
|   if (binaryInfo.originalPlatform !== 'win32') { | ||||
|     try { | ||||
|       console.log('Setting executable permissions...'); | ||||
|       chmodSync(binaryPath, 0o755); | ||||
|       console.log('✓ Binary permissions updated'); | ||||
|     } catch (err) { | ||||
|       console.error(`⚠️  Warning: Could not set executable permissions: ${err.message}`); | ||||
|       console.error('   You may need to manually run:'); | ||||
|       console.error(`   chmod +x ${binaryPath}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   console.log(''); | ||||
|   console.log('✅ NUPST installation completed successfully!'); | ||||
|   console.log(''); | ||||
|   console.log('You can now use NUPST by running:'); | ||||
|   console.log('  nupst --help'); | ||||
|   console.log(''); | ||||
|   console.log('For initial setup, run:'); | ||||
|   console.log('  sudo nupst ups add'); | ||||
|   console.log(''); | ||||
|   console.log('==========================================='); | ||||
| } | ||||
|  | ||||
| // Run the installation | ||||
| main().catch(err => { | ||||
|   console.error(`❌ Installation failed: ${err.message}`); | ||||
|   process.exit(1); | ||||
| }); | ||||
							
								
								
									
										
											BIN
										
									
								
								serve.zone-nupst-5.0.5.tgz
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								serve.zone-nupst-5.0.5.tgz
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										227
									
								
								setup.sh
									
									
									
									
									
								
							
							
						
						
									
										227
									
								
								setup.sh
									
									
									
									
									
								
							| @@ -1,227 +0,0 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # NUPST Setup Script | ||||
| # Downloads the appropriate Node.js binary for the current platform | ||||
|  | ||||
| # Find the directory where this script is located | ||||
| SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" | ||||
|  | ||||
| # Create vendor directory if it doesn't exist | ||||
| mkdir -p "$SCRIPT_DIR/vendor" | ||||
|  | ||||
| # Get the latest LTS Node.js version | ||||
| echo "Determining latest LTS Node.js version..." | ||||
| NODE_VERSIONS_JSON=$(curl -s https://nodejs.org/dist/index.json) | ||||
| if [ $? -ne 0 ]; then | ||||
|   echo "Warning: Could not fetch latest Node.js versions. Using fallback version." | ||||
|   NODE_VERSION="20.11.1"  # Fallback to a recent LTS version | ||||
| else | ||||
|   # Extract the latest LTS version (those marked with lts field) | ||||
|   NODE_VERSION=$(echo "$NODE_VERSIONS_JSON" | grep -o '"version":"v[0-9.]*".*"lts":[^,]*' | grep -v '"lts":false' | grep -o 'v[0-9.]*' | head -1 | cut -c 2-) | ||||
|    | ||||
|   if [ -z "$NODE_VERSION" ]; then | ||||
|     echo "Warning: Could not determine latest LTS version. Using fallback version." | ||||
|     NODE_VERSION="20.11.1"  # Fallback to a recent LTS version | ||||
|   else | ||||
|     echo "Latest Node.js LTS version: $NODE_VERSION" | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| # Detect architecture | ||||
| ARCH=$(uname -m) | ||||
| OS=$(uname -s) | ||||
|  | ||||
| # Map architecture and OS to Node.js download URL | ||||
| NODE_URL="" | ||||
| NODE_DIR="" | ||||
| case "$OS" in | ||||
|   Linux) | ||||
|     case "$ARCH" in | ||||
|       x86_64) | ||||
|         NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz" | ||||
|         NODE_DIR="node-linux-x64" | ||||
|         ;; | ||||
|       aarch64|arm64) | ||||
|         NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-arm64.tar.gz" | ||||
|         NODE_DIR="node-linux-arm64" | ||||
|         ;; | ||||
|       *) | ||||
|         echo "Unsupported architecture: $ARCH. Please install Node.js manually." | ||||
|         exit 1 | ||||
|         ;; | ||||
|     esac | ||||
|     ;; | ||||
|   Darwin) | ||||
|     case "$ARCH" in | ||||
|       x86_64) | ||||
|         NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-x64.tar.gz" | ||||
|         NODE_DIR="node-darwin-x64" | ||||
|         ;; | ||||
|       arm64) | ||||
|         NODE_URL="https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-arm64.tar.gz" | ||||
|         NODE_DIR="node-darwin-arm64" | ||||
|         ;; | ||||
|       *) | ||||
|         echo "Unsupported architecture: $ARCH. Please install Node.js manually." | ||||
|         exit 1 | ||||
|         ;; | ||||
|     esac | ||||
|     ;; | ||||
|   *) | ||||
|     echo "Unsupported operating system: $OS. Please install Node.js manually." | ||||
|     exit 1 | ||||
|     ;; | ||||
| esac | ||||
|  | ||||
| # Check if we already have the Node.js binary | ||||
| if [ -f "$SCRIPT_DIR/vendor/$NODE_DIR/bin/node" ]; then | ||||
|   echo "Node.js binary already exists for $OS-$ARCH. Skipping download." | ||||
| else | ||||
|   echo "Downloading Node.js v$NODE_VERSION for $OS-$ARCH..." | ||||
|    | ||||
|   # Download and extract Node.js | ||||
|   TMP_FILE="$SCRIPT_DIR/vendor/node.tar.gz" | ||||
|   curl -L "$NODE_URL" -o "$TMP_FILE" | ||||
|    | ||||
|   if [ $? -ne 0 ]; then | ||||
|     echo "Error downloading Node.js. Please check your internet connection and try again." | ||||
|     exit 1 | ||||
|   fi | ||||
|    | ||||
|   # Create target directory | ||||
|   mkdir -p "$SCRIPT_DIR/vendor/$NODE_DIR" | ||||
|    | ||||
|   # Extract Node.js | ||||
|   tar -xzf "$TMP_FILE" -C "$SCRIPT_DIR/vendor" | ||||
|    | ||||
|   # Move extracted files to the target directory | ||||
|   NODE_EXTRACT_DIR=$(find "$SCRIPT_DIR/vendor" -maxdepth 1 -name "node-v*" -type d | head -n 1) | ||||
|   if [ -d "$NODE_EXTRACT_DIR" ]; then | ||||
|     cp -R "$NODE_EXTRACT_DIR"/* "$SCRIPT_DIR/vendor/$NODE_DIR/" | ||||
|     rm -rf "$NODE_EXTRACT_DIR" | ||||
|   else | ||||
|     echo "Error extracting Node.js. Please try again." | ||||
|     exit 1 | ||||
|   fi | ||||
|    | ||||
|   # Clean up | ||||
|   rm "$TMP_FILE" | ||||
|    | ||||
|   echo "Node.js v$NODE_VERSION for $OS-$ARCH has been downloaded and extracted." | ||||
| fi | ||||
|  | ||||
| # Remove any existing dist_ts directory | ||||
| if [ -d "$SCRIPT_DIR/dist_ts" ]; then | ||||
|   echo "Removing existing dist_ts directory..." | ||||
|   rm -rf "$SCRIPT_DIR/dist_ts" | ||||
| fi | ||||
|  | ||||
| # Download dist_ts from npm registry | ||||
| echo "Downloading dist_ts from npm registry..." | ||||
|  | ||||
| # Create temp directory | ||||
| TEMP_DIR=$(mktemp -d) | ||||
|  | ||||
| # Get version from package.json | ||||
| if [ -f "$SCRIPT_DIR/package.json" ]; then | ||||
|   echo "Reading version from package.json..." | ||||
|   # Extract version using grep and cut | ||||
|   VERSION=$(grep -o '"version": "[^"]*"' "$SCRIPT_DIR/package.json" | cut -d'"' -f4) | ||||
|    | ||||
|   if [ -z "$VERSION" ]; then | ||||
|     echo "Error: Could not determine version from package.json." | ||||
|     rm -rf "$TEMP_DIR" | ||||
|     exit 1 | ||||
|   fi | ||||
|    | ||||
|   echo "Package version is $VERSION. Downloading matching package tarball..." | ||||
| else | ||||
|   echo "Warning: package.json not found. Getting latest version from npm registry..." | ||||
|   VERSION=$(curl -s https://registry.npmjs.org/@serve.zone/nupst | grep -o '"latest":"[^"]*"' | cut -d'"' -f4) | ||||
|    | ||||
|   if [ -z "$VERSION" ]; then | ||||
|     echo "Error: Could not determine version from npm registry." | ||||
|     rm -rf "$TEMP_DIR" | ||||
|     exit 1 | ||||
|   fi | ||||
|    | ||||
|   echo "Latest version is $VERSION. Using as fallback." | ||||
| fi | ||||
|  | ||||
| # First try to download with the version from package.json | ||||
| TARBALL_URL="https://registry.npmjs.org/@serve.zone/nupst/-/nupst-$VERSION.tgz" | ||||
| TARBALL_PATH="$TEMP_DIR/nupst.tgz" | ||||
|  | ||||
| echo "Attempting to download version $VERSION from $TARBALL_URL..." | ||||
| curl -sL "$TARBALL_URL" -o "$TARBALL_PATH" | ||||
|  | ||||
| # If download fails or file is empty, try to get the latest version from npm | ||||
| if [ $? -ne 0 ] || [ ! -s "$TARBALL_PATH" ]; then | ||||
|   echo "Package version $VERSION not found on npm registry." | ||||
|   echo "Fetching latest version information from npm registry..." | ||||
|    | ||||
|   # Get latest version from npm registry | ||||
|   NPM_REGISTRY_INFO=$(curl -s https://registry.npmjs.org/@serve.zone/nupst) | ||||
|    | ||||
|   if [ $? -ne 0 ]; then | ||||
|     echo "Error: Could not connect to npm registry." | ||||
|     echo "Will attempt to build from source instead." | ||||
|     rm -rf "$TEMP_DIR" | ||||
|     mkdir -p "$SCRIPT_DIR/dist_ts" | ||||
|     BUILD_FROM_SOURCE=1 | ||||
|     return 0 | ||||
|   fi | ||||
|    | ||||
|   # Extract latest version | ||||
|   LATEST_VERSION=$(echo "$NPM_REGISTRY_INFO" | grep -o '"latest":"[^"]*"' | cut -d'"' -f4) | ||||
|    | ||||
|   if [ -z "$LATEST_VERSION" ]; then | ||||
|     echo "Error: Could not determine latest version from npm registry." | ||||
|     echo "Will attempt to build from source instead." | ||||
|     rm -rf "$TEMP_DIR" | ||||
|     mkdir -p "$SCRIPT_DIR/dist_ts" | ||||
|     BUILD_FROM_SOURCE=1 | ||||
|     return 0 | ||||
|   fi | ||||
|    | ||||
|   echo "Found latest version: $LATEST_VERSION. Downloading..." | ||||
|    | ||||
|   TARBALL_URL="https://registry.npmjs.org/@serve.zone/nupst/-/nupst-$LATEST_VERSION.tgz" | ||||
|   TARBALL_PATH="$TEMP_DIR/nupst.tgz" | ||||
|    | ||||
|   curl -sL "$TARBALL_URL" -o "$TARBALL_PATH" | ||||
|    | ||||
|   if [ $? -ne 0 ] || [ ! -s "$TARBALL_PATH" ]; then | ||||
|     echo "Error: Failed to download any package version from npm registry." | ||||
|     echo "Installation cannot continue without the dist_ts directory." | ||||
|     rm -rf "$TEMP_DIR" | ||||
|     exit 1 | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| # Extract the tarball | ||||
| mkdir -p "$TEMP_DIR/extract" | ||||
| tar -xzf "$TARBALL_PATH" -C "$TEMP_DIR/extract" | ||||
|  | ||||
| # Copy dist_ts to the installation directory | ||||
| if [ -d "$TEMP_DIR/extract/package/dist_ts" ]; then | ||||
|   echo "Copying dist_ts directory to installation..." | ||||
|   mkdir -p "$SCRIPT_DIR/dist_ts" | ||||
|   cp -R "$TEMP_DIR/extract/package/dist_ts/"* "$SCRIPT_DIR/dist_ts/" | ||||
| else | ||||
|   echo "Error: dist_ts directory not found in the downloaded npm package." | ||||
|   rm -rf "$TEMP_DIR" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Clean up | ||||
| rm -rf "$TEMP_DIR" | ||||
|  | ||||
| echo "dist_ts directory successfully downloaded from npm registry." | ||||
|  | ||||
| # Make launcher script executable | ||||
| chmod +x "$SCRIPT_DIR/bin/nupst" | ||||
|  | ||||
| echo "NUPST setup completed successfully." | ||||
| echo "You can now run NUPST using: $SCRIPT_DIR/bin/nupst" | ||||
| echo "To install NUPST globally, run: sudo ln -s $SCRIPT_DIR/bin/nupst /usr/local/bin/nupst" | ||||
							
								
								
									
										168
									
								
								test/manualdocker/00-test-fresh-v4-install.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										168
									
								
								test/manualdocker/00-test-fresh-v4-install.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,168 @@ | ||||
| #!/bin/bash | ||||
| # | ||||
| # Test fresh v4 installation from scratch | ||||
| # Tests the most common user scenario: clean install using curl | bash | ||||
| # | ||||
|  | ||||
| set -e | ||||
|  | ||||
| CONTAINER_NAME="nupst-test-fresh-v4" | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  NUPST Fresh v4 Installation Test" | ||||
| echo "================================================" | ||||
| echo "" | ||||
|  | ||||
| # Check if container already exists | ||||
| if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then | ||||
|   echo "⚠️  Container ${CONTAINER_NAME} already exists" | ||||
|   read -p "Remove and recreate? (y/N): " -n 1 -r | ||||
|   echo | ||||
|   if [[ $REPLY =~ ^[Yy]$ ]]; then | ||||
|     echo "→ Stopping and removing existing container..." | ||||
|     docker stop ${CONTAINER_NAME} 2>/dev/null || true | ||||
|     docker rm ${CONTAINER_NAME} 2>/dev/null || true | ||||
|   else | ||||
|     echo "Exiting. Remove manually with: docker rm -f ${CONTAINER_NAME}" | ||||
|     exit 1 | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| echo "→ Creating Docker container with systemd..." | ||||
| docker run -d \ | ||||
|   --name ${CONTAINER_NAME} \ | ||||
|   --privileged \ | ||||
|   --cgroupns=host \ | ||||
|   -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ | ||||
|   ubuntu:22.04 \ | ||||
|   /bin/bash -c "apt-get update && apt-get install -y systemd systemd-sysv && exec /sbin/init" | ||||
|  | ||||
| echo "→ Waiting for systemd to initialize..." | ||||
| sleep 10 | ||||
|  | ||||
| echo "→ Waiting for dpkg lock to be released..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do | ||||
|     echo '  Waiting for dpkg lock...' | ||||
|     sleep 2 | ||||
|   done | ||||
|   echo '  dpkg lock released' | ||||
| " | ||||
|  | ||||
| echo "→ Installing prerequisites (curl)..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   apt-get update -qq | ||||
|   apt-get install -y -qq curl | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "→ Installing NUPST v4 using curl | bash..." | ||||
| echo "   Command: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y" | ||||
| echo "" | ||||
|  | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "================================================" | ||||
| echo "  Verifying Installation" | ||||
| echo "================================================" | ||||
| echo "" | ||||
|  | ||||
| echo "→ Checking binary location..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   if [ -f /opt/nupst/nupst ]; then | ||||
|     echo '  ✓ Binary exists at /opt/nupst/nupst' | ||||
|     ls -lh /opt/nupst/nupst | ||||
|   else | ||||
|     echo '  ✗ Binary not found at /opt/nupst/nupst' | ||||
|     exit 1 | ||||
|   fi | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "→ Checking symlink..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   if [ -L /usr/local/bin/nupst ]; then | ||||
|     echo '  ✓ Symlink exists at /usr/local/bin/nupst' | ||||
|     ls -lh /usr/local/bin/nupst | ||||
|   elif [ -L /usr/bin/nupst ]; then | ||||
|     echo '  ✓ Symlink exists at /usr/bin/nupst' | ||||
|     ls -lh /usr/bin/nupst | ||||
|   else | ||||
|     echo '  ✗ Symlink not found in /usr/local/bin or /usr/bin' | ||||
|     exit 1 | ||||
|   fi | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "→ Checking PATH integration..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   NUPST_PATH=\$(which nupst 2>/dev/null) | ||||
|   if [ -n \"\$NUPST_PATH\" ]; then | ||||
|     echo '  ✓ nupst found in PATH at: '\$NUPST_PATH | ||||
|   else | ||||
|     echo '  ✗ nupst not found in PATH' | ||||
|     echo '  PATH contents:' | ||||
|     echo \$PATH | ||||
|     exit 1 | ||||
|   fi | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "→ Testing nupst command execution..." | ||||
| docker exec ${CONTAINER_NAME} nupst --version | ||||
|  | ||||
| echo "" | ||||
| echo "→ Creating minimal config for service test..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   mkdir -p /etc/nupst | ||||
|   cat > /etc/nupst/config.json << 'EOF' | ||||
| { | ||||
|   \"version\": \"4.0\", | ||||
|   \"upsDevices\": [], | ||||
|   \"groups\": [], | ||||
|   \"checkInterval\": 30000 | ||||
| } | ||||
| EOF | ||||
|   echo '  ✓ Minimal config created' | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "→ Testing service creation..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   echo '  Running: nupst service enable' | ||||
|   nupst service enable | ||||
|  | ||||
|   if [ -f /etc/systemd/system/nupst.service ]; then | ||||
|     echo '  ✓ Service file created successfully' | ||||
|   else | ||||
|     echo '  ✗ Service file creation failed' | ||||
|     exit 1 | ||||
|   fi | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "→ Checking if service is enabled..." | ||||
| docker exec ${CONTAINER_NAME} systemctl is-enabled nupst | ||||
|  | ||||
| echo "" | ||||
| echo "================================================" | ||||
| echo "  ✓ Fresh v4 Installation Test Complete" | ||||
| echo "================================================" | ||||
| echo "" | ||||
| echo "Installation verified successfully:" | ||||
| echo "  • Binary installed to /opt/nupst/nupst" | ||||
| echo "  • Symlink created for global access" | ||||
| echo "  • nupst command available in PATH" | ||||
| echo "  • Command executes correctly" | ||||
| echo "  • Systemd service file created" | ||||
| echo "" | ||||
| echo "Useful commands:" | ||||
| echo "  docker exec -it ${CONTAINER_NAME} bash" | ||||
| echo "  docker exec ${CONTAINER_NAME} nupst --help" | ||||
| echo "  docker exec ${CONTAINER_NAME} nupst service status" | ||||
| echo "  docker stop ${CONTAINER_NAME}" | ||||
| echo "  docker rm -f ${CONTAINER_NAME}" | ||||
| echo "" | ||||
							
								
								
									
										148
									
								
								test/manualdocker/01-setup-v3-container.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										148
									
								
								test/manualdocker/01-setup-v3-container.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,148 @@ | ||||
| #!/bin/bash | ||||
| # | ||||
| # Setup Docker container with systemd and install NUPST v3 | ||||
| # This creates a container from commit 806f81c6a057a2a5da586b96a231d391f12eb1bb (v3) | ||||
| # | ||||
|  | ||||
| set -e | ||||
|  | ||||
| CONTAINER_NAME="nupst-test-v3" | ||||
| V3_COMMIT="806f81c6a057a2a5da586b96a231d391f12eb1bb" | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  NUPST v3 Test Container Setup" | ||||
| echo "================================================" | ||||
| echo "" | ||||
|  | ||||
| # Check if container already exists | ||||
| if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then | ||||
|   echo "⚠️  Container ${CONTAINER_NAME} already exists" | ||||
|   read -p "Remove and recreate? (y/N): " -n 1 -r | ||||
|   echo | ||||
|   if [[ $REPLY =~ ^[Yy]$ ]]; then | ||||
|     echo "→ Stopping and removing existing container..." | ||||
|     docker stop ${CONTAINER_NAME} 2>/dev/null || true | ||||
|     docker rm ${CONTAINER_NAME} 2>/dev/null || true | ||||
|   else | ||||
|     echo "Exiting. Remove manually with: docker rm -f ${CONTAINER_NAME}" | ||||
|     exit 1 | ||||
|   fi | ||||
| fi | ||||
|  | ||||
| echo "→ Creating Docker container (will install systemd)..." | ||||
| docker run -d \ | ||||
|   --name ${CONTAINER_NAME} \ | ||||
|   --privileged \ | ||||
|   --cgroupns=host \ | ||||
|   -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ | ||||
|   ubuntu:22.04 \ | ||||
|   /bin/bash -c "apt-get update && apt-get install -y systemd systemd-sysv && exec /sbin/init" | ||||
|  | ||||
| echo "→ Waiting for systemd to initialize..." | ||||
| sleep 10 | ||||
|  | ||||
| echo "→ Waiting for dpkg lock to be released..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do | ||||
|     echo '  Waiting for dpkg lock...' | ||||
|     sleep 2 | ||||
|   done | ||||
|   echo '  dpkg lock released' | ||||
| " | ||||
|  | ||||
| echo "→ Installing prerequisites in container..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   apt-get update -qq | ||||
|   apt-get install -y -qq git curl sudo jq | ||||
| " | ||||
|  | ||||
| echo "→ Cloning NUPST v3 (commit ${V3_COMMIT})..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   cd /opt | ||||
|   git clone https://code.foss.global/serve.zone/nupst.git | ||||
|   cd nupst | ||||
|   git checkout ${V3_COMMIT} | ||||
|   echo 'Checked out commit:' | ||||
|   git log -1 --oneline | ||||
| " | ||||
|  | ||||
| echo "→ Running NUPST v3 installation directly (bypassing install.sh auto-update)..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   cd /opt/nupst | ||||
|   # Run setup.sh directly to avoid install.sh trying to update to v4 | ||||
|   bash setup.sh -y | ||||
| " | ||||
|  | ||||
| echo "→ Creating NUPST configuration using real UPS data from .nogit/env.json..." | ||||
|  | ||||
| # Check if .nogit/env.json exists | ||||
| if [ ! -f "../../.nogit/env.json" ]; then | ||||
|   echo "❌ Error: .nogit/env.json not found" | ||||
|   echo "This file contains test UPS credentials and is required for testing" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Read UPS data from .nogit/env.json and create v3 config | ||||
| docker exec ${CONTAINER_NAME} bash -c "mkdir -p /etc/nupst" | ||||
|  | ||||
| # Generate config from .nogit/env.json using jq | ||||
| cat ../../.nogit/env.json | jq -r ' | ||||
| { | ||||
|   "upsList": [ | ||||
|     { | ||||
|       "id": "test-ups-v1", | ||||
|       "name": "Test UPS (SNMP v1)", | ||||
|       "host": .testConfigV1.snmp.host, | ||||
|       "port": .testConfigV1.snmp.port, | ||||
|       "community": .testConfigV1.snmp.community, | ||||
|       "version": (.testConfigV1.snmp.version | tostring), | ||||
|       "batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0", | ||||
|       "onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0", | ||||
|       "shutdownCommand": "echo \"Shutdown triggered for test-ups-v1\"" | ||||
|     }, | ||||
|     { | ||||
|       "id": "test-ups-v3", | ||||
|       "name": "Test UPS (SNMP v3)", | ||||
|       "host": .testConfigV3.snmp.host, | ||||
|       "port": .testConfigV3.snmp.port, | ||||
|       "version": (.testConfigV3.snmp.version | tostring), | ||||
|       "securityLevel": .testConfigV3.snmp.securityLevel, | ||||
|       "username": .testConfigV3.snmp.username, | ||||
|       "authProtocol": .testConfigV3.snmp.authProtocol, | ||||
|       "authKey": .testConfigV3.snmp.authKey, | ||||
|       "batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0", | ||||
|       "onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0", | ||||
|       "shutdownCommand": "echo \"Shutdown triggered for test-ups-v3\"" | ||||
|     } | ||||
|   ], | ||||
|   "groups": [] | ||||
| }' | docker exec -i ${CONTAINER_NAME} tee /etc/nupst/config.json > /dev/null | ||||
|  | ||||
| echo "  ✓ Real UPS config created at /etc/nupst/config.json (from .nogit/env.json)" | ||||
|  | ||||
| echo "→ Enabling NUPST systemd service..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   nupst enable | ||||
| " | ||||
|  | ||||
| echo "→ Starting NUPST service..." | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   nupst start | ||||
| " | ||||
|  | ||||
| echo "" | ||||
| echo "================================================" | ||||
| echo "  ✓ NUPST v3 Container Ready" | ||||
| echo "================================================" | ||||
| echo "" | ||||
| echo "Container name: ${CONTAINER_NAME}" | ||||
| echo "NUPST version: v3 (commit ${V3_COMMIT})" | ||||
| echo "" | ||||
| echo "Useful commands:" | ||||
| echo "  docker exec -it ${CONTAINER_NAME} bash" | ||||
| echo "  docker exec ${CONTAINER_NAME} systemctl status nupst" | ||||
| echo "  docker exec ${CONTAINER_NAME} nupst --version" | ||||
| echo "  docker stop ${CONTAINER_NAME}" | ||||
| echo "  docker start ${CONTAINER_NAME}" | ||||
| echo "  docker rm -f ${CONTAINER_NAME}" | ||||
| echo "" | ||||
							
								
								
									
										59
									
								
								test/manualdocker/02-test-v3-to-v4-migration.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										59
									
								
								test/manualdocker/02-test-v3-to-v4-migration.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| #!/bin/bash | ||||
| # | ||||
| # Test migration from v3 to v4 | ||||
| # Run this after 01-setup-v3-container.sh | ||||
| # | ||||
|  | ||||
| set -e | ||||
|  | ||||
| CONTAINER_NAME="nupst-test-v3" | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  NUPST v3 → v4 Migration Test" | ||||
| echo "================================================" | ||||
| echo "" | ||||
|  | ||||
| # Check if container exists | ||||
| if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then | ||||
|   echo "❌ Container ${CONTAINER_NAME} is not running" | ||||
|   echo "Run ./01-setup-v3-container.sh first" | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| echo "→ Checking current NUPST status..." | ||||
| docker exec ${CONTAINER_NAME} systemctl status nupst --no-pager || true | ||||
| echo "" | ||||
|  | ||||
| echo "→ Checking current version..." | ||||
| docker exec ${CONTAINER_NAME} nupst --version | ||||
| echo "" | ||||
|  | ||||
| echo "→ Stopping v3 service..." | ||||
| docker exec ${CONTAINER_NAME} systemctl stop nupst | ||||
| echo "" | ||||
|  | ||||
| echo "→ Running v4 installation from main branch (should auto-detect v3 and migrate)..." | ||||
| echo "   Using: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash" | ||||
| docker exec ${CONTAINER_NAME} bash -c " | ||||
|   curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y | ||||
| " | ||||
|  | ||||
| echo "→ Checking service status after migration..." | ||||
| docker exec ${CONTAINER_NAME} systemctl status nupst --no-pager || true | ||||
| echo "" | ||||
|  | ||||
| echo "→ Checking new version..." | ||||
| docker exec ${CONTAINER_NAME} nupst --version | ||||
| echo "" | ||||
|  | ||||
| echo "→ Testing service commands..." | ||||
| docker exec ${CONTAINER_NAME} nupst service status || true | ||||
| echo "" | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  ✓ Migration Test Complete" | ||||
| echo "================================================" | ||||
| echo "" | ||||
| echo "Check logs with:" | ||||
| echo "  docker exec ${CONTAINER_NAME} nupst service logs" | ||||
| echo "" | ||||
							
								
								
									
										28
									
								
								test/manualdocker/03-cleanup.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										28
									
								
								test/manualdocker/03-cleanup.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| #!/bin/bash | ||||
| # | ||||
| # Cleanup test container | ||||
| # | ||||
|  | ||||
| set -e | ||||
|  | ||||
| CONTAINER_NAME="nupst-test-v3" | ||||
|  | ||||
| echo "================================================" | ||||
| echo "  Cleanup NUPST Test Container" | ||||
| echo "================================================" | ||||
| echo "" | ||||
|  | ||||
| if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then | ||||
|   echo "→ Stopping container..." | ||||
|   docker stop ${CONTAINER_NAME} 2>/dev/null || true | ||||
|  | ||||
|   echo "→ Removing container..." | ||||
|   docker rm ${CONTAINER_NAME} 2>/dev/null || true | ||||
|  | ||||
|   echo "" | ||||
|   echo "✓ Container ${CONTAINER_NAME} removed" | ||||
| else | ||||
|   echo "Container ${CONTAINER_NAME} not found" | ||||
| fi | ||||
|  | ||||
| echo "" | ||||
							
								
								
									
										149
									
								
								test/manualdocker/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								test/manualdocker/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| # Manual Docker Testing Scripts | ||||
|  | ||||
| This directory contains scripts for manually testing NUPST installation and migration in Docker containers with systemd support. | ||||
|  | ||||
| ## Prerequisites | ||||
|  | ||||
| - Docker installed and running | ||||
| - Privileged access (for systemd in container) | ||||
| - Linux host (systemd container requirements) | ||||
|  | ||||
| ## Test Scripts | ||||
|  | ||||
| ### 1. `01-setup-v3-container.sh` | ||||
|  | ||||
| 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 | ||||
| ``` | ||||
|  | ||||
| **Container name:** `nupst-test-v3` | ||||
|  | ||||
| ### 2. `02-test-v3-to-v4-migration.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) | ||||
| - Verifies service is running with v4 | ||||
| - Tests basic commands | ||||
|  | ||||
| **Usage:** | ||||
| ```bash | ||||
| chmod +x 02-test-v3-to-v4-migration.sh | ||||
| ./02-test-v3-to-v4-migration.sh | ||||
| ``` | ||||
|  | ||||
| **Prerequisites:** Must run `01-setup-v3-container.sh` first | ||||
|  | ||||
| ### 3. `03-cleanup.sh` | ||||
|  | ||||
| Removes the test container. | ||||
|  | ||||
| **Usage:** | ||||
| ```bash | ||||
| chmod +x 03-cleanup.sh | ||||
| ./03-cleanup.sh | ||||
| ``` | ||||
|  | ||||
| ## Manual Testing Workflow | ||||
|  | ||||
| ### Full Migration Test | ||||
|  | ||||
| 1. **Set up v3 environment:** | ||||
|    ```bash | ||||
|    ./01-setup-v3-container.sh | ||||
|    ``` | ||||
|  | ||||
| 2. **Verify v3 is working:** | ||||
|    ```bash | ||||
|    docker exec nupst-test-v3 nupst --version | ||||
|    docker exec nupst-test-v3 systemctl status nupst | ||||
|    ``` | ||||
|  | ||||
| 3. **Test migration to v4:** | ||||
|    ```bash | ||||
|    ./02-test-v3-to-v4-migration.sh | ||||
|    ``` | ||||
|  | ||||
| 4. **Manual verification:** | ||||
|    ```bash | ||||
|    # Enter container | ||||
|    docker exec -it nupst-test-v3 bash | ||||
|  | ||||
|    # Inside container: | ||||
|    nupst --version                    # Should show v4.0.0 | ||||
|    nupst service status               # Should show running service | ||||
|    cat /etc/nupst/config.json         # Config should be preserved | ||||
|    systemctl status nupst             # Service should be active | ||||
|    ``` | ||||
|  | ||||
| 5. **Cleanup:** | ||||
|    ```bash | ||||
|    ./03-cleanup.sh | ||||
|    ``` | ||||
|  | ||||
| ## Useful Docker Commands | ||||
|  | ||||
| ```bash | ||||
| # Enter container shell | ||||
| docker exec -it nupst-test-v3 bash | ||||
|  | ||||
| # Check service status | ||||
| docker exec nupst-test-v3 systemctl status nupst | ||||
|  | ||||
| # View service logs | ||||
| docker exec nupst-test-v3 journalctl -u nupst -n 50 | ||||
|  | ||||
| # Check NUPST version | ||||
| docker exec nupst-test-v3 nupst --version | ||||
|  | ||||
| # Run NUPST commands | ||||
| docker exec nupst-test-v3 nupst service status | ||||
| docker exec nupst-test-v3 nupst ups list | ||||
|  | ||||
| # Stop container | ||||
| docker stop nupst-test-v3 | ||||
|  | ||||
| # Start container | ||||
| docker start nupst-test-v3 | ||||
|  | ||||
| # Remove container | ||||
| docker rm -f nupst-test-v3 | ||||
| ``` | ||||
|  | ||||
| ## Notes | ||||
|  | ||||
| - The container runs with `--privileged` flag for systemd support | ||||
| - Container uses Ubuntu 22.04 as base image | ||||
| - v3 installation is from commit `806f81c6a057a2a5da586b96a231d391f12eb1bb` | ||||
| - v4 migration pulls from `migration/deno-v4` branch | ||||
| - All scripts are designed to be idempotent where possible | ||||
|  | ||||
| ## 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` | ||||
							
								
								
									
										157
									
								
								test/test.logger.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								test/test.logger.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | ||||
| import { assert, assertEquals } from 'jsr:@std/assert@^1.0.0'; | ||||
| import { Logger } from '../ts/logger.ts'; | ||||
|  | ||||
| // Create a Logger instance for testing | ||||
| const logger = new Logger(); | ||||
|  | ||||
| Deno.test('should create a logger instance', () => { | ||||
|   assert(logger instanceof Logger); | ||||
| }); | ||||
|  | ||||
| Deno.test('should log messages with different log levels', () => { | ||||
|   // We're not testing console output directly, just ensuring no errors | ||||
|   logger.log('Regular log message'); | ||||
|   logger.error('Error message'); | ||||
|   logger.warn('Warning message'); | ||||
|   logger.success('Success message'); | ||||
|  | ||||
|   // Just assert that the test runs without errors | ||||
|   assert(true); | ||||
| }); | ||||
|  | ||||
| Deno.test('should create a logbox with title, content, and end', () => { | ||||
|   // Just ensuring no errors occur | ||||
|   logger.logBoxTitle('Test Box', 40); | ||||
|   logger.logBoxLine('This is a test line'); | ||||
|   logger.logBoxEnd(); | ||||
|  | ||||
|   // Just assert that the test runs without errors | ||||
|   assert(true); | ||||
| }); | ||||
|  | ||||
| Deno.test('should handle width persistence between logbox calls', () => { | ||||
|   logger.logBoxTitle('Width Test', 45); | ||||
|  | ||||
|   // These should use the width from the title | ||||
|   logger.logBoxLine('Line 1'); | ||||
|   logger.logBoxLine('Line 2'); | ||||
|   logger.logBoxEnd(); | ||||
|  | ||||
|   let errorThrown = false; | ||||
|  | ||||
|   try { | ||||
|     // This should work fine after the reset in logBoxEnd | ||||
|     logger.logBoxTitle('New Box', 30); | ||||
|     logger.logBoxLine('New line'); | ||||
|     logger.logBoxEnd(); | ||||
|   } catch (_error) { | ||||
|     errorThrown = true; | ||||
|   } | ||||
|  | ||||
|   assertEquals(errorThrown, false); | ||||
| }); | ||||
|  | ||||
| Deno.test('should use default width when no width is specified', () => { | ||||
|   // This should automatically use the default width instead of throwing | ||||
|   let errorThrown = false; | ||||
|  | ||||
|   try { | ||||
|     logger.logBoxLine('This should use default width'); | ||||
|     logger.logBoxEnd(); | ||||
|   } catch (_error) { | ||||
|     errorThrown = true; | ||||
|   } | ||||
|  | ||||
|   // Verify no error was thrown | ||||
|   assertEquals(errorThrown, false); | ||||
| }); | ||||
|  | ||||
| Deno.test('should create a complete logbox in one call', () => { | ||||
|   // Just ensuring no errors occur | ||||
|   logger.logBox('Complete Box', [ | ||||
|     'Line 1', | ||||
|     'Line 2', | ||||
|     'Line 3', | ||||
|   ], 40); | ||||
|  | ||||
|   // Just assert that the test runs without errors | ||||
|   assert(true); | ||||
| }); | ||||
|  | ||||
| Deno.test('should handle content that exceeds box width', () => { | ||||
|   // Just ensuring no errors occur when content is too long | ||||
|   logger.logBox('Truncation Test', [ | ||||
|     'This line is way too long and should be truncated because it exceeds the available space', | ||||
|   ], 30); | ||||
|  | ||||
|   // Just assert that the test runs without errors | ||||
|   assert(true); | ||||
| }); | ||||
|  | ||||
| Deno.test('should create dividers with custom characters', () => { | ||||
|   // Just ensuring no errors occur | ||||
|   logger.logDivider(30); | ||||
|   logger.logDivider(20, '*'); | ||||
|  | ||||
|   // Just assert that the test runs without errors | ||||
|   assert(true); | ||||
| }); | ||||
|  | ||||
| Deno.test('should create divider with default width', () => { | ||||
|   // This should use the default width | ||||
|   logger.logDivider(undefined, '-'); | ||||
|  | ||||
|   // Just assert that the test runs without errors | ||||
|   assert(true); | ||||
| }); | ||||
|  | ||||
| Deno.test('Logger Demo', () => { | ||||
|   console.log('\n=== LOGGER DEMO ===\n'); | ||||
|  | ||||
|   // Basic logging | ||||
|   logger.log('Regular log message'); | ||||
|   logger.error('Error message'); | ||||
|   logger.warn('Warning message'); | ||||
|   logger.success('Success message'); | ||||
|  | ||||
|   // Logbox with title, content lines, and end | ||||
|   logger.logBoxTitle('Configuration Loaded', 50); | ||||
|   logger.logBoxLine('SNMP Settings:'); | ||||
|   logger.logBoxLine('  Host: 127.0.0.1'); | ||||
|   logger.logBoxLine('  Port: 161'); | ||||
|   logger.logBoxLine('  Version: 1'); | ||||
|   logger.logBoxEnd(); | ||||
|  | ||||
|   // Complete logbox in one call | ||||
|   logger.logBox('UPS Status', [ | ||||
|     'Power Status: onBattery', | ||||
|     'Battery Capacity: 75%', | ||||
|     'Runtime Remaining: 30 minutes', | ||||
|   ], 45); | ||||
|  | ||||
|   // Logbox with content that's too long for the width | ||||
|   logger.logBox('Truncation Example', [ | ||||
|     'This line is short enough to fit within the box width', | ||||
|     'This line is way too long and will be truncated because it exceeds the available space for content within the logbox', | ||||
|   ], 40); | ||||
|  | ||||
|   // Demonstrating logbox width being remembered | ||||
|   logger.logBoxTitle('Width Persistence Example', 60); | ||||
|   logger.logBoxLine('These lines use the width from the title'); | ||||
|   logger.logBoxLine('No need to specify the width again'); | ||||
|   logger.logBoxEnd(); | ||||
|  | ||||
|   // Demonstrating default width | ||||
|   console.log('\nDefault Width Example:'); | ||||
|   logger.logBoxLine('This line uses the default width'); | ||||
|   logger.logBoxLine('Still using default width'); | ||||
|   logger.logBoxEnd(); | ||||
|  | ||||
|   // Divider example | ||||
|   logger.log('\nDivider example:'); | ||||
|   logger.logDivider(30); | ||||
|   logger.logDivider(30, '*'); | ||||
|   logger.logDivider(undefined, '='); | ||||
|  | ||||
|   assert(true); | ||||
| }); | ||||
							
								
								
									
										233
									
								
								test/test.showcase.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								test/test.showcase.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,233 @@ | ||||
| /** | ||||
|  * Showcase test for NUPST CLI outputs | ||||
|  * Demonstrates all the beautiful colored output features | ||||
|  * | ||||
|  * 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'; | ||||
|  | ||||
| console.log(''); | ||||
| console.log('═'.repeat(80)); | ||||
| logger.highlight('NUPST CLI OUTPUT SHOWCASE'); | ||||
| logger.dim('Demonstrating beautiful, colored terminal output'); | ||||
| console.log('═'.repeat(80)); | ||||
| console.log(''); | ||||
|  | ||||
| // === 1. Basic Logging Methods === | ||||
| logger.logBoxTitle('Basic Logging Methods', 60, 'info'); | ||||
| logger.logBoxLine(''); | ||||
| logger.log('Normal log message (default color)'); | ||||
| logger.success('Success message with ✓ symbol'); | ||||
| logger.error('Error message with ✗ symbol'); | ||||
| logger.warn('Warning message with ⚠ symbol'); | ||||
| logger.info('Info message with ℹ symbol'); | ||||
| logger.dim('Dim/secondary text for less important info'); | ||||
| logger.highlight('Highlighted/bold text for emphasis'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 2. Colored Boxes === | ||||
| logger.logBoxTitle('Colored Box Styles', 60); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine('Boxes can be styled with different colors:'); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| 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'); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| 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'); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 3. Status Symbols === | ||||
| logger.logBoxTitle('Status Symbols', 60, 'info'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`${symbols.running}  Service Running`); | ||||
| logger.logBoxLine(`${symbols.stopped}  Service Stopped`); | ||||
| logger.logBoxLine(`${symbols.starting}  Service Starting`); | ||||
| logger.logBoxLine(`${symbols.unknown}  Status Unknown`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`${symbols.success}  Operation Successful`); | ||||
| logger.logBoxLine(`${symbols.error}  Operation Failed`); | ||||
| logger.logBoxLine(`${symbols.warning}  Warning Condition`); | ||||
| logger.logBoxLine(`${symbols.info}  Information`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 4. Battery Level Colors === | ||||
| logger.logBoxTitle('Battery Level Color Coding', 60, 'info'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine('Battery levels are color-coded:'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`  ${getBatteryColor(85)('85%')} - Good (green, ≥60%)`); | ||||
| logger.logBoxLine(`  ${getBatteryColor(45)('45%')} - Medium (yellow, 30-60%)`); | ||||
| logger.logBoxLine(`  ${getBatteryColor(15)('15%')} - Critical (red, <30%)`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 5. Power Status Formatting === | ||||
| logger.logBoxTitle('Power Status Formatting', 60, 'info'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`Status: ${formatPowerStatus('online')}`); | ||||
| logger.logBoxLine(`Status: ${formatPowerStatus('onBattery')}`); | ||||
| logger.logBoxLine(`Status: ${formatPowerStatus('unknown')}`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 6. Table Formatting === | ||||
| 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: 'Runtime', key: 'runtime', align: 'right' }, | ||||
| ]; | ||||
|  | ||||
| const upsData = [ | ||||
|   { | ||||
|     id: 'ups-1', | ||||
|     name: 'Main UPS', | ||||
|     host: '192.168.1.10', | ||||
|     status: 'Online', | ||||
|     battery: '95%', | ||||
|     runtime: '45 min', | ||||
|   }, | ||||
|   { | ||||
|     id: 'ups-2', | ||||
|     name: 'Backup UPS', | ||||
|     host: '192.168.1.11', | ||||
|     status: 'On Battery', | ||||
|     battery: '42%', | ||||
|     runtime: '12 min', | ||||
|   }, | ||||
|   { | ||||
|     id: 'ups-3', | ||||
|     name: 'Critical UPS', | ||||
|     host: '192.168.1.12', | ||||
|     status: 'On Battery', | ||||
|     battery: '18%', | ||||
|     runtime: '5 min', | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| logger.logTable(upsColumns, upsData, 'UPS Devices'); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 7. Group Table === | ||||
| const groupColumns: ITableColumn[] = [ | ||||
|   { header: 'ID', key: 'id' }, | ||||
|   { header: 'Name', key: 'name' }, | ||||
|   { header: 'Mode', key: 'mode' }, | ||||
|   { header: 'UPS Count', key: 'count', align: 'right' }, | ||||
| ]; | ||||
|  | ||||
| const groupData = [ | ||||
|   { id: 'dc-1', name: 'Data Center 1', mode: 'redundant', count: '3' }, | ||||
|   { id: 'office', name: 'Office Servers', mode: 'nonRedundant', count: '2' }, | ||||
| ]; | ||||
|  | ||||
| logger.logTable(groupColumns, groupData, 'UPS Groups'); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 8. Service Status Example === | ||||
| logger.logBoxTitle('Service Status', 70, 'success'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`Status:   ${symbols.running} ${theme.statusActive('Active (Running)')}`); | ||||
| logger.logBoxLine(`Enabled:  ${symbols.success} ${theme.success('Yes')}`); | ||||
| logger.logBoxLine(`Uptime:   2 days, 5 hours, 23 minutes`); | ||||
| logger.logBoxLine(`PID:      ${theme.dim('12345')}`); | ||||
| logger.logBoxLine(`Memory:   ${theme.dim('45.2 MB')}`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 9. Configuration Example === | ||||
| logger.logBoxTitle('Configuration', 70); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`UPS Devices:      ${theme.highlight('3')}`); | ||||
| logger.logBoxLine(`Groups:           ${theme.highlight('2')}`); | ||||
| logger.logBoxLine(`Check Interval:   ${theme.dim('30 seconds')}`); | ||||
| logger.logBoxLine(`Config File:      ${theme.path('/etc/nupst/config.json')}`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 10. Update Available Example === | ||||
| logger.logBoxTitle('Update Available', 70, 'warning'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`Current Version:  ${theme.dim('4.0.1')}`); | ||||
| logger.logBoxLine(`Latest Version:   ${theme.highlight('4.0.2')}`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === 11. Error Example === | ||||
| logger.logBoxTitle('Error Example', 70, 'error'); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`${symbols.error} Failed to connect to UPS at 192.168.1.10`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine('Possible causes:'); | ||||
| logger.logBoxLine(`  ${theme.dim('• UPS is offline or unreachable')}`); | ||||
| logger.logBoxLine(`  ${theme.dim('• Incorrect SNMP community string')}`); | ||||
| logger.logBoxLine(`  ${theme.dim('• Firewall blocking port 161')}`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxLine(`Try: ${theme.command('nupst ups test --debug')}`); | ||||
| logger.logBoxLine(''); | ||||
| logger.logBoxEnd(); | ||||
|  | ||||
| console.log(''); | ||||
|  | ||||
| // === Final Summary === | ||||
| console.log('═'.repeat(80)); | ||||
| logger.success('CLI Output Showcase Complete!'); | ||||
| logger.dim('All color and formatting features demonstrated'); | ||||
| console.log('═'.repeat(80)); | ||||
| console.log(''); | ||||
							
								
								
									
										361
									
								
								test/test.ts
									
									
									
									
									
								
							
							
						
						
									
										361
									
								
								test/test.ts
									
									
									
									
									
								
							| @@ -1,330 +1,53 @@ | ||||
| import { tap, expect } from '@push.rocks/tapbundle'; | ||||
| import { NupstSnmp } from '../ts/snmp.js'; | ||||
| import type { SnmpConfig, UpsStatus } from '../ts/snmp.js'; | ||||
| import { SnmpEncoder } from '../ts/snmp/encoder.js'; | ||||
| import { SnmpPacketCreator } from '../ts/snmp/packet-creator.js'; | ||||
| import { SnmpPacketParser } from '../ts/snmp/packet-parser.js'; | ||||
| import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0'; | ||||
| import { NupstSnmp } from '../ts/snmp/manager.ts'; | ||||
| import type { ISnmpConfig } from '../ts/snmp/types.ts'; | ||||
|  | ||||
| import * as qenv from '@push.rocks/qenv'; | ||||
| import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; | ||||
| const testQenv = new qenv.Qenv('./', '.nogit/'); | ||||
|  | ||||
| // Create an SNMP instance with debug enabled | ||||
| const snmp = new NupstSnmp(true); | ||||
|  | ||||
| // Load the test configuration from .nogit/env.json  | ||||
| const testConfig = await testQenv.getEnvVarOnDemandAsObject('testConfig'); | ||||
| // Load the test configuration from .nogit/env.json | ||||
| const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1'); | ||||
| const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3'); | ||||
|  | ||||
| tap.test('should log config', async () => { | ||||
|   console.log(testConfig); | ||||
| }); | ||||
|  | ||||
| tap.test('SNMP packet creation and parsing test', async () => { | ||||
|   // We'll test the internal methods that are now in separate classes | ||||
|    | ||||
|   // Test OID conversion | ||||
|   const oidStr = '1.3.6.1.4.1.3808.1.1.1.4.1.1.0'; | ||||
|   const oidArray = SnmpEncoder.oidToArray(oidStr); | ||||
|   console.log('OID array length:', oidArray.length); | ||||
|   console.log('OID array:', oidArray); | ||||
|   // The OID has 14 elements after splitting | ||||
|   expect(oidArray.length).toEqual(14); | ||||
|   expect(oidArray[0]).toEqual(1); | ||||
|   expect(oidArray[1]).toEqual(3); | ||||
|    | ||||
|   // Test OID encoding | ||||
|   const encodedOid = SnmpEncoder.encodeOID(oidArray); | ||||
|   expect(encodedOid).toBeInstanceOf(Buffer); | ||||
|    | ||||
|   // Test SNMP request creation | ||||
|   const request = SnmpPacketCreator.createSnmpGetRequest(oidStr, 'public', true); | ||||
|   expect(request).toBeInstanceOf(Buffer); | ||||
|   expect(request.length).toBeGreaterThan(20); | ||||
|    | ||||
|   // Log the request for debugging | ||||
|   console.log('SNMP Request buffer:', request.toString('hex')); | ||||
|    | ||||
|   // Test integer encoding | ||||
|   const int = SnmpEncoder.encodeInteger(42); | ||||
|   expect(int).toBeInstanceOf(Buffer); | ||||
|   expect(int.length).toBeGreaterThanOrEqual(1); | ||||
|    | ||||
|   // Test SNMPv3 engine ID discovery message | ||||
|   const discoveryMsg = SnmpPacketCreator.createDiscoveryMessage(testConfig, 1); | ||||
|   expect(discoveryMsg).toBeInstanceOf(Buffer); | ||||
|   expect(discoveryMsg.length).toBeGreaterThan(20); | ||||
|    | ||||
|   console.log('SNMPv3 Discovery message:', discoveryMsg.toString('hex')); | ||||
| }); | ||||
|  | ||||
| tap.test('SNMP response parsing simulation', async () => { | ||||
|   // Create a simulated SNMP response for parsing | ||||
|    | ||||
|   // Simulate an INTEGER response (battery capacity) | ||||
|   const intResponse = Buffer.from([ | ||||
|     0x30, 0x29, // Sequence, length 41 | ||||
|     0x02, 0x01, 0x00, // Integer (version), value 0 | ||||
|     0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public" | ||||
|     0xa2, 0x1c, // GetResponse | ||||
|     0x02, 0x01, 0x01, // Integer (request ID), value 1 | ||||
|     0x02, 0x01, 0x00, // Integer (error status), value 0 | ||||
|     0x02, 0x01, 0x00, // Integer (error index), value 0 | ||||
|     0x30, 0x11, // Sequence (varbinds) | ||||
|     0x30, 0x0f, // Sequence (varbind) | ||||
|     0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x01, 0x00, // OID (example) | ||||
|     0x02, 0x01, 0x64 // Integer (value), value 100 (100%) | ||||
|   ]); | ||||
|    | ||||
|   // Simulate a Gauge32 response (battery capacity) | ||||
|   const gauge32Response = Buffer.from([ | ||||
|     0x30, 0x29, // Sequence, length 41 | ||||
|     0x02, 0x01, 0x00, // Integer (version), value 0 | ||||
|     0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public" | ||||
|     0xa2, 0x1c, // GetResponse | ||||
|     0x02, 0x01, 0x01, // Integer (request ID), value 1 | ||||
|     0x02, 0x01, 0x00, // Integer (error status), value 0 | ||||
|     0x02, 0x01, 0x00, // Integer (error index), value 0 | ||||
|     0x30, 0x11, // Sequence (varbinds) | ||||
|     0x30, 0x0f, // Sequence (varbind) | ||||
|     0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x01, 0x00, // OID (example) | ||||
|     0x42, 0x01, 0x64 // Gauge32 (value), value 100 (100%) | ||||
|   ]); | ||||
|    | ||||
|   // Simulate a TimeTicks response (battery runtime) | ||||
|   const timeTicksResponse = Buffer.from([ | ||||
|     0x30, 0x29, // Sequence, length 41 | ||||
|     0x02, 0x01, 0x00, // Integer (version), value 0 | ||||
|     0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public" | ||||
|     0xa2, 0x1c, // GetResponse | ||||
|     0x02, 0x01, 0x01, // Integer (request ID), value 1 | ||||
|     0x02, 0x01, 0x00, // Integer (error status), value 0 | ||||
|     0x02, 0x01, 0x00, // Integer (error index), value 0 | ||||
|     0x30, 0x11, // Sequence (varbinds) | ||||
|     0x30, 0x0f, // Sequence (varbind) | ||||
|     0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x01, 0x00, // OID (example) | ||||
|     0x43, 0x01, 0x0f // TimeTicks (value), value 15 (0.15 seconds or 15/100 seconds) | ||||
|   ]); | ||||
|    | ||||
|   // Test parsing INTEGER response | ||||
|   const intValue = SnmpPacketParser.parseSnmpResponse(intResponse, testConfig, true); | ||||
|   console.log('Parsed INTEGER value:', intValue); | ||||
|   expect(intValue).toEqual(100); | ||||
|    | ||||
|   // Test parsing Gauge32 response | ||||
|   const gauge32Value = SnmpPacketParser.parseSnmpResponse(gauge32Response, testConfig, true); | ||||
|   console.log('Parsed Gauge32 value:', gauge32Value); | ||||
|   expect(gauge32Value).toEqual(100); | ||||
|    | ||||
|   // Test parsing TimeTicks response | ||||
|   const timeTicksValue = SnmpPacketParser.parseSnmpResponse(timeTicksResponse, testConfig, true); | ||||
|   console.log('Parsed TimeTicks value:', timeTicksValue); | ||||
|   expect(timeTicksValue).toEqual(15); | ||||
| }); | ||||
|  | ||||
| tap.test('CyberPower TimeTicks conversion', async () => { | ||||
|   // Test the conversion of TimeTicks to minutes for CyberPower UPS | ||||
|    | ||||
|   // Set up a config for CyberPower | ||||
|   const cyberPowerConfig: SnmpConfig = { | ||||
|     ...testConfig, | ||||
|     upsModel: 'cyberpower' | ||||
|   }; | ||||
|    | ||||
|   // Create a simulated TimeTicks response with a value of 104 (104/100 seconds) | ||||
|   const ticksResponse = Buffer.from([ | ||||
|     0x30, 0x29, // Sequence | ||||
|     0x02, 0x01, 0x00, // Integer (version), value 0 | ||||
|     0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public" | ||||
|     0xa2, 0x1c, // GetResponse | ||||
|     0x02, 0x01, 0x01, // Integer (request ID), value 1 | ||||
|     0x02, 0x01, 0x00, // Integer (error status), value 0 | ||||
|     0x02, 0x01, 0x00, // Integer (error index), value 0 | ||||
|     0x30, 0x11, // Sequence (varbinds) | ||||
|     0x30, 0x0f, // Sequence (varbind) | ||||
|     0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x8c, 0x10, 0x01, 0x02, 0x04, 0x00, // OID (battery runtime) | ||||
|     0x43, 0x01, 0x68 // TimeTicks (value), value 104 (104/100 seconds) | ||||
|   ]); | ||||
|    | ||||
|   // Mock the getUpsStatus function to test our TimeTicks conversion logic | ||||
|   const mockGetUpsStatus = async () => { | ||||
|     // Parse the TimeTicks value from the response | ||||
|     const runtime = SnmpPacketParser.parseSnmpResponse(ticksResponse, testConfig, true); | ||||
|     console.log('Raw runtime value:', runtime); | ||||
|      | ||||
|     // Create a sample UPS status result | ||||
|     const result = { | ||||
|       powerStatus: 'onBattery', | ||||
|       batteryCapacity: 100, | ||||
|       batteryRuntime: 0, | ||||
|       raw: { | ||||
|         powerStatus: 2, | ||||
|         batteryCapacity: 100, | ||||
|         batteryRuntime: runtime, | ||||
|       }, | ||||
|     }; | ||||
|      | ||||
|     // Convert TimeTicks to minutes for CyberPower | ||||
|     if (cyberPowerConfig.upsModel === 'cyberpower' && runtime > 0) { | ||||
|       result.batteryRuntime = Math.floor(runtime / 6000); | ||||
|       console.log(`Converting CyberPower runtime from ${runtime} ticks to ${result.batteryRuntime} minutes`); | ||||
|     } else { | ||||
|       result.batteryRuntime = runtime; | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   }; | ||||
|    | ||||
|   // Call our mock function | ||||
|   const status = await mockGetUpsStatus(); | ||||
|    | ||||
|   // Assert the conversion worked correctly | ||||
|   console.log('Final status object:', status); | ||||
|   expect(status.batteryRuntime).toEqual(0); // 104 ticks / 6000 = 0.0173... rounds to 0 minutes | ||||
| }); | ||||
|  | ||||
| tap.test('Simulate fully charged online UPS', async () => { | ||||
|   // Test a realistic scenario of an online UPS with high battery capacity and ~30 mins runtime | ||||
|    | ||||
|   // Create simulated responses for power status (online), battery capacity (95%), runtime (30 min) | ||||
|    | ||||
|   // Power Status = 2 (online for CyberPower) | ||||
|   const powerStatusResponse = Buffer.from([ | ||||
|     0x30, 0x29, // Sequence | ||||
|     0x02, 0x01, 0x00, // Integer (version), value 0 | ||||
|     0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public" | ||||
|     0xa2, 0x1c, // GetResponse | ||||
|     0x02, 0x01, 0x01, // Integer (request ID), value 1 | ||||
|     0x02, 0x01, 0x00, // Integer (error status), value 0 | ||||
|     0x02, 0x01, 0x00, // Integer (error index), value 0 | ||||
|     0x30, 0x11, // Sequence (varbinds) | ||||
|     0x30, 0x0f, // Sequence (varbind) | ||||
|     0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0xed, 0x08, 0x01, 0x01, 0x01, 0x00, // OID (power status) | ||||
|     0x02, 0x01, 0x02 // Integer (value), value 2 (online) | ||||
|   ]); | ||||
|    | ||||
|   // Battery Capacity = 95% (as Gauge32) | ||||
|   const batteryCapacityResponse = Buffer.from([ | ||||
|     0x30, 0x29, // Sequence | ||||
|     0x02, 0x01, 0x00, // Integer (version), value 0 | ||||
|     0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public" | ||||
|     0xa2, 0x1c, // GetResponse | ||||
|     0x02, 0x01, 0x02, // Integer (request ID), value 2 | ||||
|     0x02, 0x01, 0x00, // Integer (error status), value 0 | ||||
|     0x02, 0x01, 0x00, // Integer (error index), value 0 | ||||
|     0x30, 0x11, // Sequence (varbinds) | ||||
|     0x30, 0x0f, // Sequence (varbind) | ||||
|     0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0xed, 0x08, 0x01, 0x02, 0x01, 0x00, // OID (battery capacity) | ||||
|     0x42, 0x01, 0x5F // Gauge32 (value), value 95 (95%) | ||||
|   ]); | ||||
|    | ||||
|   // Battery Runtime = 30 minutes (as TimeTicks) | ||||
|   // 30 minutes = 1800 seconds = 180000 ticks (in 1/100 seconds) | ||||
|   const batteryRuntimeResponse = Buffer.from([ | ||||
|     0x30, 0x2c, // Sequence | ||||
|     0x02, 0x01, 0x00, // Integer (version), value 0 | ||||
|     0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, // Octet String (community), value "public" | ||||
|     0xa2, 0x1f, // GetResponse | ||||
|     0x02, 0x01, 0x03, // Integer (request ID), value 3 | ||||
|     0x02, 0x01, 0x00, // Integer (error status), value 0 | ||||
|     0x02, 0x01, 0x00, // Integer (error index), value 0 | ||||
|     0x30, 0x14, // Sequence (varbinds) | ||||
|     0x30, 0x12, // Sequence (varbind) | ||||
|     0x06, 0x0b, 0x2b, 0x06, 0x01, 0x04, 0x01, 0xed, 0x08, 0x01, 0x02, 0x04, 0x00, // OID (battery runtime) | ||||
|     0x43, 0x04, 0x00, 0x02, 0xBF, 0x20 // TimeTicks (value), value 180000 (1800 seconds = 30 minutes) | ||||
|   ]); | ||||
|    | ||||
|   // Mock the getUpsStatus function to test with our simulated data | ||||
|   const mockGetUpsStatus = async () => { | ||||
|     console.log('Simulating UPS status request with synthetic data'); | ||||
|      | ||||
|     // Create a config that specifies this is a CyberPower UPS | ||||
|     const upsConfig: SnmpConfig = { | ||||
|       host: '192.168.1.1', | ||||
|       port: 161, | ||||
|       version: 1, | ||||
|       community: 'public', | ||||
|       timeout: 5000, | ||||
|       upsModel: 'cyberpower', | ||||
|     }; | ||||
|      | ||||
|     // Parse each simulated response | ||||
|     const powerStatus = SnmpPacketParser.parseSnmpResponse(powerStatusResponse, upsConfig, true); | ||||
|     console.log('Power status value:', powerStatus); | ||||
|      | ||||
|     const batteryCapacity = SnmpPacketParser.parseSnmpResponse(batteryCapacityResponse, upsConfig, true); | ||||
|     console.log('Battery capacity value:', batteryCapacity); | ||||
|      | ||||
|     const batteryRuntime = SnmpPacketParser.parseSnmpResponse(batteryRuntimeResponse, upsConfig, true); | ||||
|     console.log('Battery runtime value:', batteryRuntime); | ||||
|      | ||||
|     // Convert TimeTicks to minutes for CyberPower UPSes | ||||
|     const runtimeMinutes = Math.floor(batteryRuntime / 6000); | ||||
|     console.log(`Converting ${batteryRuntime} ticks to ${runtimeMinutes} minutes`); | ||||
|      | ||||
|     // Interpret power status for CyberPower | ||||
|     // CyberPower: 2=online, 3=on battery | ||||
|     let powerStatusText: 'online' | 'onBattery' | 'unknown' = 'unknown'; | ||||
|     if (powerStatus === 2) { | ||||
|       powerStatusText = 'online'; | ||||
|     } else if (powerStatus === 3) { | ||||
|       powerStatusText = 'onBattery'; | ||||
|     } | ||||
|      | ||||
|     // Create the status result | ||||
|     const result: UpsStatus = { | ||||
|       powerStatus: powerStatusText, | ||||
|       batteryCapacity: batteryCapacity, | ||||
|       batteryRuntime: runtimeMinutes, | ||||
|       raw: { | ||||
|         powerStatus, | ||||
|         batteryCapacity, | ||||
|         batteryRuntime, | ||||
|       }, | ||||
|     }; | ||||
|      | ||||
|     return result; | ||||
|   }; | ||||
|    | ||||
|   // Call our mock function | ||||
|   const status = await mockGetUpsStatus(); | ||||
|    | ||||
|   // Assert that the values match our expectations | ||||
|   console.log('UPS Status Result:', status); | ||||
|   expect(status.powerStatus).toEqual('online'); | ||||
|   expect(status.batteryCapacity).toEqual(95); | ||||
|   expect(status.batteryRuntime).toEqual(30); | ||||
| Deno.test('should log config', () => { | ||||
|   console.log(testConfigV1); | ||||
|   assert(true); | ||||
| }); | ||||
|  | ||||
| // Test with real UPS using the configuration from .nogit/env.json | ||||
| tap.test('Real UPS test', async () => { | ||||
| Deno.test('Real UPS test v1', async () => { | ||||
|   try { | ||||
|     console.log('Testing with real UPS configuration...'); | ||||
|      | ||||
|  | ||||
|     // Extract the correct SNMP config from the test configuration | ||||
|     const snmpConfig = testConfig.snmp; | ||||
|     const snmpConfig = testConfigV1.snmp as ISnmpConfig; | ||||
|     console.log('SNMP Config:'); | ||||
|     console.log(`  Host: ${snmpConfig.host}:${snmpConfig.port}`); | ||||
|     console.log(`  Version: SNMPv${snmpConfig.version}`); | ||||
|     console.log(`  UPS Model: ${snmpConfig.upsModel}`); | ||||
|      | ||||
|  | ||||
|     // Use a short timeout for testing | ||||
|     const testSnmpConfig = {  | ||||
|     const testSnmpConfig = { | ||||
|       ...snmpConfig, | ||||
|       timeout: Math.min(snmpConfig.timeout, 10000) // Use at most 10 seconds for testing | ||||
|       timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     // Try to get the UPS status | ||||
|     const status = await snmp.getUpsStatus(testSnmpConfig); | ||||
|      | ||||
|  | ||||
|     console.log('UPS Status:'); | ||||
|     console.log(`  Power Status: ${status.powerStatus}`); | ||||
|     console.log(`  Battery Capacity: ${status.batteryCapacity}%`); | ||||
|     console.log(`  Runtime Remaining: ${status.batteryRuntime} minutes`); | ||||
|      | ||||
|  | ||||
|     // Just make sure we got valid data types back | ||||
|     expect(status).toBeTruthy(); | ||||
|     expect(['online', 'onBattery', 'unknown']).toContain(status.powerStatus); | ||||
|     expect(typeof status.batteryCapacity).toEqual('number'); | ||||
|     expect(typeof status.batteryRuntime).toEqual('number'); | ||||
|     assertExists(status); | ||||
|     assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus)); | ||||
|     assertEquals(typeof status.batteryCapacity, 'number'); | ||||
|     assertEquals(typeof status.batteryRuntime, 'number'); | ||||
|   } catch (error) { | ||||
|     console.log('Real UPS test failed:', error); | ||||
|     // Skip the test if we can't connect to the real UPS | ||||
| @@ -332,5 +55,39 @@ tap.test('Real UPS test', async () => { | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // Export the default tap object | ||||
| export default tap.start(); | ||||
| Deno.test('Real UPS test v3', async () => { | ||||
|   try { | ||||
|     console.log('Testing with real UPS configuration...'); | ||||
|  | ||||
|     // Extract the correct SNMP config from the test configuration | ||||
|     const snmpConfig = testConfigV3.snmp as ISnmpConfig; | ||||
|     console.log('SNMP Config:'); | ||||
|     console.log(`  Host: ${snmpConfig.host}:${snmpConfig.port}`); | ||||
|     console.log(`  Version: SNMPv${snmpConfig.version}`); | ||||
|     console.log(`  UPS Model: ${snmpConfig.upsModel}`); | ||||
|  | ||||
|     // Use a short timeout for testing | ||||
|     const testSnmpConfig = { | ||||
|       ...snmpConfig, | ||||
|       timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing | ||||
|     }; | ||||
|  | ||||
|     // Try to get the UPS status | ||||
|     const status = await snmp.getUpsStatus(testSnmpConfig); | ||||
|  | ||||
|     console.log('UPS Status:'); | ||||
|     console.log(`  Power Status: ${status.powerStatus}`); | ||||
|     console.log(`  Battery Capacity: ${status.batteryCapacity}%`); | ||||
|     console.log(`  Runtime Remaining: ${status.batteryRuntime} minutes`); | ||||
|  | ||||
|     // Just make sure we got valid data types back | ||||
|     assertExists(status); | ||||
|     assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus)); | ||||
|     assertEquals(typeof status.batteryCapacity, 'number'); | ||||
|     assertEquals(typeof status.batteryRuntime, 'number'); | ||||
|   } catch (error) { | ||||
|     console.log('Real UPS test failed:', error); | ||||
|     // Skip the test if we can't connect to the real UPS | ||||
|     console.log('Skipping this test since the UPS might not be available'); | ||||
|   } | ||||
| }); | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@serve.zone/nupst', | ||||
|   version: '2.4.3', | ||||
|   description: 'Node.js UPS Shutdown Tool for SNMP-enabled UPS devices' | ||||
|   version: '5.1.2', | ||||
|   description: 'Network UPS Shutdown Tool - Monitor SNMP-enabled UPS devices and orchestrate graceful system shutdowns during power emergencies' | ||||
| } | ||||
|   | ||||
							
								
								
									
										170
									
								
								ts/actions/base-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								ts/actions/base-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| /** | ||||
|  * Base classes and interfaces for the NUPST action system | ||||
|  * | ||||
|  * Actions are triggered on: | ||||
|  * 1. Power status changes (online ↔ onBattery) | ||||
|  * 2. Threshold violations (battery/runtime cross below configured thresholds) | ||||
|  */ | ||||
|  | ||||
| export type TPowerStatus = 'online' | 'onBattery' | 'unknown'; | ||||
|  | ||||
| /** | ||||
|  * Context provided to actions when they execute | ||||
|  * Contains all relevant UPS state and trigger information | ||||
|  */ | ||||
| export interface IActionContext { | ||||
|   // UPS identification | ||||
|   /** Unique ID of the UPS */ | ||||
|   upsId: string; | ||||
|   /** Human-readable name of the UPS */ | ||||
|   upsName: string; | ||||
|  | ||||
|   // Current state | ||||
|   /** Current power status */ | ||||
|   powerStatus: TPowerStatus; | ||||
|   /** Current battery capacity percentage (0-100) */ | ||||
|   batteryCapacity: number; | ||||
|   /** Estimated battery runtime in minutes */ | ||||
|   batteryRuntime: number; | ||||
|  | ||||
|   // State tracking | ||||
|   /** Previous power status before this trigger */ | ||||
|   previousPowerStatus: TPowerStatus; | ||||
|  | ||||
|   // Metadata | ||||
|   /** Timestamp when this action was triggered (milliseconds since epoch) */ | ||||
|   timestamp: number; | ||||
|   /** Reason this action was triggered */ | ||||
|   triggerReason: 'powerStatusChange' | 'thresholdViolation'; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Action trigger mode - determines when an action executes | ||||
|  */ | ||||
| export type TActionTriggerMode = | ||||
|   | 'onlyPowerChanges' // Only on power status changes (online ↔ onBattery) | ||||
|   | 'onlyThresholds' // Only when action's thresholds are exceeded | ||||
|   | 'powerChangesAndThresholds' // On power changes OR threshold violations | ||||
|   | 'anyChange'; // On every UPS poll/check (every ~30s) | ||||
|  | ||||
| /** | ||||
|  * Configuration for an action | ||||
|  */ | ||||
| export interface IActionConfig { | ||||
|   /** Type of action to execute */ | ||||
|   type: 'shutdown' | 'webhook' | 'script'; | ||||
|  | ||||
|   // Trigger configuration | ||||
|   /** | ||||
|    * When should this action be triggered? | ||||
|    * - onlyPowerChanges: Only on power status changes | ||||
|    * - onlyThresholds: Only when thresholds exceeded | ||||
|    * - powerChangesAndThresholds: On both (default) | ||||
|    * - anyChange: On every check | ||||
|    */ | ||||
|   triggerMode?: TActionTriggerMode; | ||||
|  | ||||
|   // Threshold configuration (applies to all action types) | ||||
|   /** Threshold settings for this action */ | ||||
|   thresholds?: { | ||||
|     /** Battery percentage threshold (0-100) */ | ||||
|     battery: number; | ||||
|     /** Runtime threshold in minutes */ | ||||
|     runtime: number; | ||||
|   }; | ||||
|  | ||||
|   // Shutdown action configuration | ||||
|   /** Delay before shutdown in minutes (default: 5) */ | ||||
|   shutdownDelay?: number; | ||||
|   /** Only execute shutdown on threshold violation, not power status changes */ | ||||
|   onlyOnThresholdViolation?: boolean; | ||||
|  | ||||
|   // Webhook action configuration | ||||
|   /** URL to call for webhook */ | ||||
|   webhookUrl?: string; | ||||
|   /** HTTP method to use (default: POST) */ | ||||
|   webhookMethod?: 'GET' | 'POST'; | ||||
|   /** Timeout for webhook request in milliseconds (default: 10000) */ | ||||
|   webhookTimeout?: number; | ||||
|   /** Only execute webhook on threshold violation */ | ||||
|   webhookOnlyOnThresholdViolation?: boolean; | ||||
|  | ||||
|   // Script action configuration | ||||
|   /** Path to script relative to /etc/nupst (e.g., "myaction.sh") */ | ||||
|   scriptPath?: string; | ||||
|   /** Timeout for script execution in milliseconds (default: 60000) */ | ||||
|   scriptTimeout?: number; | ||||
|   /** Only execute script on threshold violation */ | ||||
|   scriptOnlyOnThresholdViolation?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Abstract base class for all actions | ||||
|  * Each action type must extend this class and implement execute() | ||||
|  */ | ||||
| export abstract class Action { | ||||
|   /** Type identifier for this action */ | ||||
|   abstract readonly type: string; | ||||
|  | ||||
|   /** | ||||
|    * Create a new action with the given configuration | ||||
|    * @param config Action configuration | ||||
|    */ | ||||
|   constructor(protected config: IActionConfig) {} | ||||
|  | ||||
|   /** | ||||
|    * Execute this action with the given context | ||||
|    * @param context Current UPS state and trigger information | ||||
|    */ | ||||
|   abstract execute(context: IActionContext): Promise<void>; | ||||
|  | ||||
|   /** | ||||
|    * Helper to check if this action should execute based on trigger mode | ||||
|    * @param context Action context with current UPS state | ||||
|    * @returns True if action should execute | ||||
|    */ | ||||
|   protected shouldExecute(context: IActionContext): boolean { | ||||
|     const mode = this.config.triggerMode || 'powerChangesAndThresholds'; // Default | ||||
|  | ||||
|     switch (mode) { | ||||
|       case 'onlyPowerChanges': | ||||
|         // Only execute on power status changes | ||||
|         return context.triggerReason === 'powerStatusChange'; | ||||
|  | ||||
|       case 'onlyThresholds': | ||||
|         // Only execute when this action's thresholds are exceeded | ||||
|         if (!this.config.thresholds) return false; // No thresholds = never execute | ||||
|         return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime); | ||||
|  | ||||
|       case 'powerChangesAndThresholds': | ||||
|         // Execute on power changes OR when thresholds exceeded | ||||
|         if (context.triggerReason === 'powerStatusChange') return true; | ||||
|         if (!this.config.thresholds) return false; | ||||
|         return this.areThresholdsExceeded(context.batteryCapacity, context.batteryRuntime); | ||||
|  | ||||
|       case 'anyChange': | ||||
|         // Execute on every trigger (power change or threshold check) | ||||
|         return true; | ||||
|  | ||||
|       default: | ||||
|         return true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if current battery/runtime exceeds this action's thresholds | ||||
|    * @param batteryCapacity Current battery percentage | ||||
|    * @param batteryRuntime Current runtime in minutes | ||||
|    * @returns True if thresholds are exceeded | ||||
|    */ | ||||
|   protected areThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean { | ||||
|     if (!this.config.thresholds) { | ||||
|       return false; // No thresholds configured | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       batteryCapacity < this.config.thresholds.battery || | ||||
|       batteryRuntime < this.config.thresholds.runtime | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										91
									
								
								ts/actions/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								ts/actions/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| /** | ||||
|  * Action system exports and ActionManager | ||||
|  * | ||||
|  * This module provides the central coordination for the action system. | ||||
|  * The ActionManager is responsible for creating and executing actions. | ||||
|  */ | ||||
|  | ||||
| import { logger } from '../logger.ts'; | ||||
| import type { Action, IActionConfig, IActionContext } from './base-action.ts'; | ||||
| import { ShutdownAction } from './shutdown-action.ts'; | ||||
| import { WebhookAction } from './webhook-action.ts'; | ||||
| import { ScriptAction } from './script-action.ts'; | ||||
|  | ||||
| // Re-export types for convenience | ||||
| export type { IActionConfig, IActionContext, TPowerStatus } from './base-action.ts'; | ||||
| export { Action } from './base-action.ts'; | ||||
| export { ShutdownAction } from './shutdown-action.ts'; | ||||
| export { WebhookAction } from './webhook-action.ts'; | ||||
| export { ScriptAction } from './script-action.ts'; | ||||
|  | ||||
| /** | ||||
|  * ActionManager - Coordinates action creation and execution | ||||
|  * | ||||
|  * Provides factory methods for creating actions from configuration | ||||
|  * and orchestrates action execution with error handling. | ||||
|  */ | ||||
| export class ActionManager { | ||||
|   /** | ||||
|    * Create an action instance from configuration | ||||
|    * @param config Action configuration | ||||
|    * @returns Instantiated action | ||||
|    * @throws Error if action type is unknown | ||||
|    */ | ||||
|   static createAction(config: IActionConfig): Action { | ||||
|     switch (config.type) { | ||||
|       case 'shutdown': | ||||
|         return new ShutdownAction(config); | ||||
|       case 'webhook': | ||||
|         return new WebhookAction(config); | ||||
|       case 'script': | ||||
|         return new ScriptAction(config); | ||||
|       default: | ||||
|         throw new Error(`Unknown action type: ${(config as IActionConfig).type}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute a sequence of actions with the given context | ||||
|    * Each action runs sequentially, and failures are logged but don't stop the chain | ||||
|    * @param actions Array of action configurations to execute | ||||
|    * @param context Action context with UPS state | ||||
|    */ | ||||
|   static async executeActions( | ||||
|     actions: IActionConfig[], | ||||
|     context: IActionContext, | ||||
|   ): Promise<void> { | ||||
|     if (!actions || actions.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     logger.log(''); | ||||
|     logger.logBoxTitle(`Executing ${actions.length} Action(s)`, 60, 'info'); | ||||
|     logger.logBoxLine(`Trigger: ${context.triggerReason}`); | ||||
|     logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`); | ||||
|     logger.logBoxLine(`Power: ${context.powerStatus}`); | ||||
|     logger.logBoxLine(`Battery: ${context.batteryCapacity}% / ${context.batteryRuntime} min`); | ||||
|     logger.logBoxEnd(); | ||||
|     logger.log(''); | ||||
|  | ||||
|     for (let i = 0; i < actions.length; i++) { | ||||
|       const actionConfig = actions[i]; | ||||
|       try { | ||||
|         logger.info(`[${i + 1}/${actions.length}] ${actionConfig.type} action...`); | ||||
|  | ||||
|         const action = this.createAction(actionConfig); | ||||
|         await action.execute(context); | ||||
|       } catch (error) { | ||||
|         logger.error( | ||||
|           `Action ${actionConfig.type} failed: ${ | ||||
|             error instanceof Error ? error.message : String(error) | ||||
|           }`, | ||||
|         ); | ||||
|         // Continue with next action despite failure | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     logger.log(''); | ||||
|     logger.success('Action execution completed'); | ||||
|     logger.log(''); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										167
									
								
								ts/actions/script-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								ts/actions/script-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | ||||
| import * as path from 'node:path'; | ||||
| 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 { logger } from '../logger.ts'; | ||||
|  | ||||
| const execAsync = promisify(exec); | ||||
|  | ||||
| /** | ||||
|  * ScriptAction - Executes a custom shell script from /etc/nupst/ | ||||
|  * | ||||
|  * Runs user-provided scripts with UPS state passed as environment variables and arguments. | ||||
|  * Scripts must be .sh files located in /etc/nupst/ for security. | ||||
|  */ | ||||
| export class ScriptAction extends Action { | ||||
|   readonly type = 'script'; | ||||
|  | ||||
|   private static readonly SCRIPT_DIR = '/etc/nupst'; | ||||
|  | ||||
|   /** | ||||
|    * Execute the script action | ||||
|    * @param context Action context with UPS state | ||||
|    */ | ||||
|   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'})`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!this.config.scriptPath) { | ||||
|       logger.error('Script path not configured'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Validate and build script path | ||||
|     const scriptPath = this.validateAndBuildScriptPath(this.config.scriptPath); | ||||
|     if (!scriptPath) { | ||||
|       logger.error(`Invalid script path: ${this.config.scriptPath}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Check if script exists and is executable | ||||
|     if (!fs.existsSync(scriptPath)) { | ||||
|       logger.error(`Script not found: ${scriptPath}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const timeout = this.config.scriptTimeout || 60000; // Default 60 seconds | ||||
|  | ||||
|     logger.info(`Executing script: ${scriptPath}`); | ||||
|  | ||||
|     try { | ||||
|       await this.executeScript(scriptPath, context, timeout); | ||||
|       logger.success('Script executed successfully'); | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Script execution failed: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|       // Don't throw - script failures shouldn't stop other actions | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validate script path and build full path | ||||
|    * Ensures security by preventing path traversal and limiting to /etc/nupst | ||||
|    * @param scriptPath Relative script path from config | ||||
|    * @returns Full validated path or null if invalid | ||||
|    */ | ||||
|   private validateAndBuildScriptPath(scriptPath: string): string | null { | ||||
|     // Remove any leading/trailing whitespace | ||||
|     scriptPath = scriptPath.trim(); | ||||
|  | ||||
|     // Reject paths with path traversal attempts | ||||
|     if (scriptPath.includes('..') || scriptPath.includes('/') || scriptPath.includes('\\')) { | ||||
|       logger.error('Script path must not contain directory separators or parent references'); | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     // Require .sh extension | ||||
|     if (!scriptPath.endsWith('.sh')) { | ||||
|       logger.error('Script must have .sh extension'); | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     // Build full path | ||||
|     return path.join(ScriptAction.SCRIPT_DIR, scriptPath); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute the script with UPS state as environment variables and arguments | ||||
|    * @param scriptPath Full path to the script | ||||
|    * @param context Action context | ||||
|    * @param timeout Execution timeout in milliseconds | ||||
|    */ | ||||
|   private async executeScript( | ||||
|     scriptPath: string, | ||||
|     context: IActionContext, | ||||
|     timeout: number, | ||||
|   ): Promise<void> { | ||||
|     // Prepare environment variables | ||||
|     const env = { | ||||
|       ...process.env, | ||||
|       NUPST_UPS_ID: context.upsId, | ||||
|       NUPST_UPS_NAME: context.upsName, | ||||
|       NUPST_POWER_STATUS: context.powerStatus, | ||||
|       NUPST_BATTERY_CAPACITY: String(context.batteryCapacity), | ||||
|       NUPST_BATTERY_RUNTIME: String(context.batteryRuntime), | ||||
|       NUPST_TRIGGER_REASON: context.triggerReason, | ||||
|       NUPST_TIMESTAMP: String(context.timestamp), | ||||
|       // Include action's own thresholds if configured | ||||
|       NUPST_BATTERY_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.battery) : '', | ||||
|       NUPST_RUNTIME_THRESHOLD: this.config.thresholds ? String(this.config.thresholds.runtime) : '', | ||||
|     }; | ||||
|  | ||||
|     // Build command with arguments | ||||
|     // Arguments: powerStatus batteryCapacity batteryRuntime | ||||
|     const args = [ | ||||
|       context.powerStatus, | ||||
|       String(context.batteryCapacity), | ||||
|       String(context.batteryRuntime), | ||||
|     ].join(' '); | ||||
|  | ||||
|     const command = `bash "${scriptPath}" ${args}`; | ||||
|  | ||||
|     try { | ||||
|       const { stdout, stderr } = await execAsync(command, { | ||||
|         env, | ||||
|         cwd: ScriptAction.SCRIPT_DIR, | ||||
|         timeout, | ||||
|       }); | ||||
|  | ||||
|       // Log output | ||||
|       if (stdout) { | ||||
|         logger.log('Script stdout:'); | ||||
|         logger.dim(stdout.trim()); | ||||
|       } | ||||
|  | ||||
|       if (stderr) { | ||||
|         logger.warn('Script stderr:'); | ||||
|         logger.dim(stderr.trim()); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       // Check if it was a timeout | ||||
|       if (error instanceof Error && 'killed' in error && error.killed) { | ||||
|         throw new Error(`Script timed out after ${timeout}ms`); | ||||
|       } | ||||
|  | ||||
|       // Include stdout/stderr in error if available | ||||
|       if (error && typeof error === 'object' && 'stdout' in error && 'stderr' in error) { | ||||
|         const execError = error as { stdout: string; stderr: string }; | ||||
|         if (execError.stdout) { | ||||
|           logger.log('Script stdout:'); | ||||
|           logger.dim(execError.stdout.trim()); | ||||
|         } | ||||
|         if (execError.stderr) { | ||||
|           logger.warn('Script stderr:'); | ||||
|           logger.dim(execError.stderr.trim()); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										142
									
								
								ts/actions/shutdown-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								ts/actions/shutdown-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| import * as fs from 'node:fs'; | ||||
| import { execFile } from 'node:child_process'; | ||||
| import { promisify } from 'node:util'; | ||||
| import { Action, type IActionConfig, type IActionContext } from './base-action.ts'; | ||||
| import { logger } from '../logger.ts'; | ||||
|  | ||||
| const execFileAsync = promisify(execFile); | ||||
|  | ||||
| /** | ||||
|  * ShutdownAction - Initiates system shutdown | ||||
|  * | ||||
|  * This action triggers a system shutdown using the standard shutdown command. | ||||
|  * It includes a configurable delay to allow VMs and services to gracefully terminate. | ||||
|  */ | ||||
| export class ShutdownAction extends Action { | ||||
|   readonly type = 'shutdown'; | ||||
|  | ||||
|   /** | ||||
|    * Execute the shutdown action | ||||
|    * @param context Action context with UPS state | ||||
|    */ | ||||
|   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'})`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const shutdownDelay = this.config.shutdownDelay || 5; // Default 5 minutes | ||||
|  | ||||
|     logger.log(''); | ||||
|     logger.logBoxTitle('Initiating System Shutdown', 60, 'error'); | ||||
|     logger.logBoxLine(`UPS: ${context.upsName} (${context.upsId})`); | ||||
|     logger.logBoxLine(`Power Status: ${context.powerStatus}`); | ||||
|     logger.logBoxLine(`Battery: ${context.batteryCapacity}%`); | ||||
|     logger.logBoxLine(`Runtime: ${context.batteryRuntime} minutes`); | ||||
|     logger.logBoxLine(`Trigger: ${context.triggerReason}`); | ||||
|     logger.logBoxLine(`Shutdown delay: ${shutdownDelay} minutes`); | ||||
|     logger.logBoxEnd(); | ||||
|     logger.log(''); | ||||
|  | ||||
|     try { | ||||
|       await this.executeShutdownCommand(shutdownDelay); | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Shutdown command failed: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|       // Try alternative methods | ||||
|       await this.tryAlternativeShutdownMethods(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute the primary shutdown command | ||||
|    * @param delayMinutes Minutes to delay before shutdown | ||||
|    */ | ||||
|   private async executeShutdownCommand(delayMinutes: number): Promise<void> { | ||||
|     // Find shutdown command in common system paths | ||||
|     const shutdownPaths = [ | ||||
|       '/sbin/shutdown', | ||||
|       '/usr/sbin/shutdown', | ||||
|       '/bin/shutdown', | ||||
|       '/usr/bin/shutdown', | ||||
|     ]; | ||||
|  | ||||
|     let shutdownCmd = ''; | ||||
|     for (const path of shutdownPaths) { | ||||
|       try { | ||||
|         if (fs.existsSync(path)) { | ||||
|           shutdownCmd = path; | ||||
|           logger.log(`Found shutdown command at: ${shutdownCmd}`); | ||||
|           break; | ||||
|         } | ||||
|       } catch (_e) { | ||||
|         // Continue checking other paths | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (shutdownCmd) { | ||||
|       // Execute shutdown command with delay to allow for VM graceful shutdown | ||||
|       const message = `UPS battery critical, shutting down in ${delayMinutes} minutes`; | ||||
|       logger.log(`Executing: ${shutdownCmd} -h +${delayMinutes} "${message}"`); | ||||
|  | ||||
|       const { stdout } = await execFileAsync(shutdownCmd, [ | ||||
|         '-h', | ||||
|         `+${delayMinutes}`, | ||||
|         message, | ||||
|       ]); | ||||
|  | ||||
|       logger.log(`Shutdown initiated: ${stdout}`); | ||||
|       logger.log(`Allowing ${delayMinutes} minutes for VMs to shut down safely`); | ||||
|     } else { | ||||
|       throw new Error('Shutdown command not found in common paths'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Try alternative shutdown methods if primary command fails | ||||
|    */ | ||||
|   private async tryAlternativeShutdownMethods(): Promise<void> { | ||||
|     logger.error('Trying alternative shutdown methods...'); | ||||
|  | ||||
|     const alternatives = [ | ||||
|       { cmd: 'poweroff', args: ['--force'] }, | ||||
|       { cmd: 'halt', args: ['-p'] }, | ||||
|       { cmd: 'systemctl', args: ['poweroff'] }, | ||||
|       { cmd: 'reboot', args: ['-p'] }, // Some systems allow reboot -p for power off | ||||
|     ]; | ||||
|  | ||||
|     for (const alt of alternatives) { | ||||
|       try { | ||||
|         // First check if command exists in common system paths | ||||
|         const paths = [ | ||||
|           `/sbin/${alt.cmd}`, | ||||
|           `/usr/sbin/${alt.cmd}`, | ||||
|           `/bin/${alt.cmd}`, | ||||
|           `/usr/bin/${alt.cmd}`, | ||||
|         ]; | ||||
|  | ||||
|         let cmdPath = ''; | ||||
|         for (const path of paths) { | ||||
|           if (fs.existsSync(path)) { | ||||
|             cmdPath = path; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         if (cmdPath) { | ||||
|           logger.log(`Trying alternative shutdown method: ${cmdPath} ${alt.args.join(' ')}`); | ||||
|           await execFileAsync(cmdPath, alt.args); | ||||
|           logger.log(`Alternative method ${alt.cmd} succeeded`); | ||||
|           return; // Exit if successful | ||||
|         } | ||||
|       } catch (_altError) { | ||||
|         logger.error(`Alternative method ${alt.cmd} failed`); | ||||
|         // Continue to next method | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     logger.error('All shutdown methods failed'); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										141
									
								
								ts/actions/webhook-action.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								ts/actions/webhook-action.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| 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 { logger } from '../logger.ts'; | ||||
|  | ||||
| /** | ||||
|  * WebhookAction - Calls an HTTP webhook with UPS state information | ||||
|  * | ||||
|  * Sends UPS status to a configured webhook URL via GET or POST. | ||||
|  * This is useful for remote notifications and integrations with external systems. | ||||
|  */ | ||||
| export class WebhookAction extends Action { | ||||
|   readonly type = 'webhook'; | ||||
|  | ||||
|   /** | ||||
|    * Execute the webhook action | ||||
|    * @param context Action context with UPS state | ||||
|    */ | ||||
|   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'})`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!this.config.webhookUrl) { | ||||
|       logger.error('Webhook URL not configured'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const method = this.config.webhookMethod || 'POST'; | ||||
|     const timeout = this.config.webhookTimeout || 10000; | ||||
|  | ||||
|     logger.info(`Calling webhook: ${method} ${this.config.webhookUrl}`); | ||||
|  | ||||
|     try { | ||||
|       await this.callWebhook(context, method, timeout); | ||||
|       logger.success('Webhook call successful'); | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Webhook call failed: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|       // Don't throw - webhook failures shouldn't stop other actions | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Call the webhook with UPS state data | ||||
|    * @param context Action context | ||||
|    * @param method HTTP method (GET or POST) | ||||
|    * @param timeout Request timeout in milliseconds | ||||
|    */ | ||||
|   private async callWebhook( | ||||
|     context: IActionContext, | ||||
|     method: 'GET' | 'POST', | ||||
|     timeout: number, | ||||
|   ): Promise<void> { | ||||
|     const payload: any = { | ||||
|       upsId: context.upsId, | ||||
|       upsName: context.upsName, | ||||
|       powerStatus: context.powerStatus, | ||||
|       batteryCapacity: context.batteryCapacity, | ||||
|       batteryRuntime: context.batteryRuntime, | ||||
|       triggerReason: context.triggerReason, | ||||
|       timestamp: context.timestamp, | ||||
|     }; | ||||
|  | ||||
|     // Include action's own thresholds if configured | ||||
|     if (this.config.thresholds) { | ||||
|       payload.thresholds = { | ||||
|         battery: this.config.thresholds.battery, | ||||
|         runtime: this.config.thresholds.runtime, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     const url = new URL(this.config.webhookUrl!); | ||||
|  | ||||
|     if (method === 'GET') { | ||||
|       // Append payload as query parameters for GET | ||||
|       url.searchParams.append('upsId', payload.upsId); | ||||
|       url.searchParams.append('upsName', payload.upsName); | ||||
|       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)); | ||||
|     } | ||||
|  | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const protocol = url.protocol === 'https:' ? https : http; | ||||
|  | ||||
|       const options: http.RequestOptions = { | ||||
|         method, | ||||
|         headers: method === 'POST' | ||||
|           ? { | ||||
|             'Content-Type': 'application/json', | ||||
|             'User-Agent': 'nupst', | ||||
|           } | ||||
|           : { | ||||
|             'User-Agent': 'nupst', | ||||
|           }, | ||||
|         timeout, | ||||
|       }; | ||||
|  | ||||
|       const req = protocol.request(url, options, (res) => { | ||||
|         let data = ''; | ||||
|  | ||||
|         res.on('data', (chunk) => { | ||||
|           data += chunk; | ||||
|         }); | ||||
|  | ||||
|         res.on('end', () => { | ||||
|           if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { | ||||
|             logger.dim(`Webhook response (${res.statusCode}): ${data.substring(0, 100)}`); | ||||
|             resolve(); | ||||
|           } else { | ||||
|             reject(new Error(`Webhook returned status ${res.statusCode}`)); | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       req.on('error', (error) => { | ||||
|         reject(error); | ||||
|       }); | ||||
|  | ||||
|       req.on('timeout', () => { | ||||
|         req.destroy(); | ||||
|         reject(new Error(`Webhook request timed out after ${timeout}ms`)); | ||||
|       }); | ||||
|  | ||||
|       // Send POST data if applicable | ||||
|       if (method === 'POST') { | ||||
|         req.write(JSON.stringify(payload)); | ||||
|       } | ||||
|  | ||||
|       req.end(); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										357
									
								
								ts/cli/action-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										357
									
								
								ts/cli/action-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,357 @@ | ||||
| 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 { IActionConfig } from '../actions/base-action.ts'; | ||||
| import type { IUpsConfig, IGroupConfig } from '../daemon.ts'; | ||||
|  | ||||
| /** | ||||
|  * Class for handling action-related CLI commands | ||||
|  * Provides interface for managing UPS actions | ||||
|  */ | ||||
| export class ActionHandler { | ||||
|   private readonly nupst: Nupst; | ||||
|  | ||||
|   /** | ||||
|    * Create a new action handler | ||||
|    * @param nupst Reference to the main Nupst instance | ||||
|    */ | ||||
|   constructor(nupst: Nupst) { | ||||
|     this.nupst = nupst; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add a new action to a UPS or group | ||||
|    */ | ||||
|   public async add(targetId?: string): Promise<void> { | ||||
|     try { | ||||
|       if (!targetId) { | ||||
|         logger.error('Target ID is required'); | ||||
|         logger.log( | ||||
|           `  ${theme.dim('Usage:')} ${theme.command('nupst action add <ups-id|group-id>')}`, | ||||
|         ); | ||||
|         logger.log(''); | ||||
|         logger.log(`  ${theme.dim('List UPS devices:')} ${theme.command('nupst ups list')}`); | ||||
|         logger.log(`  ${theme.dim('List groups:')} ${theme.command('nupst group list')}`); | ||||
|         logger.log(''); | ||||
|         process.exit(1); | ||||
|       } | ||||
|  | ||||
|       const config = await this.nupst.getDaemon().loadConfig(); | ||||
|  | ||||
|       // Check if it's a UPS | ||||
|       const ups = config.upsDevices.find((u) => u.id === targetId); | ||||
|       // Check if it's a group | ||||
|       const group = config.groups?.find((g) => g.id === targetId); | ||||
|  | ||||
|       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(''); | ||||
|         process.exit(1); | ||||
|       } | ||||
|  | ||||
|       const target = ups || group; | ||||
|       const targetType = ups ? 'UPS' : 'Group'; | ||||
|       const targetName = ups ? ups.name : group!.name; | ||||
|  | ||||
|       const readline = await import('node:readline'); | ||||
|       const rl = readline.createInterface({ | ||||
|         input: process.stdin, | ||||
|         output: process.stdout, | ||||
|       }); | ||||
|  | ||||
|       const prompt = (question: string): Promise<string> => { | ||||
|         return new Promise((resolve) => { | ||||
|           rl.question(question, (answer: string) => { | ||||
|             resolve(answer); | ||||
|           }); | ||||
|         }); | ||||
|       }; | ||||
|  | ||||
|       try { | ||||
|         logger.log(''); | ||||
|         logger.info(`Add Action to ${targetType} ${theme.highlight(targetName)}`); | ||||
|         logger.log(''); | ||||
|  | ||||
|         // Action type (currently only shutdown is supported) | ||||
|         const type = 'shutdown'; | ||||
|         logger.log(`  ${theme.dim('Action type:')} ${theme.highlight('shutdown')}`); | ||||
|  | ||||
|         // Battery threshold | ||||
|         const batteryStr = await prompt( | ||||
|           `  ${theme.dim('Battery threshold')} ${theme.dim('(%):')} `, | ||||
|         ); | ||||
|         const battery = parseInt(batteryStr, 10); | ||||
|         if (isNaN(battery) || battery < 0 || battery > 100) { | ||||
|           logger.error('Invalid battery threshold. Must be 0-100.'); | ||||
|           process.exit(1); | ||||
|         } | ||||
|  | ||||
|         // Runtime threshold | ||||
|         const runtimeStr = await prompt( | ||||
|           `  ${theme.dim('Runtime threshold')} ${theme.dim('(minutes):')} `, | ||||
|         ); | ||||
|         const runtime = parseInt(runtimeStr, 10); | ||||
|         if (isNaN(runtime) || runtime < 0) { | ||||
|           logger.error('Invalid runtime threshold. Must be >= 0.'); | ||||
|           process.exit(1); | ||||
|         } | ||||
|  | ||||
|         // 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('2)')} onlyThresholds - Trigger only when thresholds are violated`, | ||||
|         ); | ||||
|         logger.log( | ||||
|           `    ${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]:')} `); | ||||
|         const triggerModeMap: Record<string, string> = { | ||||
|           '1': 'onlyPowerChanges', | ||||
|           '2': 'onlyThresholds', | ||||
|           '3': 'powerChangesAndThresholds', | ||||
|           '4': 'anyChange', | ||||
|           '': 'onlyThresholds', // Default | ||||
|         }; | ||||
|         const triggerMode = triggerModeMap[triggerChoice] || 'onlyThresholds'; | ||||
|  | ||||
|         // Shutdown delay | ||||
|         const delayStr = await prompt( | ||||
|           `  ${theme.dim('Shutdown delay')} ${theme.dim('(seconds) [5]:')} `, | ||||
|         ); | ||||
|         const shutdownDelay = delayStr ? parseInt(delayStr, 10) : 5; | ||||
|         if (isNaN(shutdownDelay) || shutdownDelay < 0) { | ||||
|           logger.error('Invalid shutdown delay. Must be >= 0.'); | ||||
|           process.exit(1); | ||||
|         } | ||||
|  | ||||
|         // Create the action | ||||
|         const newAction: IActionConfig = { | ||||
|           type, | ||||
|           thresholds: { | ||||
|             battery, | ||||
|             runtime, | ||||
|           }, | ||||
|           triggerMode: triggerMode as IActionConfig['triggerMode'], | ||||
|           shutdownDelay, | ||||
|         }; | ||||
|  | ||||
|         // Add to target (UPS or group) | ||||
|         if (!target!.actions) { | ||||
|           target!.actions = []; | ||||
|         } | ||||
|         target!.actions.push(newAction); | ||||
|  | ||||
|         await this.nupst.getDaemon().saveConfig(config); | ||||
|  | ||||
|         logger.log(''); | ||||
|         logger.success(`Action added to ${targetType} ${targetName}`); | ||||
|         logger.log(`  ${theme.dim('Changes saved and will be applied automatically')}`); | ||||
|         logger.log(''); | ||||
|       } finally { | ||||
|         rl.close(); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Failed to add action: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Remove an action from a UPS or group | ||||
|    */ | ||||
|   public async remove(targetId?: string, actionIndexStr?: string): Promise<void> { | ||||
|     try { | ||||
|       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>')}`, | ||||
|         ); | ||||
|         logger.log(''); | ||||
|         logger.log(`  ${theme.dim('List actions:')} ${theme.command('nupst action list')}`); | ||||
|         logger.log(''); | ||||
|         process.exit(1); | ||||
|       } | ||||
|  | ||||
|       const actionIndex = parseInt(actionIndexStr, 10); | ||||
|       if (isNaN(actionIndex) || actionIndex < 0) { | ||||
|         logger.error('Invalid action index. Must be >= 0.'); | ||||
|         process.exit(1); | ||||
|       } | ||||
|  | ||||
|       const config = await this.nupst.getDaemon().loadConfig(); | ||||
|  | ||||
|       // Check if it's a UPS | ||||
|       const ups = config.upsDevices.find((u) => u.id === targetId); | ||||
|       // Check if it's a group | ||||
|       const group = config.groups?.find((g) => g.id === targetId); | ||||
|  | ||||
|       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(''); | ||||
|         process.exit(1); | ||||
|       } | ||||
|  | ||||
|       const target = ups || group; | ||||
|       const targetType = ups ? 'UPS' : 'Group'; | ||||
|       const targetName = ups ? ups.name : group!.name; | ||||
|  | ||||
|       if (!target!.actions || target!.actions.length === 0) { | ||||
|         logger.error(`No actions configured for ${targetType} '${targetName}'`); | ||||
|         logger.log(''); | ||||
|         process.exit(1); | ||||
|       } | ||||
|  | ||||
|       if (actionIndex >= target!.actions.length) { | ||||
|         logger.error( | ||||
|           `Invalid action index. ${targetType} '${targetName}' has ${target!.actions.length} action(s) (index 0-${target!.actions.length - 1})`, | ||||
|         ); | ||||
|         logger.log(''); | ||||
|         logger.log( | ||||
|           `  ${theme.dim('List actions:')} ${theme.command(`nupst action list ${targetId}`)}`, | ||||
|         ); | ||||
|         logger.log(''); | ||||
|         process.exit(1); | ||||
|       } | ||||
|  | ||||
|       const removedAction = target!.actions[actionIndex]; | ||||
|       target!.actions.splice(actionIndex, 1); | ||||
|  | ||||
|       await this.nupst.getDaemon().saveConfig(config); | ||||
|  | ||||
|       logger.log(''); | ||||
|       logger.success(`Action removed from ${targetType} ${targetName}`); | ||||
|       logger.log(`  ${theme.dim('Type:')} ${removedAction.type}`); | ||||
|       if (removedAction.thresholds) { | ||||
|         logger.log( | ||||
|           `  ${theme.dim('Thresholds:')} Battery: ${removedAction.thresholds.battery}%, Runtime: ${removedAction.thresholds.runtime}min`, | ||||
|         ); | ||||
|       } | ||||
|       logger.log(`  ${theme.dim('Changes saved and will be applied automatically')}`); | ||||
|       logger.log(''); | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Failed to remove action: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * List all actions for a specific UPS/group or all devices | ||||
|    */ | ||||
|   public async list(targetId?: string): Promise<void> { | ||||
|     try { | ||||
|       const config = await this.nupst.getDaemon().loadConfig(); | ||||
|  | ||||
|       if (targetId) { | ||||
|         // List actions for specific UPS or group | ||||
|         const ups = config.upsDevices.find((u) => u.id === targetId); | ||||
|         const group = config.groups?.find((g) => g.id === targetId); | ||||
|  | ||||
|         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(''); | ||||
|           process.exit(1); | ||||
|         } | ||||
|  | ||||
|         if (ups) { | ||||
|           this.displayTargetActions(ups, 'UPS'); | ||||
|         } else { | ||||
|           this.displayTargetActions(group!, 'Group'); | ||||
|         } | ||||
|       } else { | ||||
|         // List actions for all UPS devices and groups | ||||
|         logger.log(''); | ||||
|         logger.info('Actions for All UPS Devices and Groups'); | ||||
|         logger.log(''); | ||||
|  | ||||
|         let hasAnyActions = false; | ||||
|  | ||||
|         // Display UPS actions | ||||
|         for (const ups of config.upsDevices) { | ||||
|           if (ups.actions && ups.actions.length > 0) { | ||||
|             hasAnyActions = true; | ||||
|             this.displayTargetActions(ups, 'UPS'); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // Display Group actions | ||||
|         for (const group of config.groups || []) { | ||||
|           if (group.actions && group.actions.length > 0) { | ||||
|             hasAnyActions = true; | ||||
|             this.displayTargetActions(group, 'Group'); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         if (!hasAnyActions) { | ||||
|           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>')}`, | ||||
|           ); | ||||
|           logger.log(''); | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Failed to list actions: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Display actions for a single UPS or Group | ||||
|    */ | ||||
|   private displayTargetActions( | ||||
|     target: IUpsConfig | IGroupConfig, | ||||
|     targetType: 'UPS' | 'Group', | ||||
|   ): void { | ||||
|     logger.log( | ||||
|       `${symbols.info} ${targetType} ${theme.highlight(target.name)} ${theme.dim(`(${target.id})`)}`, | ||||
|     ); | ||||
|     logger.log(''); | ||||
|  | ||||
|     if (!target.actions || target.actions.length === 0) { | ||||
|       logger.log(`  ${theme.dim('No actions configured')}`); | ||||
|       logger.log(''); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const columns: ITableColumn[] = [ | ||||
|       { header: 'Index', key: 'index', align: 'right' }, | ||||
|       { header: 'Type', key: 'type', align: 'left' }, | ||||
|       { header: 'Battery', key: 'battery', align: 'right' }, | ||||
|       { header: 'Runtime', key: 'runtime', align: 'right' }, | ||||
|       { header: 'Trigger Mode', key: 'triggerMode', align: 'left' }, | ||||
|       { header: 'Delay', key: 'delay', align: 'right' }, | ||||
|     ]; | ||||
|  | ||||
|     const rows = target.actions.map((action, index) => ({ | ||||
|       index: theme.dim(index.toString()), | ||||
|       type: theme.highlight(action.type), | ||||
|       battery: action.thresholds ? `${action.thresholds.battery}%` : theme.dim('N/A'), | ||||
|       runtime: action.thresholds ? `${action.thresholds.runtime}min` : theme.dim('N/A'), | ||||
|       triggerMode: theme.dim(action.triggerMode || 'onlyThresholds'), | ||||
|       delay: `${action.shutdownDelay || 5}s`, | ||||
|     })); | ||||
|  | ||||
|     logger.logTable(columns, rows); | ||||
|     logger.log(''); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										213
									
								
								ts/cli/feature-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								ts/cli/feature-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,213 @@ | ||||
| import process from 'node:process'; | ||||
| import { execSync } from 'node:child_process'; | ||||
| import { Nupst } from '../nupst.ts'; | ||||
| import { logger } from '../logger.ts'; | ||||
| import { theme } from '../colors.ts'; | ||||
| import * as helpers from '../helpers/index.ts'; | ||||
|  | ||||
| /** | ||||
|  * Class for handling feature-related CLI commands | ||||
|  * Provides interface for managing optional features like HTTP server | ||||
|  */ | ||||
| export class FeatureHandler { | ||||
|   private readonly nupst: Nupst; | ||||
|  | ||||
|   /** | ||||
|    * Create a new feature handler | ||||
|    * @param nupst Reference to the main Nupst instance | ||||
|    */ | ||||
|   constructor(nupst: Nupst) { | ||||
|     this.nupst = nupst; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Configure HTTP server feature | ||||
|    */ | ||||
|   public async configureHttpServer(): Promise<void> { | ||||
|     try { | ||||
|       const readline = await import('node:readline'); | ||||
|       const rl = readline.createInterface({ | ||||
|         input: process.stdin, | ||||
|         output: process.stdout, | ||||
|       }); | ||||
|  | ||||
|       const prompt = (question: string): Promise<string> => { | ||||
|         return new Promise((resolve) => { | ||||
|           rl.question(question, (answer: string) => { | ||||
|             resolve(answer); | ||||
|           }); | ||||
|         }); | ||||
|       }; | ||||
|  | ||||
|       try { | ||||
|         await this.runHttpServerConfig(prompt); | ||||
|       } finally { | ||||
|         rl.close(); | ||||
|         process.stdin.destroy(); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       logger.error(`HTTP Server config error: ${error instanceof Error ? error.message : String(error)}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Run the interactive HTTP server configuration process | ||||
|    * @param prompt Function to prompt for user input | ||||
|    */ | ||||
|   private async runHttpServerConfig(prompt: (question: string) => Promise<string>): Promise<void> { | ||||
|     logger.log(''); | ||||
|     logger.logBoxTitle('HTTP Server Feature Configuration', 60); | ||||
|     logger.logBoxLine('Configure the HTTP server to expose UPS status as JSON'); | ||||
|     logger.logBoxEnd(); | ||||
|     logger.log(''); | ||||
|  | ||||
|     // Load config | ||||
|     let config; | ||||
|     try { | ||||
|       await this.nupst.getDaemon().loadConfig(); | ||||
|       config = this.nupst.getDaemon().getConfig(); | ||||
|     } catch (error) { | ||||
|       logger.error('No configuration found. Please run "nupst ups add" first.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Show current status | ||||
|     if (config.httpServer?.enabled) { | ||||
|       logger.info('HTTP Server is currently: ' + theme.success('ENABLED')); | ||||
|       logger.log(`  Port: ${theme.highlight(String(config.httpServer.port))}`); | ||||
|       logger.log(`  Path: ${theme.highlight(config.httpServer.path)}`); | ||||
|       logger.log(`  Auth Token: ${theme.dim('***' + config.httpServer.authToken.slice(-4))}`); | ||||
|       logger.log(''); | ||||
|     } else { | ||||
|       logger.info('HTTP Server is currently: ' + theme.dim('DISABLED')); | ||||
|       logger.log(''); | ||||
|     } | ||||
|  | ||||
|     // Ask enable/disable | ||||
|     const action = await prompt('Enable or disable HTTP server? (enable/disable/cancel): '); | ||||
|  | ||||
|     if (action.toLowerCase() === 'cancel' || action.toLowerCase() === 'c') { | ||||
|       logger.log('Cancelled.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (action.toLowerCase() === 'disable' || action.toLowerCase() === 'd') { | ||||
|       // Disable HTTP server | ||||
|       config.httpServer = { | ||||
|         enabled: false, | ||||
|         port: config.httpServer?.port || 8080, | ||||
|         path: config.httpServer?.path || '/ups-status', | ||||
|         authToken: config.httpServer?.authToken || '', | ||||
|       }; | ||||
|  | ||||
|       this.nupst.getDaemon().saveConfig(config); | ||||
|  | ||||
|       logger.log(''); | ||||
|       logger.success('HTTP Server disabled'); | ||||
|       logger.log(''); | ||||
|  | ||||
|       await this.restartServiceIfRunning(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (action.toLowerCase() !== 'enable' && action.toLowerCase() !== 'e') { | ||||
|       logger.error('Invalid option. Please enter "enable", "disable", or "cancel".'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Enable - gather configuration | ||||
|     logger.log(''); | ||||
|  | ||||
|     const portInput = await prompt(`HTTP Server Port [${config.httpServer?.port || 8080}]: `); | ||||
|     const port = portInput ? parseInt(portInput, 10) : (config.httpServer?.port || 8080); | ||||
|  | ||||
|     if (isNaN(port) || port < 1 || port > 65535) { | ||||
|       logger.error('Invalid port number. Must be between 1 and 65535.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const pathInput = await prompt(`URL Path [${config.httpServer?.path || '/ups-status'}]: `); | ||||
|     const path = pathInput || config.httpServer?.path || '/ups-status'; | ||||
|  | ||||
|     // Ensure path starts with / | ||||
|     const finalPath = path.startsWith('/') ? path : `/${path}`; | ||||
|  | ||||
|     // Generate or reuse auth token | ||||
|     let authToken = config.httpServer?.authToken; | ||||
|     if (!authToken) { | ||||
|       // Generate new random token | ||||
|       authToken = helpers.shortId() + helpers.shortId() + helpers.shortId(); | ||||
|       logger.log(''); | ||||
|       logger.info('Generated new authentication token'); | ||||
|     } else { | ||||
|       const regenerate = await prompt('Regenerate authentication token? (y/N): '); | ||||
|       if (regenerate.toLowerCase() === 'y' || regenerate.toLowerCase() === 'yes') { | ||||
|         authToken = helpers.shortId() + helpers.shortId() + helpers.shortId(); | ||||
|         logger.info('Generated new authentication token'); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Save configuration | ||||
|     config.httpServer = { | ||||
|       enabled: true, | ||||
|       port, | ||||
|       path: finalPath, | ||||
|       authToken, | ||||
|     }; | ||||
|  | ||||
|     this.nupst.getDaemon().saveConfig(config); | ||||
|  | ||||
|     // Display summary | ||||
|     logger.log(''); | ||||
|     logger.logBoxTitle('HTTP Server Configuration', 70, 'success'); | ||||
|     logger.logBoxLine(`Status: ${theme.success('ENABLED')}`); | ||||
|     logger.logBoxLine(`Port: ${theme.highlight(String(port))}`); | ||||
|     logger.logBoxLine(`Path: ${theme.highlight(finalPath)}`); | ||||
|     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 "http://localhost:${port}${finalPath}?token=${authToken}"`); | ||||
|     logger.logBoxEnd(); | ||||
|     logger.log(''); | ||||
|  | ||||
|     logger.warn('IMPORTANT: Save the authentication token securely!'); | ||||
|     logger.log(''); | ||||
|  | ||||
|     await this.restartServiceIfRunning(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Restart the service if it's currently running | ||||
|    */ | ||||
|   private async restartServiceIfRunning(): Promise<void> { | ||||
|     try { | ||||
|       const isActive = execSync('systemctl is-active nupst.service || true').toString().trim() === 'active'; | ||||
|  | ||||
|       if (isActive) { | ||||
|         logger.log(''); | ||||
|         const readline = await import('node:readline'); | ||||
|         const rl = readline.createInterface({ | ||||
|           input: process.stdin, | ||||
|           output: process.stdout, | ||||
|         }); | ||||
|  | ||||
|         const answer = await new Promise<string>((resolve) => { | ||||
|           rl.question('Service is running. Restart to apply changes? (Y/n): ', resolve); | ||||
|         }); | ||||
|  | ||||
|         rl.close(); | ||||
|  | ||||
|         if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { | ||||
|           logger.info('Restarting service...'); | ||||
|           execSync('sudo systemctl restart nupst.service'); | ||||
|           logger.success('Service restarted successfully'); | ||||
|         } else { | ||||
|           logger.warn('Changes will take effect on next service restart'); | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       // Ignore errors - service might not be installed | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										606
									
								
								ts/cli/group-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										606
									
								
								ts/cli/group-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,606 @@ | ||||
| import process from 'node:process'; | ||||
| import { Nupst } from '../nupst.ts'; | ||||
| import { logger, type ITableColumn } from '../logger.ts'; | ||||
| import { theme } from '../colors.ts'; | ||||
| import * as helpers from '../helpers/index.ts'; | ||||
| import { type IGroupConfig } from '../daemon.ts'; | ||||
|  | ||||
| /** | ||||
|  * Class for handling group-related CLI commands | ||||
|  * Provides interface for managing UPS groups | ||||
|  */ | ||||
| export class GroupHandler { | ||||
|   private readonly nupst: Nupst; | ||||
|  | ||||
|   /** | ||||
|    * Create a new Group handler | ||||
|    * @param nupst Reference to the main Nupst instance | ||||
|    */ | ||||
|   constructor(nupst: Nupst) { | ||||
|     this.nupst = nupst; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * List all UPS groups | ||||
|    */ | ||||
|   public async list(): Promise<void> { | ||||
|     try { | ||||
|       // Try to load configuration | ||||
|       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'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Get current configuration | ||||
|       const config = this.nupst.getDaemon().getConfig(); | ||||
|  | ||||
|       // 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'); | ||||
|         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'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Prepare table data | ||||
|       const rows = config.groups.map((group) => { | ||||
|         // Count UPS devices in this group | ||||
|         const upsInGroup = config.upsDevices.filter((ups) => ups.groups.includes(group.id)); | ||||
|         const upsCount = upsInGroup.length; | ||||
|         const upsNames = upsInGroup.map((ups) => ups.name).join(', '); | ||||
|  | ||||
|         return { | ||||
|           id: group.id, | ||||
|           name: group.name || '', | ||||
|           mode: group.mode || 'unknown', | ||||
|           count: String(upsCount), | ||||
|           devices: upsCount > 0 ? upsNames : theme.dim('None'), | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|       const columns: ITableColumn[] = [ | ||||
|         { header: 'ID', key: 'id', align: 'left', color: theme.highlight }, | ||||
|         { header: 'Name', key: 'name', align: 'left' }, | ||||
|         { header: 'Mode', key: 'mode', align: 'left', color: theme.info }, | ||||
|         { header: 'UPS Count', key: 'count', align: 'right' }, | ||||
|         { header: 'UPS Devices', key: 'devices', align: 'left' }, | ||||
|       ]; | ||||
|  | ||||
|       logger.log(''); | ||||
|       logger.info(`UPS Groups (${config.groups.length}):`); | ||||
|       logger.log(''); | ||||
|       logger.logTable(columns, rows); | ||||
|       logger.log(''); | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Failed to list UPS groups: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add a new UPS group | ||||
|    */ | ||||
|   public async add(): Promise<void> { | ||||
|     try { | ||||
|       // Import readline module for user input | ||||
|       const readline = await import('node:readline'); | ||||
|  | ||||
|       const rl = readline.createInterface({ | ||||
|         input: process.stdin, | ||||
|         output: process.stdout, | ||||
|       }); | ||||
|  | ||||
|       // Helper function to prompt for input | ||||
|       const prompt = (question: string): Promise<string> => { | ||||
|         return new Promise((resolve) => { | ||||
|           rl.question(question, (answer: string) => { | ||||
|             resolve(answer); | ||||
|           }); | ||||
|         }); | ||||
|       }; | ||||
|  | ||||
|       try { | ||||
|         // Try to load configuration | ||||
|         try { | ||||
|           await this.nupst.getDaemon().loadConfig(); | ||||
|         } catch (error) { | ||||
|           logger.error( | ||||
|             'No configuration found. Please run "nupst setup" first to create a configuration.', | ||||
|           ); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Get current configuration | ||||
|         const config = this.nupst.getDaemon().getConfig(); | ||||
|  | ||||
|         // Initialize groups array if not exists | ||||
|         if (!config.groups) { | ||||
|           config.groups = []; | ||||
|         } | ||||
|  | ||||
|         // Check if upsDevices is initialized | ||||
|         if (!config.upsDevices) { | ||||
|           config.upsDevices = []; | ||||
|         } | ||||
|  | ||||
|         logger.log('\nNUPST Add Group'); | ||||
|         logger.log('==============\n'); | ||||
|         logger.log('This will guide you through creating a new UPS group.\n'); | ||||
|  | ||||
|         // Generate a new unique group ID | ||||
|         const groupId = helpers.shortId(); | ||||
|  | ||||
|         // Get group name | ||||
|         const name = await prompt('Group Name: '); | ||||
|  | ||||
|         // Get group mode | ||||
|         const modeInput = await prompt('Group Mode (redundant/nonRedundant) [redundant]: '); | ||||
|         const mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant'; | ||||
|  | ||||
|         // Get optional description | ||||
|         const description = await prompt('Group Description (optional): '); | ||||
|  | ||||
|         // Create the new group | ||||
|         const newGroup: IGroupConfig = { | ||||
|           id: groupId, | ||||
|           name: name || `Group-${groupId}`, | ||||
|           mode, | ||||
|           description: description || undefined, | ||||
|         }; | ||||
|  | ||||
|         // Add the group to the configuration | ||||
|         config.groups.push(newGroup); | ||||
|  | ||||
|         // Save the configuration | ||||
|         await this.nupst.getDaemon().saveConfig(config); | ||||
|  | ||||
|         // Display summary | ||||
|         const boxWidth = 45; | ||||
|         logger.logBoxTitle('Group Created', boxWidth); | ||||
|         logger.logBoxLine(`ID: ${newGroup.id}`); | ||||
|         logger.logBoxLine(`Name: ${newGroup.name}`); | ||||
|         logger.logBoxLine(`Mode: ${newGroup.mode}`); | ||||
|         if (newGroup.description) { | ||||
|           logger.logBoxLine(`Description: ${newGroup.description}`); | ||||
|         } | ||||
|         logger.logBoxEnd(); | ||||
|  | ||||
|         // Check if there are UPS devices to assign to this group | ||||
|         if (config.upsDevices.length > 0) { | ||||
|           const assignUps = await prompt( | ||||
|             'Would you like to assign UPS devices to this group now? (y/N): ', | ||||
|           ); | ||||
|           if (assignUps.toLowerCase() === 'y') { | ||||
|             await this.assignUpsToGroup(newGroup.id, config, prompt); | ||||
|  | ||||
|             // Save again after assigning UPS devices | ||||
|             await this.nupst.getDaemon().saveConfig(config); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // Check if service is running and restart it if needed | ||||
|         this.nupst.getUpsHandler().restartServiceIfRunning(); | ||||
|  | ||||
|         logger.log('\nGroup setup complete!'); | ||||
|       } finally { | ||||
|         rl.close(); | ||||
|         process.stdin.destroy(); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       logger.error(`Add group error: ${error instanceof Error ? error.message : String(error)}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Edit an existing UPS group | ||||
|    * @param groupId ID of the group to edit | ||||
|    */ | ||||
|   public async edit(groupId: string): Promise<void> { | ||||
|     try { | ||||
|       // Import readline module for user input | ||||
|       const readline = await import('node:readline'); | ||||
|  | ||||
|       const rl = readline.createInterface({ | ||||
|         input: process.stdin, | ||||
|         output: process.stdout, | ||||
|       }); | ||||
|  | ||||
|       // Helper function to prompt for input | ||||
|       const prompt = (question: string): Promise<string> => { | ||||
|         return new Promise((resolve) => { | ||||
|           rl.question(question, (answer: string) => { | ||||
|             resolve(answer); | ||||
|           }); | ||||
|         }); | ||||
|       }; | ||||
|  | ||||
|       try { | ||||
|         // Try to load configuration | ||||
|         try { | ||||
|           await this.nupst.getDaemon().loadConfig(); | ||||
|         } catch (error) { | ||||
|           logger.error( | ||||
|             'No configuration found. Please run "nupst setup" first to create a configuration.', | ||||
|           ); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Get current configuration | ||||
|         const config = this.nupst.getDaemon().getConfig(); | ||||
|  | ||||
|         // Check if groups are initialized | ||||
|         if (!config.groups || !Array.isArray(config.groups)) { | ||||
|           logger.error( | ||||
|             'No groups configured. Please run "nupst group add" first to create a group.', | ||||
|           ); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Find the group to edit | ||||
|         const groupIndex = config.groups.findIndex((group) => group.id === groupId); | ||||
|         if (groupIndex === -1) { | ||||
|           logger.error(`Group with ID "${groupId}" not found.`); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         const group = config.groups[groupIndex]; | ||||
|  | ||||
|         logger.log(`\nNUPST Edit Group: ${group.name} (${group.id})`); | ||||
|         logger.log('==============================================\n'); | ||||
|  | ||||
|         // Edit group name | ||||
|         const newName = await prompt(`Group Name [${group.name}]: `); | ||||
|         if (newName.trim()) { | ||||
|           group.name = newName; | ||||
|         } | ||||
|  | ||||
|         // Edit group mode | ||||
|         const currentMode = group.mode || 'redundant'; | ||||
|         const modeInput = await prompt(`Group Mode (redundant/nonRedundant) [${currentMode}]: `); | ||||
|         if (modeInput.trim()) { | ||||
|           group.mode = modeInput.toLowerCase() === 'nonredundant' ? 'nonRedundant' : 'redundant'; | ||||
|         } | ||||
|  | ||||
|         // Edit description | ||||
|         const currentDesc = group.description || ''; | ||||
|         const newDesc = await prompt(`Group Description [${currentDesc}]: `); | ||||
|         if (newDesc.trim() || newDesc === '') { | ||||
|           group.description = newDesc.trim() || undefined; | ||||
|         } | ||||
|  | ||||
|         // Update the group in the configuration | ||||
|         config.groups[groupIndex] = group; | ||||
|  | ||||
|         // Save the configuration | ||||
|         await this.nupst.getDaemon().saveConfig(config); | ||||
|  | ||||
|         // Display summary | ||||
|         const boxWidth = 45; | ||||
|         logger.logBoxTitle('Group Updated', boxWidth); | ||||
|         logger.logBoxLine(`ID: ${group.id}`); | ||||
|         logger.logBoxLine(`Name: ${group.name}`); | ||||
|         logger.logBoxLine(`Mode: ${group.mode}`); | ||||
|         if (group.description) { | ||||
|           logger.logBoxLine(`Description: ${group.description}`); | ||||
|         } | ||||
|         logger.logBoxEnd(); | ||||
|  | ||||
|         // Edit UPS assignments if requested | ||||
|         const editAssignments = await prompt( | ||||
|           'Would you like to edit UPS assignments for this group? (y/N): ', | ||||
|         ); | ||||
|         if (editAssignments.toLowerCase() === 'y') { | ||||
|           await this.assignUpsToGroup(group.id, config, prompt); | ||||
|  | ||||
|           // Save again after editing assignments | ||||
|           await this.nupst.getDaemon().saveConfig(config); | ||||
|         } | ||||
|  | ||||
|         // Check if service is running and restart it if needed | ||||
|         this.nupst.getUpsHandler().restartServiceIfRunning(); | ||||
|  | ||||
|         logger.log('\nGroup edit complete!'); | ||||
|       } finally { | ||||
|         rl.close(); | ||||
|         process.stdin.destroy(); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       logger.error(`Edit group error: ${error instanceof Error ? error.message : String(error)}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete an existing UPS group | ||||
|    * @param groupId ID of the group to delete | ||||
|    */ | ||||
|   public async remove(groupId: string): Promise<void> { | ||||
|     try { | ||||
|       // Try to load configuration | ||||
|       try { | ||||
|         await this.nupst.getDaemon().loadConfig(); | ||||
|       } catch (error) { | ||||
|         logger.error( | ||||
|           'No configuration found. Please run "nupst setup" first to create a configuration.', | ||||
|         ); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Get current configuration | ||||
|       const config = this.nupst.getDaemon().getConfig(); | ||||
|  | ||||
|       // Check if groups are initialized | ||||
|       if (!config.groups || !Array.isArray(config.groups)) { | ||||
|         logger.error('No groups configured.'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Find the group to delete | ||||
|       const groupIndex = config.groups.findIndex((group) => group.id === groupId); | ||||
|       if (groupIndex === -1) { | ||||
|         logger.error(`Group with ID "${groupId}" not found.`); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const groupToDelete = config.groups[groupIndex]; | ||||
|  | ||||
|       // Get confirmation before deleting | ||||
|       const readline = await import('node:readline'); | ||||
|       const rl = readline.createInterface({ | ||||
|         input: process.stdin, | ||||
|         output: process.stdout, | ||||
|       }); | ||||
|  | ||||
|       const confirm = await new Promise<string>((resolve) => { | ||||
|         rl.question( | ||||
|           `Are you sure you want to delete group "${groupToDelete.name}" (${groupId})? [y/N]: `, | ||||
|           (answer) => { | ||||
|             resolve(answer.toLowerCase()); | ||||
|           }, | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|       rl.close(); | ||||
|       process.stdin.destroy(); | ||||
|  | ||||
|       if (confirm !== 'y' && confirm !== 'yes') { | ||||
|         logger.log('Deletion cancelled.'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Remove this group from all UPS device group assignments | ||||
|       if (config.upsDevices && Array.isArray(config.upsDevices)) { | ||||
|         for (const ups of config.upsDevices) { | ||||
|           const groupIndex = ups.groups.indexOf(groupId); | ||||
|           if (groupIndex !== -1) { | ||||
|             ups.groups.splice(groupIndex, 1); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // Remove the group from the array | ||||
|       config.groups.splice(groupIndex, 1); | ||||
|  | ||||
|       // Save the configuration | ||||
|       await this.nupst.getDaemon().saveConfig(config); | ||||
|  | ||||
|       logger.log(`Group "${groupToDelete.name}" (${groupId}) has been deleted.`); | ||||
|  | ||||
|       // Check if service is running and restart it if needed | ||||
|       this.nupst.getUpsHandler().restartServiceIfRunning(); | ||||
|     } catch (error) { | ||||
|       logger.error( | ||||
|         `Failed to delete group: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Assign UPS devices to groups | ||||
|    * @param ups UPS configuration to update | ||||
|    * @param groups Available groups | ||||
|    * @param prompt Function to prompt for user input | ||||
|    */ | ||||
|   public async assignUpsToGroups( | ||||
|     ups: any, | ||||
|     groups: any[], | ||||
|     prompt: (question: string) => Promise<string>, | ||||
|   ): Promise<void> { | ||||
|     // Initialize groups array if it doesn't exist | ||||
|     if (!ups.groups) { | ||||
|       ups.groups = []; | ||||
|     } | ||||
|  | ||||
|     // Show current group assignments | ||||
|     logger.log('\nCurrent Group Assignments:'); | ||||
|     if (ups.groups && ups.groups.length > 0) { | ||||
|       for (const groupId of ups.groups) { | ||||
|         const group = groups.find((g) => g.id === groupId); | ||||
|         if (group) { | ||||
|           logger.log(`- ${group.name} (${group.id})`); | ||||
|         } else { | ||||
|           logger.log(`- Unknown group (${groupId})`); | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       logger.log('- None'); | ||||
|     } | ||||
|  | ||||
|     // Show available groups | ||||
|     logger.log('\nAvailable Groups:'); | ||||
|     if (groups.length === 0) { | ||||
|       logger.log('- No groups available. Use "nupst group add" to create groups.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     for (let i = 0; i < groups.length; i++) { | ||||
|       const group = groups[i]; | ||||
|       const assigned = ups.groups && ups.groups.includes(group.id); | ||||
|       logger.log( | ||||
|         `${i + 1}) ${group.name} (${group.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Prompt for group selection | ||||
|     const selection = await prompt( | ||||
|       '\nSelect groups to assign/unassign (comma-separated numbers, or "clear" to remove all): ', | ||||
|     ); | ||||
|  | ||||
|     if (selection.toLowerCase() === 'clear') { | ||||
|       // Clear all group assignments | ||||
|       ups.groups = []; | ||||
|       logger.log('All group assignments cleared.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!selection.trim()) { | ||||
|       // No change if empty input | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Process selections | ||||
|     const selections = selection.split(',').map((s) => s.trim()); | ||||
|  | ||||
|     for (const sel of selections) { | ||||
|       const index = parseInt(sel, 10) - 1; | ||||
|       if (isNaN(index) || index < 0 || index >= groups.length) { | ||||
|         logger.error(`Invalid selection: ${sel}`); | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       const group = groups[index]; | ||||
|  | ||||
|       // Initialize groups array if needed (should already be done above) | ||||
|       if (!ups.groups) { | ||||
|         ups.groups = []; | ||||
|       } | ||||
|  | ||||
|       // Toggle assignment | ||||
|       const groupIndex = ups.groups.indexOf(group.id); | ||||
|       if (groupIndex === -1) { | ||||
|         // Add to group | ||||
|         ups.groups.push(group.id); | ||||
|         logger.log(`Added to group: ${group.name} (${group.id})`); | ||||
|       } else { | ||||
|         // Remove from group | ||||
|         ups.groups.splice(groupIndex, 1); | ||||
|         logger.log(`Removed from group: ${group.name} (${group.id})`); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Assign UPS devices to a specific group | ||||
|    * @param groupId Group ID to assign UPS devices to | ||||
|    * @param config Full configuration | ||||
|    * @param prompt Function to prompt for user input | ||||
|    */ | ||||
|   public async assignUpsToGroup( | ||||
|     groupId: string, | ||||
|     config: any, | ||||
|     prompt: (question: string) => Promise<string>, | ||||
|   ): Promise<void> { | ||||
|     if (!config.upsDevices || config.upsDevices.length === 0) { | ||||
|       logger.log('No UPS devices available. Use "nupst add" to add UPS devices.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const group = config.groups.find((g: { id: string }) => g.id === groupId); | ||||
|     if (!group) { | ||||
|       logger.error(`Group with ID "${groupId}" not found.`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Show current assignments | ||||
|     logger.log(`\nUPS devices in group "${group.name}" (${group.id}):`); | ||||
|     const upsInGroup = config.upsDevices.filter((ups: { groups?: string[] }) => | ||||
|       ups.groups && ups.groups.includes(groupId) | ||||
|     ); | ||||
|     if (upsInGroup.length === 0) { | ||||
|       logger.log('- None'); | ||||
|     } else { | ||||
|       for (const ups of upsInGroup) { | ||||
|         logger.log(`- ${ups.name} (${ups.id})`); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Show all UPS devices | ||||
|     logger.log('\nAvailable UPS devices:'); | ||||
|     for (let i = 0; i < config.upsDevices.length; i++) { | ||||
|       const ups = config.upsDevices[i]; | ||||
|       const assigned = ups.groups && ups.groups.includes(groupId); | ||||
|       logger.log(`${i + 1}) ${ups.name} (${ups.id}) [${assigned ? 'Assigned' : 'Not Assigned'}]`); | ||||
|     } | ||||
|  | ||||
|     // Prompt for UPS selection | ||||
|     const selection = await prompt( | ||||
|       '\nSelect UPS devices to assign/unassign (comma-separated numbers, or "clear" to remove all): ', | ||||
|     ); | ||||
|  | ||||
|     if (selection.toLowerCase() === 'clear') { | ||||
|       // Clear all UPS from this group | ||||
|       for (const ups of config.upsDevices) { | ||||
|         if (ups.groups) { | ||||
|           const groupIndex = ups.groups.indexOf(groupId); | ||||
|           if (groupIndex !== -1) { | ||||
|             ups.groups.splice(groupIndex, 1); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       logger.log(`All UPS devices removed from group "${group.name}".`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!selection.trim()) { | ||||
|       // No change if empty input | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Process selections | ||||
|     const selections = selection.split(',').map((s) => s.trim()); | ||||
|  | ||||
|     for (const sel of selections) { | ||||
|       const index = parseInt(sel, 10) - 1; | ||||
|       if (isNaN(index) || index < 0 || index >= config.upsDevices.length) { | ||||
|         logger.error(`Invalid selection: ${sel}`); | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       const ups = config.upsDevices[index]; | ||||
|  | ||||
|       // Initialize groups array if needed | ||||
|       if (!ups.groups) { | ||||
|         ups.groups = []; | ||||
|       } | ||||
|  | ||||
|       // Toggle assignment | ||||
|       const groupIndex = ups.groups.indexOf(groupId); | ||||
|       if (groupIndex === -1) { | ||||
|         // Add to group | ||||
|         ups.groups.push(groupId); | ||||
|         logger.log(`Added "${ups.name}" to group "${group.name}"`); | ||||
|       } else { | ||||
|         // Remove from group | ||||
|         ups.groups.splice(groupIndex, 1); | ||||
|         logger.log(`Removed "${ups.name}" from group "${group.name}"`); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										302
									
								
								ts/cli/service-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								ts/cli/service-handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,302 @@ | ||||
| import process from 'node:process'; | ||||
| import { execSync } from 'node:child_process'; | ||||
| import { Nupst } from '../nupst.ts'; | ||||
| import { logger } from '../logger.ts'; | ||||
|  | ||||
| /** | ||||
|  * Class for handling service-related CLI commands | ||||
|  * Provides interface for managing systemd service | ||||
|  */ | ||||
| export class ServiceHandler { | ||||
|   private readonly nupst: Nupst; | ||||
|  | ||||
|   /** | ||||
|    * Create a new Service handler | ||||
|    * @param nupst Reference to the main Nupst instance | ||||
|    */ | ||||
|   constructor(nupst: Nupst) { | ||||
|     this.nupst = nupst; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Enable the service (requires root) | ||||
|    */ | ||||
|   public async enable(): Promise<void> { | ||||
|     this.checkRootAccess('This command must be run as root.'); | ||||
|     await this.nupst.getSystemd().install(); | ||||
|     logger.log('NUPST service has been installed. Use "nupst start" to start the service.'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start the daemon directly | ||||
|    * @param debugMode Whether to enable debug mode | ||||
|    */ | ||||
|   public async daemonStart(debugMode: boolean = false): Promise<void> { | ||||
|     logger.log('Starting NUPST daemon...'); | ||||
|     try { | ||||
|       // Enable debug mode for SNMP if requested | ||||
|       if (debugMode) { | ||||
|         this.nupst.getSnmp().enableDebug(); | ||||
|         logger.log('SNMP debug mode enabled'); | ||||
|       } | ||||
|       await this.nupst.getDaemon().start(); | ||||
|     } catch (error) { | ||||
|       // Error is already logged and process.exit is called in daemon.start() | ||||
|       // No need to handle it here | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Show logs of the systemd service | ||||
|    */ | ||||
|   public async logs(): Promise<void> { | ||||
|     try { | ||||
|       // Use exec with spawn to properly follow logs in real-time | ||||
|       const { spawn } = await import('child_process'); | ||||
|       logger.log('Tailing nupst service logs (Ctrl+C to exit)...\n'); | ||||
|  | ||||
|       const journalctl = spawn('journalctl', ['-u', 'nupst.service', '-n', '50', '-f'], { | ||||
|         stdio: ['ignore', 'inherit', 'inherit'], | ||||
|       }); | ||||
|  | ||||
|       // Forward signals to child process | ||||
|       process.on('SIGINT', () => { | ||||
|         journalctl.kill('SIGINT'); | ||||
|         process.exit(0); | ||||
|       }); | ||||
|  | ||||
|       // Wait for process to exit | ||||
|       await new Promise<void>((resolve) => { | ||||
|         journalctl.on('exit', () => resolve()); | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       logger.error(`Failed to retrieve logs: ${error}`); | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Stop the systemd service | ||||
|    */ | ||||
|   public async stop(): Promise<void> { | ||||
|     await this.nupst.getSystemd().stop(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start the systemd service | ||||
|    */ | ||||
|   public async start(): Promise<void> { | ||||
|     try { | ||||
|       await this.nupst.getSystemd().start(); | ||||
|     } catch (error) { | ||||
|       // Error will be displayed by systemd.start() | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Show status of the systemd service and UPS | ||||
|    */ | ||||
|   public async status(): Promise<void> { | ||||
|     // Extract debug options from args array | ||||
|     const debugOptions = this.extractDebugOptions(process.argv); | ||||
|     await this.nupst.getSystemd().getStatus(debugOptions.debugMode); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Disable the service (requires root) | ||||
|    */ | ||||
|   public async disable(): Promise<void> { | ||||
|     this.checkRootAccess('This command must be run as root.'); | ||||
|     await this.nupst.getSystemd().disable(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if the user has root access | ||||
|    * @param errorMessage Error message to display if not root | ||||
|    */ | ||||
|   private checkRootAccess(errorMessage: string): void { | ||||
|     if (process.getuid && process.getuid() !== 0) { | ||||
|       logger.error(errorMessage); | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update NUPST from repository and refresh systemd service | ||||
|    */ | ||||
|   public async update(): Promise<void> { | ||||
|     try { | ||||
|       // Check if running as root | ||||
|       this.checkRootAccess( | ||||
|         'This command must be run as root to update NUPST.', | ||||
|       ); | ||||
|  | ||||
|       console.log(''); | ||||
|       logger.info('Checking for updates...'); | ||||
|  | ||||
|       try { | ||||
|         // Get current version | ||||
|         const currentVersion = this.nupst.getVersion(); | ||||
|  | ||||
|         // Fetch latest version from Gitea API | ||||
|         const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/latest'; | ||||
|         const response = execSync(`curl -sSL ${apiUrl}`).toString(); | ||||
|         const release = JSON.parse(response); | ||||
|         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}`; | ||||
|  | ||||
|         logger.dim(`Current version: ${normalizedCurrent}`); | ||||
|         logger.dim(`Latest version:  ${normalizedLatest}`); | ||||
|         console.log(''); | ||||
|  | ||||
|         // Compare normalized versions | ||||
|         if (normalizedCurrent === normalizedLatest) { | ||||
|           logger.success('Already up to date!'); | ||||
|           console.log(''); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         logger.info(`New version available: ${latestVersion}`); | ||||
|         logger.dim('Downloading and installing...'); | ||||
|         console.log(''); | ||||
|  | ||||
|         // Download and run the install script | ||||
|         // This handles everything: download binary, stop service, replace, restart | ||||
|         const installUrl = 'https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh'; | ||||
|  | ||||
|         execSync(`curl -sSL ${installUrl} | bash`, { | ||||
|           stdio: 'inherit', // Show install script output to user | ||||
|         }); | ||||
|  | ||||
|         console.log(''); | ||||
|         logger.success(`Updated to ${latestVersion}`); | ||||
|         console.log(''); | ||||
|       } catch (error) { | ||||
|         console.log(''); | ||||
|         logger.error('Update failed'); | ||||
|         logger.dim(`${error instanceof Error ? error.message : String(error)}`); | ||||
|         console.log(''); | ||||
|         process.exit(1); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       logger.error(`Update failed: ${error instanceof Error ? error.message : String(error)}`); | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Completely uninstall NUPST from the system | ||||
|    */ | ||||
|   public async uninstall(): Promise<void> { | ||||
|     // Check if running as root | ||||
|     this.checkRootAccess('This command must be run as root.'); | ||||
|  | ||||
|     try { | ||||
|       // Import readline module for user input | ||||
|       const readline = await import('readline'); | ||||
|  | ||||
|       const rl = readline.createInterface({ | ||||
|         input: process.stdin, | ||||
|         output: process.stdout, | ||||
|       }); | ||||
|  | ||||
|       // Helper function to prompt for input | ||||
|       const prompt = (question: string): Promise<string> => { | ||||
|         return new Promise((resolve) => { | ||||
|           rl.question(question, (answer: string) => { | ||||
|             resolve(answer); | ||||
|           }); | ||||
|         }); | ||||
|       }; | ||||
|  | ||||
|       logger.log(''); | ||||
|       logger.highlight('NUPST Uninstaller'); | ||||
|       logger.dim('==============='); | ||||
|       logger.log('This will completely remove NUPST from your system.'); | ||||
|       logger.log(''); | ||||
|  | ||||
|       // Ask about removing configuration | ||||
|       const removeConfig = await prompt( | ||||
|         'Do you want to remove the NUPST configuration files? (y/N): ', | ||||
|       ); | ||||
|  | ||||
|       // Find the uninstall.sh script location | ||||
|       let uninstallScriptPath: string; | ||||
|  | ||||
|       // Try to determine script location based on executable path | ||||
|       try { | ||||
|         // For ESM, we can use import.meta.url, but since we might be in CJS | ||||
|         // we'll use a more reliable approach based on process.argv[1] | ||||
|         const binPath = process.argv[1]; | ||||
|         const { dirname, join } = await import('path'); | ||||
|         const modulePath = dirname(dirname(binPath)); | ||||
|         uninstallScriptPath = join(modulePath, 'uninstall.sh'); | ||||
|  | ||||
|         // Check if the script exists | ||||
|         const { access } = await import('fs/promises'); | ||||
|         await access(uninstallScriptPath); | ||||
|       } catch (error) { | ||||
|         // If we can't find it in the expected location, try common installation paths | ||||
|         const commonPaths = ['/opt/nupst/uninstall.sh', `${process.cwd()}/uninstall.sh`]; | ||||
|         const { existsSync } = await import('fs'); | ||||
|  | ||||
|         uninstallScriptPath = ''; | ||||
|         for (const path of commonPaths) { | ||||
|           if (existsSync(path)) { | ||||
|             uninstallScriptPath = path; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         if (!uninstallScriptPath) { | ||||
|           logger.error('Could not locate uninstall.sh script. Aborting uninstall.'); | ||||
|           rl.close(); | ||||
|           process.stdin.destroy(); | ||||
|           process.exit(1); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // Close readline before executing script | ||||
|       rl.close(); | ||||
|       process.stdin.destroy(); | ||||
|  | ||||
|       // Execute uninstall.sh with the appropriate option | ||||
|       logger.log(''); | ||||
|       logger.log(`Running uninstaller from ${uninstallScriptPath}...`); | ||||
|  | ||||
|       // Pass the configuration removal option as an environment variable | ||||
|       const env = { | ||||
|         ...process.env, | ||||
|         REMOVE_CONFIG: removeConfig.toLowerCase() === 'y' ? 'yes' : 'no', | ||||
|         REMOVE_REPO: 'yes', // Always remove repo as requested | ||||
|         NUPST_CLI_CALL: 'true', // Flag to indicate this is being called from CLI | ||||
|       }; | ||||
|  | ||||
|       // Run the uninstall script with sudo | ||||
|       execSync(`sudo bash ${uninstallScriptPath}`, { | ||||
|         env, | ||||
|         stdio: 'inherit', // Show output in the terminal | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`); | ||||
|       process.exit(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extract and remove debug options from args array | ||||
|    * @param args Command line arguments | ||||
|    * @returns Object with debug flags and cleaned args | ||||
|    */ | ||||
|   private extractDebugOptions(args: string[]): { debugMode: boolean; cleanedArgs: string[] } { | ||||
|     const debugMode = args.includes('--debug') || args.includes('-d'); | ||||
|     // Remove debug flags from args | ||||
|     const cleanedArgs = args.filter((arg) => arg !== '--debug' && arg !== '-d'); | ||||
|  | ||||
|     return { debugMode, cleanedArgs }; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1134
									
								
								ts/cli/ups-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1134
									
								
								ts/cli/ups-handler.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										88
									
								
								ts/colors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								ts/colors.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| /** | ||||
|  * Color theme and styling utilities for NUPST CLI | ||||
|  * Uses Deno standard library colors module | ||||
|  */ | ||||
| import * as colors from '@std/fmt/colors'; | ||||
|  | ||||
| /** | ||||
|  * Color theme for consistent CLI styling | ||||
|  */ | ||||
| export const theme = { | ||||
|   // Message types | ||||
|   error: colors.red, | ||||
|   warning: colors.yellow, | ||||
|   success: colors.green, | ||||
|   info: colors.cyan, | ||||
|   dim: colors.dim, | ||||
|   highlight: colors.bold, | ||||
|  | ||||
|   // Status indicators | ||||
|   statusActive: (text: string) => colors.green(colors.bold(text)), | ||||
|   statusInactive: (text: string) => colors.red(text), | ||||
|   statusWarning: (text: string) => colors.yellow(text), | ||||
|   statusUnknown: (text: string) => colors.dim(text), | ||||
|  | ||||
|   // Battery level colors | ||||
|   batteryGood: colors.green, // > 60% | ||||
|   batteryMedium: colors.yellow, // 30-60% | ||||
|   batteryCritical: colors.red, // < 30% | ||||
|  | ||||
|   // Box borders | ||||
|   borderSuccess: colors.green, | ||||
|   borderError: colors.red, | ||||
|   borderWarning: colors.yellow, | ||||
|   borderInfo: colors.cyan, | ||||
|   borderDefault: (text: string) => text, // No color | ||||
|  | ||||
|   // Command/code highlighting | ||||
|   command: colors.cyan, | ||||
|   code: colors.dim, | ||||
|   path: colors.blue, | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Status symbols with colors | ||||
|  */ | ||||
| export const symbols = { | ||||
|   success: colors.green('✓'), | ||||
|   error: colors.red('✗'), | ||||
|   warning: colors.yellow('⚠'), | ||||
|   info: colors.cyan('ℹ'), | ||||
|   running: colors.green('●'), | ||||
|   stopped: colors.red('○'), | ||||
|   starting: colors.yellow('◐'), | ||||
|   unknown: colors.dim('◯'), | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Get color for battery level | ||||
|  */ | ||||
| export function getBatteryColor(percentage: number): (text: string) => string { | ||||
|   if (percentage >= 60) return theme.batteryGood; | ||||
|   if (percentage >= 30) return theme.batteryMedium; | ||||
|   return theme.batteryCritical; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get color for runtime remaining | ||||
|  */ | ||||
| export function getRuntimeColor(minutes: number): (text: string) => string { | ||||
|   if (minutes >= 20) return theme.batteryGood; | ||||
|   if (minutes >= 10) return theme.batteryMedium; | ||||
|   return theme.batteryCritical; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Format UPS power status with color | ||||
|  */ | ||||
| export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string { | ||||
|   switch (status) { | ||||
|     case 'online': | ||||
|       return theme.success('Online'); | ||||
|     case 'onBattery': | ||||
|       return theme.warning('On Battery'); | ||||
|     case 'unknown': | ||||
|     default: | ||||
|       return theme.dim('Unknown'); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1096
									
								
								ts/daemon.ts
									
									
									
									
									
								
							
							
						
						
									
										1096
									
								
								ts/daemon.ts
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1
									
								
								ts/helpers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								ts/helpers/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export * from './shortid.ts'; | ||||
							
								
								
									
										22
									
								
								ts/helpers/shortid.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								ts/helpers/shortid.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| /** | ||||
|  * Generate a short unique ID of 6 alphanumeric characters | ||||
|  * @returns A 6-character alphanumeric string | ||||
|  */ | ||||
| export function shortId(): string { | ||||
|   // Define the character set: a-z, A-Z, 0-9 | ||||
|   const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; | ||||
|  | ||||
|   // Generate cryptographically secure random values | ||||
|   const randomValues = new Uint8Array(6); | ||||
|   crypto.getRandomValues(randomValues); | ||||
|  | ||||
|   // Map each random value to a character in our set | ||||
|   let result = ''; | ||||
|   for (let i = 0; i < 6; i++) { | ||||
|     // Use modulo to map the random byte to a character index | ||||
|     const index = randomValues[i] % chars.length; | ||||
|     result += chars[index]; | ||||
|   } | ||||
|  | ||||
|   return result; | ||||
| } | ||||
							
								
								
									
										113
									
								
								ts/http-server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								ts/http-server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| import * as http from 'node:http'; | ||||
| import { URL } from 'node:url'; | ||||
| import { logger } from './logger.ts'; | ||||
| import type { IUpsStatus } from './daemon.ts'; | ||||
|  | ||||
| /** | ||||
|  * HTTP Server for exposing UPS status as JSON | ||||
|  * Serves cached data from the daemon's monitoring loop | ||||
|  */ | ||||
| export class NupstHttpServer { | ||||
|   private server?: http.Server; | ||||
|   private port: number; | ||||
|   private path: string; | ||||
|   private authToken: string; | ||||
|   private getUpsStatus: () => Map<string, IUpsStatus>; | ||||
|  | ||||
|   /** | ||||
|    * Create a new HTTP server instance | ||||
|    * @param port Port to listen on | ||||
|    * @param path URL path for the endpoint | ||||
|    * @param authToken Authentication token required for access | ||||
|    * @param getUpsStatus Function to retrieve cached UPS status | ||||
|    */ | ||||
|   constructor( | ||||
|     port: number, | ||||
|     path: string, | ||||
|     authToken: string, | ||||
|     getUpsStatus: () => Map<string, IUpsStatus> | ||||
|   ) { | ||||
|     this.port = port; | ||||
|     this.path = path; | ||||
|     this.authToken = authToken; | ||||
|     this.getUpsStatus = getUpsStatus; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Verify authentication token from request | ||||
|    * Supports both Bearer token in Authorization header and token query parameter | ||||
|    * @param req HTTP request | ||||
|    * @returns True if authenticated, false otherwise | ||||
|    */ | ||||
|   private isAuthenticated(req: http.IncomingMessage): boolean { | ||||
|     // Check Authorization header (Bearer token) | ||||
|     const authHeader = req.headers.authorization; | ||||
|     if (authHeader?.startsWith('Bearer ')) { | ||||
|       const token = authHeader.substring(7); | ||||
|       return token === this.authToken; | ||||
|     } | ||||
|  | ||||
|     // Check token query parameter | ||||
|     if (req.url) { | ||||
|       const url = new URL(req.url, `http://localhost:${this.port}`); | ||||
|       const tokenParam = url.searchParams.get('token'); | ||||
|       return tokenParam === this.authToken; | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start the HTTP server | ||||
|    */ | ||||
|   public start(): void { | ||||
|     this.server = http.createServer((req, res) => { | ||||
|       // Parse URL | ||||
|       const reqUrl = new URL(req.url || '/', `http://localhost:${this.port}`); | ||||
|  | ||||
|       if (reqUrl.pathname === this.path && req.method === 'GET') { | ||||
|         // Check authentication | ||||
|         if (!this.isAuthenticated(req)) { | ||||
|           res.writeHead(401, { | ||||
|             'Content-Type': 'application/json', | ||||
|             'WWW-Authenticate': 'Bearer' | ||||
|           }); | ||||
|           res.end(JSON.stringify({ error: 'Unauthorized' })); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Get cached status (no refresh) | ||||
|         const statusMap = this.getUpsStatus(); | ||||
|         const statusArray = Array.from(statusMap.values()); | ||||
|  | ||||
|         res.writeHead(200, { | ||||
|           'Content-Type': 'application/json', | ||||
|           'Cache-Control': 'no-cache' | ||||
|         }); | ||||
|         res.end(JSON.stringify(statusArray, null, 2)); | ||||
|       } else { | ||||
|         res.writeHead(404, { 'Content-Type': 'application/json' }); | ||||
|         res.end(JSON.stringify({ error: 'Not Found' })); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     this.server.listen(this.port, () => { | ||||
|       logger.success(`HTTP server started on port ${this.port} at ${this.path}`); | ||||
|     }); | ||||
|  | ||||
|     this.server.on('error', (error: any) => { | ||||
|       logger.error(`HTTP server error: ${error.message}`); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Stop the HTTP server | ||||
|    */ | ||||
|   public stop(): void { | ||||
|     if (this.server) { | ||||
|       this.server.close(() => { | ||||
|         logger.log('HTTP server stopped'); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,6 +1,8 @@ | ||||
| #!/usr/bin/env node | ||||
|  | ||||
| import { NupstCli } from './cli.js'; | ||||
| import { NupstCli } from './cli.ts'; | ||||
| import { logger } from './logger.ts'; | ||||
| import process from 'node:process'; | ||||
|  | ||||
| /** | ||||
|  * Main entry point for NUPST | ||||
| @@ -12,7 +14,7 @@ async function main() { | ||||
| } | ||||
|  | ||||
| // Run the main function and handle any errors | ||||
| main().catch(error => { | ||||
|   console.error('Error:', error); | ||||
| main().catch((error) => { | ||||
|   logger.error(`Error: ${error}`); | ||||
|   process.exit(1); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										333
									
								
								ts/logger.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										333
									
								
								ts/logger.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,333 @@ | ||||
| import { theme, symbols } from './colors.ts'; | ||||
|  | ||||
| /** | ||||
|  * Table column alignment options | ||||
|  */ | ||||
| export type TColumnAlign = 'left' | 'right' | 'center'; | ||||
|  | ||||
| /** | ||||
|  * Table column definition | ||||
|  */ | ||||
| export interface ITableColumn { | ||||
|   /** Column header text */ | ||||
|   header: string; | ||||
|   /** Column key in data object */ | ||||
|   key: string; | ||||
|   /** Column alignment (default: left) */ | ||||
|   align?: TColumnAlign; | ||||
|   /** Column width (auto-calculated if not specified) */ | ||||
|   width?: number; | ||||
|   /** Color function to apply to cell values */ | ||||
|   color?: (value: string) => string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Box style types with colors | ||||
|  */ | ||||
| export type TBoxStyle = 'default' | 'success' | 'error' | 'warning' | 'info'; | ||||
|  | ||||
| /** | ||||
|  * A simple logger class that provides consistent formatting for log messages | ||||
|  * including support for logboxes with title, lines, and closing | ||||
|  */ | ||||
| export class Logger { | ||||
|   private currentBoxWidth: number | null = null; | ||||
|   private currentBoxStyle: TBoxStyle = 'default'; | ||||
|   private static instance: Logger; | ||||
|  | ||||
|   /** Default width to use when no width is specified */ | ||||
|   private readonly DEFAULT_WIDTH = 60; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new Logger instance | ||||
|    */ | ||||
|   constructor() { | ||||
|     this.currentBoxWidth = null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the singleton logger instance | ||||
|    * @returns The singleton logger instance | ||||
|    */ | ||||
|   public static getInstance(): Logger { | ||||
|     if (!Logger.instance) { | ||||
|       Logger.instance = new Logger(); | ||||
|     } | ||||
|     return Logger.instance; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a message | ||||
|    * @param message Message to log | ||||
|    */ | ||||
|   public log(message: string): void { | ||||
|     console.log(message); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log an error message (red with ✗ symbol) | ||||
|    * @param message Error message to log | ||||
|    */ | ||||
|   public error(message: string): void { | ||||
|     console.error(`${symbols.error} ${theme.error(message)}`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a warning message (yellow with ⚠ symbol) | ||||
|    * @param message Warning message to log | ||||
|    */ | ||||
|   public warn(message: string): void { | ||||
|     console.warn(`${symbols.warning} ${theme.warning(message)}`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a success message (green with ✓ symbol) | ||||
|    * @param message Success message to log | ||||
|    */ | ||||
|   public success(message: string): void { | ||||
|     console.log(`${symbols.success} ${theme.success(message)}`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log an info message (cyan with ℹ symbol) | ||||
|    * @param message Info message to log | ||||
|    */ | ||||
|   public info(message: string): void { | ||||
|     console.log(`${symbols.info} ${theme.info(message)}`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a dim/secondary message | ||||
|    * @param message Message to log in dim style | ||||
|    */ | ||||
|   public dim(message: string): void { | ||||
|     console.log(theme.dim(message)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a highlighted/bold message | ||||
|    * @param message Message to highlight | ||||
|    */ | ||||
|   public highlight(message: string): void { | ||||
|     console.log(theme.highlight(message)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get color function for box based on style | ||||
|    */ | ||||
|   private getBoxColor(style: TBoxStyle): (text: string) => string { | ||||
|     switch (style) { | ||||
|       case 'success': | ||||
|         return theme.borderSuccess; | ||||
|       case 'error': | ||||
|         return theme.borderError; | ||||
|       case 'warning': | ||||
|         return theme.borderWarning; | ||||
|       case 'info': | ||||
|         return theme.borderInfo; | ||||
|       case 'default': | ||||
|       default: | ||||
|         return theme.borderDefault; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a logbox title and set the current box width | ||||
|    * @param title Title of the logbox | ||||
|    * @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH | ||||
|    * @param style Box style for coloring (default, success, error, warning, info) | ||||
|    */ | ||||
|   public logBoxTitle(title: string, width?: number, style?: TBoxStyle): void { | ||||
|     this.currentBoxWidth = width || this.DEFAULT_WIDTH; | ||||
|     this.currentBoxStyle = style || 'default'; | ||||
|  | ||||
|     const colorFn = this.getBoxColor(this.currentBoxStyle); | ||||
|  | ||||
|     // Create the title line with appropriate padding | ||||
|     const paddedTitle = ` ${title} `; | ||||
|     const remainingSpace = this.currentBoxWidth - 3 - paddedTitle.length; | ||||
|  | ||||
|     // Title line: ┌─ Title ───┐ | ||||
|     const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}┐`; | ||||
|  | ||||
|     console.log(colorFn(titleLine)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a logbox line | ||||
|    * @param content Content of the line | ||||
|    * @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH. | ||||
|    */ | ||||
|   public logBoxLine(content: string, width?: number): void { | ||||
|     if (!this.currentBoxWidth && !width) { | ||||
|       // No current width and no width provided, use default width | ||||
|       this.logBoxTitle('', this.DEFAULT_WIDTH); | ||||
|     } | ||||
|  | ||||
|     const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH; | ||||
|     const colorFn = this.getBoxColor(this.currentBoxStyle); | ||||
|  | ||||
|     // Calculate the available space for content (use visible length) | ||||
|     const availableSpace = boxWidth - 2; // Account for left and right borders | ||||
|     const visibleLen = this.visibleLength(content); | ||||
|  | ||||
|     if (visibleLen <= availableSpace - 1) { | ||||
|       // If content fits with at least one space for the right border stripe | ||||
|       const padding = availableSpace - visibleLen - 1; | ||||
|       const line = `│ ${content}${' '.repeat(padding)}│`; | ||||
|       console.log(colorFn(line)); | ||||
|     } else { | ||||
|       // Content is too long, let it flow out of boundaries. | ||||
|       const line = `│ ${content}`; | ||||
|       console.log(colorFn(line)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a logbox end | ||||
|    * @param width Optional width override. If not provided, uses the current box width or DEFAULT_WIDTH. | ||||
|    */ | ||||
|   public logBoxEnd(width?: number): void { | ||||
|     const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH; | ||||
|     const colorFn = this.getBoxColor(this.currentBoxStyle); | ||||
|  | ||||
|     // Create the bottom border: └────────┘ | ||||
|     const bottomLine = `└${'─'.repeat(boxWidth - 2)}┘`; | ||||
|     console.log(colorFn(bottomLine)); | ||||
|  | ||||
|     // Reset the current box width and style | ||||
|     this.currentBoxWidth = null; | ||||
|     this.currentBoxStyle = 'default'; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a complete logbox with title, content lines, and ending | ||||
|    * @param title Title of the logbox | ||||
|    * @param lines Array of content lines | ||||
|    * @param width Width of the logbox, defaults to DEFAULT_WIDTH | ||||
|    * @param style Box style for coloring | ||||
|    */ | ||||
|   public logBox(title: string, lines: string[], width?: number, style?: TBoxStyle): void { | ||||
|     this.logBoxTitle(title, width || this.DEFAULT_WIDTH, style); | ||||
|  | ||||
|     for (const line of lines) { | ||||
|       this.logBoxLine(line); | ||||
|     } | ||||
|  | ||||
|     this.logBoxEnd(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a divider line | ||||
|    * @param width Width of the divider, defaults to DEFAULT_WIDTH | ||||
|    * @param character Character to use for the divider (default: ─) | ||||
|    */ | ||||
|   public logDivider(width?: number, character: string = '─'): void { | ||||
|     console.log(character.repeat(width || this.DEFAULT_WIDTH)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Strip ANSI color codes from string for accurate length calculation | ||||
|    */ | ||||
|   private stripAnsi(text: string): string { | ||||
|     // Remove ANSI escape codes | ||||
|     return text.replace(/\x1b\[[0-9;]*m/g, ''); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get visible length of string (excluding ANSI codes) | ||||
|    */ | ||||
|   private visibleLength(text: string): number { | ||||
|     return this.stripAnsi(text).length; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Align text within a column (handles ANSI color codes correctly) | ||||
|    */ | ||||
|   private alignText(text: string, width: number, align: TColumnAlign = 'left'): string { | ||||
|     const visibleLen = this.visibleLength(text); | ||||
|  | ||||
|     if (visibleLen >= width) { | ||||
|       // Text is too long, truncate the visible part | ||||
|       const stripped = this.stripAnsi(text); | ||||
|       return stripped.substring(0, width); | ||||
|     } | ||||
|  | ||||
|     const padding = width - visibleLen; | ||||
|  | ||||
|     switch (align) { | ||||
|       case 'right': | ||||
|         return ' '.repeat(padding) + text; | ||||
|       case 'center': { | ||||
|         const leftPad = Math.floor(padding / 2); | ||||
|         const rightPad = padding - leftPad; | ||||
|         return ' '.repeat(leftPad) + text + ' '.repeat(rightPad); | ||||
|       } | ||||
|       case 'left': | ||||
|       default: | ||||
|         return text + ' '.repeat(padding); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Log a formatted table | ||||
|    * @param columns Column definitions | ||||
|    * @param rows Array of data objects | ||||
|    * @param title Optional table title | ||||
|    */ | ||||
|   public logTable(columns: ITableColumn[], rows: Record<string, string>[], title?: string): void { | ||||
|     if (rows.length === 0) { | ||||
|       this.dim('No data to display'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Calculate column widths | ||||
|     const columnWidths = columns.map((col) => { | ||||
|       if (col.width) return col.width; | ||||
|  | ||||
|       // Auto-calculate width based on header and data (use visible length) | ||||
|       let maxWidth = this.visibleLength(col.header); | ||||
|       for (const row of rows) { | ||||
|         const value = String(row[col.key] || ''); | ||||
|         maxWidth = Math.max(maxWidth, this.visibleLength(value)); | ||||
|       } | ||||
|       return maxWidth; | ||||
|     }); | ||||
|  | ||||
|     // Calculate total table width | ||||
|     const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + (columns.length * 3) + 1; | ||||
|  | ||||
|     // Print title if provided | ||||
|     if (title) { | ||||
|       this.logBoxTitle(title, totalWidth); | ||||
|     } else { | ||||
|       // Print top border | ||||
|       console.log('┌' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐'); | ||||
|     } | ||||
|  | ||||
|     // Print header row | ||||
|     const headerCells = columns.map((col, i) => | ||||
|       theme.highlight(this.alignText(col.header, columnWidths[i], col.align)) | ||||
|     ); | ||||
|     console.log('│ ' + headerCells.join(' │ ') + ' │'); | ||||
|  | ||||
|     // Print separator | ||||
|     console.log('├' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤'); | ||||
|  | ||||
|     // Print data rows | ||||
|     for (const row of rows) { | ||||
|       const cells = columns.map((col, i) => { | ||||
|         const value = String(row[col.key] || ''); | ||||
|         const aligned = this.alignText(value, columnWidths[i], col.align); | ||||
|         return col.color ? col.color(aligned) : aligned; | ||||
|       }); | ||||
|       console.log('│ ' + cells.join(' │ ') + ' │'); | ||||
|     } | ||||
|  | ||||
|     // Print bottom border | ||||
|     console.log('└' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Export a singleton instance for easy use | ||||
| export const logger = Logger.getInstance(); | ||||
							
								
								
									
										67
									
								
								ts/migrations/base-migration.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								ts/migrations/base-migration.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| /** | ||||
|  * Abstract base class for configuration migrations | ||||
|  * | ||||
|  * Each migration represents an upgrade from one config version to another. | ||||
|  * Migrations run in order based on the `order` field, allowing users to jump | ||||
|  * multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4). | ||||
|  */ | ||||
| /** | ||||
|  * Abstract base class for configuration migrations | ||||
|  * | ||||
|  * Each migration represents an upgrade from one config version to another. | ||||
|  * Migrations run in order based on the `toVersion` field, allowing users to jump | ||||
|  * multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4). | ||||
|  */ | ||||
| export abstract class BaseMigration { | ||||
|   /** | ||||
|    * Source version this migration upgrades from | ||||
|    * e.g., "1.x", "3.x" | ||||
|    */ | ||||
|   abstract readonly fromVersion: string; | ||||
|  | ||||
|   /** | ||||
|    * Target version this migration upgrades to | ||||
|    * e.g., "2.0", "4.0", "4.1" | ||||
|    */ | ||||
|   abstract readonly toVersion: string; | ||||
|  | ||||
|   /** | ||||
|    * Check if this migration should run on the given config | ||||
|    * | ||||
|    * @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>; | ||||
|  | ||||
|   /** | ||||
|    * Perform the migration on the given config | ||||
|    * | ||||
|    * @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>>; | ||||
|  | ||||
|   /** | ||||
|    * Get human-readable name for this migration | ||||
|    * | ||||
|    * @returns Migration name | ||||
|    */ | ||||
|   getName(): string { | ||||
|     return `Migration ${this.fromVersion} → ${this.toVersion}`; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Parse version string into a comparable number | ||||
|    * Supports formats like "2.0", "4.1", etc. | ||||
|    * Returns a number like 2.0, 4.1 for sorting | ||||
|    * | ||||
|    * @returns Parsed version number for ordering | ||||
|    */ | ||||
|   getVersionOrder(): number { | ||||
|     const parsed = parseFloat(this.toVersion); | ||||
|     if (isNaN(parsed)) { | ||||
|       throw new Error(`Invalid version format: ${this.toVersion}`); | ||||
|     } | ||||
|     return parsed; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										11
									
								
								ts/migrations/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								ts/migrations/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| /** | ||||
|  * Configuration migrations module | ||||
|  * | ||||
|  * Exports the migration system for upgrading configs between versions. | ||||
|  */ | ||||
|  | ||||
| export { BaseMigration } from './base-migration.ts'; | ||||
| export { MigrationRunner } from './migration-runner.ts'; | ||||
| export { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; | ||||
| export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; | ||||
| export { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; | ||||
							
								
								
									
										75
									
								
								ts/migrations/migration-runner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								ts/migrations/migration-runner.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| import { BaseMigration } from './base-migration.ts'; | ||||
| import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; | ||||
| import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; | ||||
| import { MigrationV4_0ToV4_1 } from './migration-v4.0-to-v4.1.ts'; | ||||
| import { logger } from '../logger.ts'; | ||||
|  | ||||
| /** | ||||
|  * Migration runner | ||||
|  * | ||||
|  * Discovers all available migrations, sorts them by order, | ||||
|  * and runs applicable migrations in sequence. | ||||
|  */ | ||||
| export class MigrationRunner { | ||||
|   private migrations: BaseMigration[]; | ||||
|  | ||||
|   constructor() { | ||||
|     // Register all migrations here | ||||
|     this.migrations = [ | ||||
|       new MigrationV1ToV2(), | ||||
|       new MigrationV3ToV4(), | ||||
|       new MigrationV4_0ToV4_1(), | ||||
|       // Add future migrations here (v4.3, v4.4, etc.) | ||||
|     ]; | ||||
|  | ||||
|     // Sort by version order to ensure they run in sequence | ||||
|     this.migrations.sort((a, b) => a.getVersionOrder() - b.getVersionOrder()); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Run all applicable migrations on the config | ||||
|    * | ||||
|    * @param config - Raw configuration object to migrate | ||||
|    * @returns Migrated configuration and whether migrations ran | ||||
|    */ | ||||
|   async run( | ||||
|     config: Record<string, unknown>, | ||||
|   ): Promise<{ config: Record<string, unknown>; migrated: boolean }> { | ||||
|     let currentConfig = config; | ||||
|     let anyMigrationsRan = false; | ||||
|  | ||||
|     for (const migration of this.migrations) { | ||||
|       const shouldRun = await migration.shouldRun(currentConfig); | ||||
|  | ||||
|       if (shouldRun) { | ||||
|         // Only show "checking" message when we actually need to migrate | ||||
|         if (!anyMigrationsRan) { | ||||
|           logger.dim('Checking for required config migrations...'); | ||||
|         } | ||||
|         logger.info(`Running ${migration.getName()}...`); | ||||
|         currentConfig = await migration.migrate(currentConfig); | ||||
|         anyMigrationsRan = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (anyMigrationsRan) { | ||||
|       logger.success('Configuration migrations complete'); | ||||
|     } else { | ||||
|       logger.success('config format ok'); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       config: currentConfig, | ||||
|       migrated: anyMigrationsRan, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get all registered migrations | ||||
|    * | ||||
|    * @returns Array of all migrations sorted by order | ||||
|    */ | ||||
|   getMigrations(): BaseMigration[] { | ||||
|     return [...this.migrations]; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										55
									
								
								ts/migrations/migration-v1-to-v2.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								ts/migrations/migration-v1-to-v2.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| import { BaseMigration } from './base-migration.ts'; | ||||
| import { logger } from '../logger.ts'; | ||||
|  | ||||
| /** | ||||
|  * Migration from v1 (single SNMP config) to v2 (upsDevices array) | ||||
|  * | ||||
|  * Detects old format: | ||||
|  * { | ||||
|  *   snmp: { ... }, | ||||
|  *   thresholds: { ... }, | ||||
|  *   checkInterval: 30000 | ||||
|  * } | ||||
|  * | ||||
|  * Converts to: | ||||
|  * { | ||||
|  *   version: "2.0", | ||||
|  *   upsDevices: [{ id: "default", name: "Default UPS", snmp: ..., thresholds: ... }], | ||||
|  *   groups: [], | ||||
|  *   checkInterval: 30000 | ||||
|  * } | ||||
|  */ | ||||
| export class MigrationV1ToV2 extends BaseMigration { | ||||
|   readonly fromVersion = '1.x'; | ||||
|   readonly toVersion = '2.0'; | ||||
|  | ||||
|   async shouldRun(config: any): Promise<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> { | ||||
|     logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`); | ||||
|  | ||||
|     const migrated = { | ||||
|       version: this.toVersion, | ||||
|       upsDevices: [ | ||||
|         { | ||||
|           id: 'default', | ||||
|           name: 'Default UPS', | ||||
|           snmp: config.snmp, | ||||
|           thresholds: config.thresholds || { | ||||
|             battery: 60, | ||||
|             runtime: 20, | ||||
|           }, | ||||
|           groups: [], | ||||
|         }, | ||||
|       ], | ||||
|       groups: [], | ||||
|       checkInterval: config.checkInterval || 30000, | ||||
|     }; | ||||
|  | ||||
|     logger.success(`${this.getName()}: Migration complete`); | ||||
|     return migrated; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										118
									
								
								ts/migrations/migration-v3-to-v4.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								ts/migrations/migration-v3-to-v4.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | ||||
| import { BaseMigration } from './base-migration.ts'; | ||||
| import { logger } from '../logger.ts'; | ||||
|  | ||||
| /** | ||||
|  * Migration from v3 (upsList) to v4 (upsDevices) | ||||
|  * | ||||
|  * Transforms v3 format with flat SNMP config: | ||||
|  * { | ||||
|  *   upsList: [ | ||||
|  *     { | ||||
|  *       id: "ups-1", | ||||
|  *       name: "UPS 1", | ||||
|  *       host: "192.168.1.1", | ||||
|  *       port: 161, | ||||
|  *       community: "public", | ||||
|  *       version: "1"  // string | ||||
|  *     } | ||||
|  *   ] | ||||
|  * } | ||||
|  * | ||||
|  * To v4 format with nested SNMP config: | ||||
|  * { | ||||
|  *   version: "4.0", | ||||
|  *   upsDevices: [ | ||||
|  *     { | ||||
|  *       id: "ups-1", | ||||
|  *       name: "UPS 1", | ||||
|  *       snmp: { | ||||
|  *         host: "192.168.1.1", | ||||
|  *         port: 161, | ||||
|  *         community: "public", | ||||
|  *         version: 1,  // number | ||||
|  *         timeout: 5000 | ||||
|  *       }, | ||||
|  *       thresholds: { battery: 60, runtime: 20 }, | ||||
|  *       groups: [] | ||||
|  *     } | ||||
|  *   ] | ||||
|  * } | ||||
|  */ | ||||
| export class MigrationV3ToV4 extends BaseMigration { | ||||
|   readonly fromVersion = '3.x'; | ||||
|   readonly toVersion = '4.0'; | ||||
|  | ||||
|   async shouldRun(config: any): Promise<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]; | ||||
|       // V3 has host at top level, v4 has it nested in snmp object | ||||
|       return !!firstDevice.host && !firstDevice.snmp; | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   async migrate(config: any): Promise<any> { | ||||
|     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; | ||||
|  | ||||
|     // Transform each UPS device from v3 flat structure to v4 nested structure | ||||
|     const transformedDevices = sourceDevices.map((device: any) => { | ||||
|       // Build SNMP config object | ||||
|       const snmpConfig: any = { | ||||
|         host: device.host, | ||||
|         port: device.port || 161, | ||||
|         version: typeof device.version === 'string' ? parseInt(device.version, 10) : device.version, | ||||
|         timeout: device.timeout || 5000, | ||||
|       }; | ||||
|  | ||||
|       // Add SNMPv1/v2c fields | ||||
|       if (device.community) { | ||||
|         snmpConfig.community = device.community; | ||||
|       } | ||||
|  | ||||
|       // Add SNMPv3 fields | ||||
|       if (device.securityLevel) snmpConfig.securityLevel = device.securityLevel; | ||||
|       if (device.username) snmpConfig.username = device.username; | ||||
|       if (device.authProtocol) snmpConfig.authProtocol = device.authProtocol; | ||||
|       if (device.authKey) snmpConfig.authKey = device.authKey; | ||||
|       if (device.privProtocol) snmpConfig.privProtocol = device.privProtocol; | ||||
|       if (device.privKey) snmpConfig.privKey = device.privKey; | ||||
|  | ||||
|       // Add UPS model if present | ||||
|       if (device.upsModel) snmpConfig.upsModel = device.upsModel; | ||||
|       if (device.customOIDs) snmpConfig.customOIDs = device.customOIDs; | ||||
|  | ||||
|       // Return v4 format with nested structure | ||||
|       return { | ||||
|         id: device.id, | ||||
|         name: device.name, | ||||
|         snmp: snmpConfig, | ||||
|         thresholds: device.thresholds || { | ||||
|           battery: 60, | ||||
|           runtime: 20, | ||||
|         }, | ||||
|         groups: device.groups || [], | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
|     const migrated = { | ||||
|       version: this.toVersion, | ||||
|       upsDevices: transformedDevices, | ||||
|       groups: config.groups || [], | ||||
|       checkInterval: config.checkInterval || 30000, | ||||
|     }; | ||||
|  | ||||
|     logger.success(`${this.getName()}: Migration complete (${transformedDevices.length} devices transformed)`); | ||||
|     return migrated; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										127
									
								
								ts/migrations/migration-v4.0-to-v4.1.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								ts/migrations/migration-v4.0-to-v4.1.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| import { BaseMigration } from './base-migration.ts'; | ||||
| import { logger } from '../logger.ts'; | ||||
|  | ||||
| /** | ||||
|  * Migration from v4.0 to v4.1 | ||||
|  * | ||||
|  * Major changes: | ||||
|  * 1. Moves thresholds from UPS level to action level | ||||
|  * 2. Creates default shutdown action for UPS devices that had thresholds | ||||
|  * 3. Adds empty actions array to UPS devices without actions | ||||
|  * 4. Adds empty actions array to groups | ||||
|  * | ||||
|  * Transforms v4.0 format (with UPS-level thresholds): | ||||
|  * { | ||||
|  *   version: "4.0", | ||||
|  *   upsDevices: [ | ||||
|  *     { | ||||
|  *       id: "ups-1", | ||||
|  *       name: "UPS 1", | ||||
|  *       snmp: {...}, | ||||
|  *       thresholds: { battery: 60, runtime: 20 },  // UPS-level | ||||
|  *       groups: [] | ||||
|  *     } | ||||
|  *   ] | ||||
|  * } | ||||
|  * | ||||
|  * To v4.1 format (with action-level thresholds): | ||||
|  * { | ||||
|  *   version: "4.1", | ||||
|  *   upsDevices: [ | ||||
|  *     { | ||||
|  *       id: "ups-1", | ||||
|  *       name: "UPS 1", | ||||
|  *       snmp: {...}, | ||||
|  *       groups: [], | ||||
|  *       actions: [  // Thresholds moved here | ||||
|  *         { | ||||
|  *           type: "shutdown", | ||||
|  *           thresholds: { battery: 60, runtime: 20 }, | ||||
|  *           triggerMode: "onlyThresholds", | ||||
|  *           shutdownDelay: 5 | ||||
|  *         } | ||||
|  *       ] | ||||
|  *     } | ||||
|  *   ] | ||||
|  * } | ||||
|  */ | ||||
| export class MigrationV4_0ToV4_1 extends BaseMigration { | ||||
|   readonly fromVersion = '4.0'; | ||||
|   readonly toVersion = '4.1'; | ||||
|  | ||||
|   async shouldRun(config: Record<string, unknown>): Promise<boolean> { | ||||
|     // Run if config is version 4.0 | ||||
|     if (config.version === '4.0') { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     // Also run if config has upsDevices with thresholds at UPS level (v4.0 format) | ||||
|     if (Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { | ||||
|       const firstDevice = config.upsDevices[0] as Record<string, unknown>; | ||||
|       // v4.0 has thresholds at UPS level, v4.1 has them in actions | ||||
|       return firstDevice.thresholds !== undefined; | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   async migrate(config: Record<string, unknown>): Promise<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`); | ||||
|  | ||||
|     // Migrate UPS devices | ||||
|     const devices = (config.upsDevices as Array<Record<string, unknown>>) || []; | ||||
|     const migratedDevices = devices.map((device) => { | ||||
|       const migrated: Record<string, unknown> = { | ||||
|         id: device.id, | ||||
|         name: device.name, | ||||
|         snmp: device.snmp, | ||||
|         groups: device.groups || [], | ||||
|       }; | ||||
|  | ||||
|       // If device has thresholds at UPS level, convert to shutdown action | ||||
|       const deviceThresholds = device.thresholds as { battery: number; runtime: number } | undefined; | ||||
|       if (deviceThresholds) { | ||||
|         migrated.actions = [ | ||||
|           { | ||||
|             type: 'shutdown', | ||||
|             thresholds: { | ||||
|               battery: deviceThresholds.battery, | ||||
|               runtime: deviceThresholds.runtime, | ||||
|             }, | ||||
|             triggerMode: 'onlyThresholds', // Preserve old behavior (only on threshold violation) | ||||
|             shutdownDelay: 5, // Default delay | ||||
|           }, | ||||
|         ]; | ||||
|         logger.dim( | ||||
|           `    → ${device.name}: Created shutdown action (battery: ${deviceThresholds.battery}%, runtime: ${deviceThresholds.runtime}min)`, | ||||
|         ); | ||||
|       } else { | ||||
|         // No thresholds, just add empty actions array | ||||
|         migrated.actions = device.actions || []; | ||||
|       } | ||||
|  | ||||
|       return migrated; | ||||
|     }); | ||||
|  | ||||
|     // Add actions to groups | ||||
|     const groups = (config.groups as Array<Record<string, unknown>>) || []; | ||||
|     const migratedGroups = groups.map((group) => ({ | ||||
|       ...group, | ||||
|       actions: group.actions || [], | ||||
|     })); | ||||
|  | ||||
|     const result = { | ||||
|       version: this.toVersion, | ||||
|       upsDevices: migratedDevices, | ||||
|       groups: migratedGroups, | ||||
|       checkInterval: config.checkInterval || 30000, | ||||
|     }; | ||||
|  | ||||
|     logger.success( | ||||
|       `${this.getName()}: Migration complete (${migratedDevices.length} devices, ${migratedGroups.length} groups updated)`, | ||||
|     ); | ||||
|     return result; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										154
									
								
								ts/nupst.ts
									
									
									
									
									
								
							
							
						
						
									
										154
									
								
								ts/nupst.ts
									
									
									
									
									
								
							| @@ -1,9 +1,14 @@ | ||||
| import { NupstSnmp } from './snmp.js'; | ||||
| import { NupstDaemon } from './daemon.js'; | ||||
| import { NupstSystemd } from './systemd.js'; | ||||
| import { commitinfo } from './00_commitinfo_data.js'; | ||||
| import { spawn } from 'child_process'; | ||||
| import * as https from 'https'; | ||||
| import { NupstSnmp } from './snmp/manager.ts'; | ||||
| import { NupstDaemon } from './daemon.ts'; | ||||
| import { NupstSystemd } from './systemd.ts'; | ||||
| import { commitinfo } from './00_commitinfo_data.ts'; | ||||
| import { logger } from './logger.ts'; | ||||
| import { UpsHandler } from './cli/ups-handler.ts'; | ||||
| import { GroupHandler } from './cli/group-handler.ts'; | ||||
| import { ServiceHandler } from './cli/service-handler.ts'; | ||||
| import { ActionHandler } from './cli/action-handler.ts'; | ||||
| import { FeatureHandler } from './cli/feature-handler.ts'; | ||||
| import * as https from 'node:https'; | ||||
|  | ||||
| /** | ||||
|  * Main Nupst class that coordinates all components | ||||
| @@ -13,6 +18,11 @@ export class Nupst { | ||||
|   private readonly snmp: NupstSnmp; | ||||
|   private readonly daemon: NupstDaemon; | ||||
|   private readonly systemd: NupstSystemd; | ||||
|   private readonly upsHandler: UpsHandler; | ||||
|   private readonly groupHandler: GroupHandler; | ||||
|   private readonly serviceHandler: ServiceHandler; | ||||
|   private readonly actionHandler: ActionHandler; | ||||
|   private readonly featureHandler: FeatureHandler; | ||||
|   private updateAvailable: boolean = false; | ||||
|   private latestVersion: string = ''; | ||||
|  | ||||
| @@ -20,10 +30,18 @@ export class Nupst { | ||||
|    * Create a new Nupst instance with all necessary components | ||||
|    */ | ||||
|   constructor() { | ||||
|     // Initialize core components | ||||
|     this.snmp = new NupstSnmp(); | ||||
|     this.snmp.setNupst(this); // Set up bidirectional reference | ||||
|     this.daemon = new NupstDaemon(this.snmp); | ||||
|     this.systemd = new NupstSystemd(this.daemon); | ||||
|  | ||||
|     // Initialize handlers | ||||
|     this.upsHandler = new UpsHandler(this); | ||||
|     this.groupHandler = new GroupHandler(this); | ||||
|     this.serviceHandler = new ServiceHandler(this); | ||||
|     this.actionHandler = new ActionHandler(this); | ||||
|     this.featureHandler = new FeatureHandler(this); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -46,7 +64,42 @@ export class Nupst { | ||||
|   public getSystemd(): NupstSystemd { | ||||
|     return this.systemd; | ||||
|   } | ||||
|    | ||||
|  | ||||
|   /** | ||||
|    * Get the UPS handler for UPS management | ||||
|    */ | ||||
|   public getUpsHandler(): UpsHandler { | ||||
|     return this.upsHandler; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the Group handler for group management | ||||
|    */ | ||||
|   public getGroupHandler(): GroupHandler { | ||||
|     return this.groupHandler; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the Service handler for service management | ||||
|    */ | ||||
|   public getServiceHandler(): ServiceHandler { | ||||
|     return this.serviceHandler; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the Action handler for action management | ||||
|    */ | ||||
|   public getActionHandler(): ActionHandler { | ||||
|     return this.actionHandler; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the Feature handler for feature management | ||||
|    */ | ||||
|   public getFeatureHandler(): FeatureHandler { | ||||
|     return this.featureHandler; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the current version of NUPST | ||||
|    * @returns The current version string | ||||
| @@ -54,7 +107,7 @@ export class Nupst { | ||||
|   public getVersion(): string { | ||||
|     return commitinfo.version; | ||||
|   } | ||||
|    | ||||
|  | ||||
|   /** | ||||
|    * Check if an update is available | ||||
|    * @returns Promise resolving to true if an update is available | ||||
| @@ -63,39 +116,41 @@ export class Nupst { | ||||
|     try { | ||||
|       const latestVersion = await this.getLatestVersion(); | ||||
|       const currentVersion = this.getVersion(); | ||||
|        | ||||
|  | ||||
|       // Compare versions | ||||
|       this.updateAvailable = this.compareVersions(latestVersion, currentVersion) > 0; | ||||
|       this.latestVersion = latestVersion; | ||||
|        | ||||
|  | ||||
|       return this.updateAvailable; | ||||
|     } catch (error) { | ||||
|       console.error(`Error checking for updates: ${error.message}`); | ||||
|       logger.error( | ||||
|         `Error checking for updates: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|  | ||||
|   /** | ||||
|    * Get update status information | ||||
|    * @returns Object with update status information | ||||
|    */ | ||||
|   public getUpdateStatus(): {  | ||||
|     currentVersion: string,  | ||||
|     latestVersion: string,  | ||||
|     updateAvailable: boolean  | ||||
|   public getUpdateStatus(): { | ||||
|     currentVersion: string; | ||||
|     latestVersion: string; | ||||
|     updateAvailable: boolean; | ||||
|   } { | ||||
|     return { | ||||
|       currentVersion: this.getVersion(), | ||||
|       latestVersion: this.latestVersion || this.getVersion(), | ||||
|       updateAvailable: this.updateAvailable | ||||
|       updateAvailable: this.updateAvailable, | ||||
|     }; | ||||
|   } | ||||
|    | ||||
|  | ||||
|   /** | ||||
|    * Get the latest version from npm registry | ||||
|    * @returns Promise resolving to the latest version string | ||||
|    */ | ||||
|   private async getLatestVersion(): Promise<string> { | ||||
|   private getLatestVersion(): Promise<string> { | ||||
|     return new Promise<string>((resolve, reject) => { | ||||
|       const options = { | ||||
|         hostname: 'registry.npmjs.org', | ||||
| @@ -103,17 +158,17 @@ export class Nupst { | ||||
|         method: 'GET', | ||||
|         headers: { | ||||
|           'Accept': 'application/json', | ||||
|           'User-Agent': `nupst/${this.getVersion()}` | ||||
|         } | ||||
|           'User-Agent': `nupst/${this.getVersion()}`, | ||||
|         }, | ||||
|       }; | ||||
|        | ||||
|  | ||||
|       const req = https.request(options, (res) => { | ||||
|         let data = ''; | ||||
|          | ||||
|  | ||||
|         res.on('data', (chunk) => { | ||||
|           data += chunk; | ||||
|         }); | ||||
|          | ||||
|  | ||||
|         res.on('end', () => { | ||||
|           try { | ||||
|             const response = JSON.parse(data); | ||||
| @@ -127,15 +182,15 @@ export class Nupst { | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|        | ||||
|  | ||||
|       req.on('error', (error) => { | ||||
|         reject(error); | ||||
|       }); | ||||
|        | ||||
|  | ||||
|       req.end(); | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|  | ||||
|   /** | ||||
|    * Compare two semantic version strings | ||||
|    * @param versionA First version | ||||
| @@ -143,47 +198,52 @@ export class Nupst { | ||||
|    * @returns -1 if versionA < versionB, 0 if equal, 1 if versionA > versionB | ||||
|    */ | ||||
|   private compareVersions(versionA: string, versionB: string): number { | ||||
|     const partsA = versionA.split('.').map(part => parseInt(part, 10)); | ||||
|     const partsB = versionB.split('.').map(part => parseInt(part, 10)); | ||||
|      | ||||
|     const partsA = versionA.split('.').map((part) => parseInt(part, 10)); | ||||
|     const partsB = versionB.split('.').map((part) => parseInt(part, 10)); | ||||
|  | ||||
|     for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { | ||||
|       const partA = i < partsA.length ? partsA[i] : 0; | ||||
|       const partB = i < partsB.length ? partsB[i] : 0; | ||||
|        | ||||
|  | ||||
|       if (partA > partB) return 1; | ||||
|       if (partA < partB) return -1; | ||||
|     } | ||||
|      | ||||
|  | ||||
|     return 0; // Versions are equal | ||||
|   } | ||||
|    | ||||
|  | ||||
|   /** | ||||
|    * Log the current version and update status | ||||
|    */ | ||||
|   public logVersionInfo(checkForUpdates: boolean = true): void { | ||||
|     const version = this.getVersion(); | ||||
|     console.log('┌─ NUPST Version ────────────────────────┐'); | ||||
|     console.log(`│ Current Version: ${version}`); | ||||
|      | ||||
|     const boxWidth = 45; | ||||
|  | ||||
|     logger.logBoxTitle('NUPST Version', boxWidth); | ||||
|     logger.logBoxLine(`Current Version: ${version}`); | ||||
|  | ||||
|     if (this.updateAvailable && this.latestVersion) { | ||||
|       console.log(`│ Update Available: ${this.latestVersion}`); | ||||
|       console.log('│ Run "sudo nupst update" to update'); | ||||
|       logger.logBoxLine(`Update Available: ${this.latestVersion}`); | ||||
|       logger.logBoxLine('Run "sudo nupst update" to update'); | ||||
|       logger.logBoxEnd(); | ||||
|     } else if (checkForUpdates) { | ||||
|       console.log('│ Checking for updates...'); | ||||
|       this.checkForUpdates().then(updateAvailable => { | ||||
|       logger.logBoxLine('Checking for updates...'); | ||||
|  | ||||
|       // We can't end the box yet since we're in an async operation | ||||
|       this.checkForUpdates().then((updateAvailable) => { | ||||
|         if (updateAvailable) { | ||||
|           console.log(`│ Update Available: ${this.latestVersion}`); | ||||
|           console.log('│ Run "sudo nupst update" to update'); | ||||
|           logger.logBoxLine(`Update Available: ${this.latestVersion}`); | ||||
|           logger.logBoxLine('Run "sudo nupst update" to update'); | ||||
|         } else { | ||||
|           console.log('│ You are running the latest version'); | ||||
|           logger.logBoxLine('You are running the latest version'); | ||||
|         } | ||||
|         console.log('└──────────────────────────────────────────┘'); | ||||
|         logger.logBoxEnd(); | ||||
|       }).catch(() => { | ||||
|         console.log('│ Could not check for updates'); | ||||
|         console.log('└──────────────────────────────────────────┘'); | ||||
|         logger.logBoxLine('Could not check for updates'); | ||||
|         logger.logBoxEnd(); | ||||
|       }); | ||||
|     } else { | ||||
|       console.log('└──────────────────────────────────────────┘'); | ||||
|       logger.logBoxEnd(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +0,0 @@ | ||||
| /** | ||||
|  * Re-export from the snmp module | ||||
|  * This file is kept for backward compatibility | ||||
|  */ | ||||
|  | ||||
| export * from './snmp/index.js'; | ||||
| @@ -1,98 +0,0 @@ | ||||
| /** | ||||
|  * SNMP encoding utilities | ||||
|  * Contains helper methods for encoding SNMP data | ||||
|  */ | ||||
| export class SnmpEncoder { | ||||
|   /** | ||||
|    * Convert OID string to array of integers | ||||
|    * @param oid OID string in dotted notation (e.g. "1.3.6.1.2.1") | ||||
|    * @returns Array of integers representing the OID | ||||
|    */ | ||||
|   public static oidToArray(oid: string): number[] { | ||||
|     return oid.split('.').map(n => parseInt(n, 10)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Encode an SNMP integer | ||||
|    * @param value Integer value to encode | ||||
|    * @returns Buffer containing the encoded integer | ||||
|    */ | ||||
|   public static encodeInteger(value: number): Buffer { | ||||
|     const buf = Buffer.alloc(4); | ||||
|     buf.writeInt32BE(value, 0); | ||||
|      | ||||
|     // Find first non-zero byte | ||||
|     let start = 0; | ||||
|     while (start < 3 && buf[start] === 0) { | ||||
|       start++; | ||||
|     } | ||||
|      | ||||
|     // Handle negative values | ||||
|     if (value < 0 && buf[start] === 0) { | ||||
|       start--; | ||||
|     } | ||||
|      | ||||
|     return buf.slice(start); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Encode an OID | ||||
|    * @param oid Array of integers representing the OID | ||||
|    * @returns Buffer containing the encoded OID | ||||
|    */ | ||||
|   public static encodeOID(oid: number[]): Buffer { | ||||
|     // First two numbers are encoded as 40*x+y | ||||
|     let encodedOid = Buffer.from([40 * (oid[0] || 0) + (oid[1] || 0)]); | ||||
|      | ||||
|     // Encode remaining numbers | ||||
|     for (let i = 2; i < oid.length; i++) { | ||||
|       const n = oid[i]; | ||||
|        | ||||
|       if (n < 128) { | ||||
|         // Simple case: number fits in one byte | ||||
|         encodedOid = Buffer.concat([encodedOid, Buffer.from([n])]); | ||||
|       } else { | ||||
|         // Number needs multiple bytes | ||||
|         const bytes = []; | ||||
|         let value = n; | ||||
|          | ||||
|         // Create bytes array in reverse order | ||||
|         do { | ||||
|           bytes.unshift(value & 0x7F); | ||||
|           value >>= 7; | ||||
|         } while (value > 0); | ||||
|          | ||||
|         // Set high bit on all but the last byte | ||||
|         for (let j = 0; j < bytes.length - 1; j++) { | ||||
|           bytes[j] |= 0x80; | ||||
|         } | ||||
|          | ||||
|         encodedOid = Buffer.concat([encodedOid, Buffer.from(bytes)]); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return encodedOid; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Decode an ASN.1 integer | ||||
|    * @param buffer Buffer containing the encoded integer | ||||
|    * @param offset Offset in the buffer | ||||
|    * @param length Length of the integer in bytes | ||||
|    * @returns Decoded integer value | ||||
|    */ | ||||
|   public static decodeInteger(buffer: Buffer, offset: number, length: number): number { | ||||
|     if (length === 1) { | ||||
|       return buffer[offset]; | ||||
|     } else if (length === 2) { | ||||
|       return buffer.readInt16BE(offset); | ||||
|     } else if (length === 3) { | ||||
|       return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2]; | ||||
|     } else if (length === 4) { | ||||
|       return buffer.readInt32BE(offset); | ||||
|     } else { | ||||
|       // For longer integers, we'll just return a simple value | ||||
|       return buffer[offset]; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -4,7 +4,7 @@ | ||||
|  */ | ||||
|  | ||||
| // Re-export all public types | ||||
| export type { IUpsStatus, IOidSet, TUpsModel, ISnmpConfig } from './types.js'; | ||||
| export type { IOidSet, ISnmpConfig, IUpsStatus, TUpsModel } from './types.ts'; | ||||
|  | ||||
| // Re-export the SNMP manager class | ||||
| export { NupstSnmp } from './manager.js'; | ||||
| export { NupstSnmp } from './manager.ts'; | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,4 +1,4 @@ | ||||
| import type { IOidSet, TUpsModel } from './types.js'; | ||||
| import type { IOidSet, TUpsModel } from './types.ts'; | ||||
|  | ||||
| /** | ||||
|  * OID sets for different UPS models | ||||
| @@ -11,45 +11,89 @@ export class UpsOidSets { | ||||
|   private static readonly UPS_OID_SETS: Record<TUpsModel, IOidSet> = { | ||||
|     // Cyberpower OIDs for RMCARD205 (based on CyberPower_MIB_v2.11) | ||||
|     cyberpower: { | ||||
|       POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus (2=online, 3=on battery) | ||||
|       POWER_STATUS: '1.3.6.1.4.1.3808.1.1.1.4.1.1.0', // upsBaseOutputStatus | ||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.3808.1.1.1.2.2.1.0', // upsAdvanceBatteryCapacity (percentage) | ||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.3808.1.1.1.2.2.4.0', // upsAdvanceBatteryRunTimeRemaining (TimeTicks) | ||||
|       OUTPUT_LOAD: '1.3.6.1.4.1.3808.1.1.1.4.2.3.0', // upsAdvanceOutputLoad (percentage) | ||||
|       OUTPUT_POWER: '1.3.6.1.4.1.3808.1.1.1.4.2.5.0', // upsAdvanceOutputPower (watts) | ||||
|       OUTPUT_VOLTAGE: '1.3.6.1.4.1.3808.1.1.1.4.2.1.0', // upsAdvanceOutputVoltage (0.1V scale) | ||||
|       OUTPUT_CURRENT: '1.3.6.1.4.1.3808.1.1.1.4.2.4.0', // upsAdvanceOutputCurrent (0.1A scale) | ||||
|       POWER_STATUS_VALUES: { | ||||
|         online: 2, // upsBaseOutputStatus: 2=onLine | ||||
|         onBattery: 3, // upsBaseOutputStatus: 3=onBattery | ||||
|       }, | ||||
|     }, | ||||
|      | ||||
|     // APC OIDs | ||||
|  | ||||
|     // APC OIDs (PowerNet MIB) | ||||
|     apc: { | ||||
|       POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // Power status (1=online, 2=on battery) | ||||
|       POWER_STATUS: '1.3.6.1.4.1.318.1.1.1.4.1.1.0', // upsBasicOutputStatus | ||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.318.1.1.1.2.2.1.0', // Battery capacity in percentage | ||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.318.1.1.1.2.2.3.0', // Remaining runtime in minutes | ||||
|       OUTPUT_LOAD: '1.3.6.1.4.1.318.1.1.1.4.2.3.0', // upsAdvOutputLoad (percentage) | ||||
|       OUTPUT_POWER: '1.3.6.1.4.1.318.1.1.1.4.2.8.0', // upsAdvOutputActivePower (watts) | ||||
|       OUTPUT_VOLTAGE: '1.3.6.1.4.1.318.1.1.1.4.2.1.0', // upsAdvOutputVoltage | ||||
|       OUTPUT_CURRENT: '1.3.6.1.4.1.318.1.1.1.4.2.4.0', // upsAdvOutputCurrent | ||||
|       POWER_STATUS_VALUES: { | ||||
|         online: 2, // upsBasicOutputStatus: 2=onLine | ||||
|         onBattery: 3, // upsBasicOutputStatus: 3=onBattery | ||||
|       }, | ||||
|     }, | ||||
|      | ||||
|     // Eaton OIDs | ||||
|  | ||||
|     // Eaton OIDs (XUPS-MIB) | ||||
|     eaton: { | ||||
|       POWER_STATUS: '1.3.6.1.4.1.534.1.1.2.0', // Power status | ||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // Battery capacity in percentage | ||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // Remaining runtime in minutes | ||||
|       POWER_STATUS: '1.3.6.1.4.1.534.1.4.4.0', // xupsOutputSource | ||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.534.1.2.4.0', // xupsBatCapacity (percentage) | ||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.534.1.2.1.0', // xupsBatTimeRemaining (seconds) | ||||
|       OUTPUT_LOAD: '1.3.6.1.4.1.534.1.4.4.1.8.1', // xupsOutputPercentLoad (phase 1) | ||||
|       OUTPUT_POWER: '1.3.6.1.4.1.534.1.4.4.1.4.1', // xupsOutputWatts (phase 1) | ||||
|       OUTPUT_VOLTAGE: '1.3.6.1.4.1.534.1.4.4.1.2.1', // xupsOutputVoltage (phase 1) | ||||
|       OUTPUT_CURRENT: '1.3.6.1.4.1.534.1.4.4.1.3.1', // xupsOutputCurrent (phase 1) | ||||
|       POWER_STATUS_VALUES: { | ||||
|         online: 3, // xupsOutputSource: 3=normal (mains power) | ||||
|         onBattery: 5, // xupsOutputSource: 5=battery | ||||
|       }, | ||||
|     }, | ||||
|      | ||||
|  | ||||
|     // TrippLite OIDs | ||||
|     tripplite: { | ||||
|       POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // Power status | ||||
|       POWER_STATUS: '1.3.6.1.4.1.850.1.1.3.1.1.1.0', // tlUpsOutputSource | ||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.850.1.1.3.2.4.1.0', // Battery capacity in percentage | ||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.850.1.1.3.2.2.1.0', // Remaining runtime in minutes | ||||
|       OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad | ||||
|       OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts) | ||||
|       OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage | ||||
|       OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale) | ||||
|       POWER_STATUS_VALUES: { | ||||
|         online: 2, // tlUpsOutputSource: 2=normal (mains power) | ||||
|         onBattery: 3, // tlUpsOutputSource: 3=onBattery | ||||
|       }, | ||||
|     }, | ||||
|      | ||||
|  | ||||
|     // Liebert/Vertiv OIDs | ||||
|     liebert: { | ||||
|       POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // Power status | ||||
|       POWER_STATUS: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.2.1', // lgpPwrOutputSource | ||||
|       BATTERY_CAPACITY: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.4.1', // Battery capacity in percentage | ||||
|       BATTERY_RUNTIME: '1.3.6.1.4.1.476.1.42.3.9.20.1.20.1.2.1.5.1', // Remaining runtime in minutes | ||||
|       OUTPUT_LOAD: '1.3.6.1.2.1.33.1.4.4.1.5.1', // RFC 1628: upsOutputPercentLoad | ||||
|       OUTPUT_POWER: '1.3.6.1.2.1.33.1.4.4.1.4.1', // RFC 1628: upsOutputPower (watts) | ||||
|       OUTPUT_VOLTAGE: '1.3.6.1.2.1.33.1.4.4.1.2.1', // RFC 1628: upsOutputVoltage | ||||
|       OUTPUT_CURRENT: '1.3.6.1.2.1.33.1.4.4.1.3.1', // RFC 1628: upsOutputCurrent (0.1A scale) | ||||
|       POWER_STATUS_VALUES: { | ||||
|         online: 2, // lgpPwrOutputSource: 2=normal (mains power) | ||||
|         onBattery: 3, // lgpPwrOutputSource: 3=onBattery | ||||
|       }, | ||||
|     }, | ||||
|      | ||||
|  | ||||
|     // Custom OIDs (to be provided by the user) | ||||
|     custom: { | ||||
|       POWER_STATUS: '', | ||||
|       BATTERY_CAPACITY: '', | ||||
|       BATTERY_RUNTIME: '', | ||||
|     } | ||||
|       OUTPUT_LOAD: '', | ||||
|       OUTPUT_POWER: '', | ||||
|       OUTPUT_VOLTAGE: '', | ||||
|       OUTPUT_CURRENT: '', | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   /** | ||||
| @@ -67,9 +111,13 @@ export class UpsOidSets { | ||||
|    */ | ||||
|   public static getStandardOids(): Record<string, string> { | ||||
|     return { | ||||
|       'power status': '1.3.6.1.2.1.33.1.4.1.0',      // upsOutputSource | ||||
|       'battery capacity': '1.3.6.1.2.1.33.1.2.4.0',  // upsEstimatedChargeRemaining | ||||
|       'battery runtime': '1.3.6.1.2.1.33.1.2.3.0'    // upsEstimatedMinutesRemaining | ||||
|       'power status': '1.3.6.1.2.1.33.1.4.1.0', // upsOutputSource | ||||
|       'battery capacity': '1.3.6.1.2.1.33.1.2.4.0', // upsEstimatedChargeRemaining | ||||
|       'battery runtime': '1.3.6.1.2.1.33.1.2.3.0', // upsEstimatedMinutesRemaining | ||||
|       'output load': '1.3.6.1.2.1.33.1.4.4.1.5.1', // upsOutputPercentLoad (indexed by line) | ||||
|       'output power': '1.3.6.1.2.1.33.1.4.4.1.4.1', // upsOutputPower in watts (indexed by line) | ||||
|       'output voltage': '1.3.6.1.2.1.33.1.4.4.1.2.1', // upsOutputVoltage (indexed by line) | ||||
|       'output current': '1.3.6.1.2.1.33.1.4.4.1.3.1', // upsOutputCurrent in 0.1A (indexed by line) | ||||
|     }; | ||||
|   } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,651 +0,0 @@ | ||||
| import * as crypto from 'crypto'; | ||||
| import type { ISnmpConfig, ISnmpV3SecurityParams } from './types.js'; | ||||
| import { SnmpEncoder } from './encoder.js'; | ||||
|  | ||||
| /** | ||||
|  * SNMP packet creation utilities | ||||
|  * Creates SNMP request packets for different SNMP versions | ||||
|  */ | ||||
| export class SnmpPacketCreator { | ||||
|   /** | ||||
|    * Create an SNMPv1 GET request | ||||
|    * @param oid OID to query | ||||
|    * @param community Community string | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Buffer containing the SNMP request | ||||
|    */ | ||||
|   public static createSnmpGetRequest(oid: string, community: string, debug: boolean = false): Buffer { | ||||
|     const oidArray = SnmpEncoder.oidToArray(oid); | ||||
|     const encodedOid = SnmpEncoder.encodeOID(oidArray); | ||||
|      | ||||
|     if (debug) { | ||||
|       console.log('OID array length:', oidArray.length); | ||||
|       console.log('OID array:', oidArray); | ||||
|     } | ||||
|      | ||||
|     // SNMP message structure | ||||
|     // Sequence | ||||
|     //   Version (Integer) | ||||
|     //   Community (String) | ||||
|     //   PDU (GetRequest) | ||||
|     //     Request ID (Integer) | ||||
|     //     Error Status (Integer) | ||||
|     //     Error Index (Integer) | ||||
|     //     Variable Bindings (Sequence) | ||||
|     //       Variable (Sequence) | ||||
|     //         OID (ObjectIdentifier) | ||||
|     //         Value (Null) | ||||
|      | ||||
|     // Use the standard method from our test that is known to work | ||||
|     // Create a fixed request ID (0x00000001) to ensure deterministic behavior | ||||
|     const requestId = Buffer.from([0x00, 0x00, 0x00, 0x01]); | ||||
|      | ||||
|     // Encode values | ||||
|     const versionBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x00])        // SNMP version 1 (0) | ||||
|     ]); | ||||
|      | ||||
|     const communityBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, community.length]), // ASN.1 Octet String, length | ||||
|       Buffer.from(community)                // Community string | ||||
|     ]); | ||||
|      | ||||
|     const requestIdBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       requestId                  // Fixed Request ID | ||||
|     ]); | ||||
|      | ||||
|     const errorStatusBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x00])        // Error Status (0 = no error) | ||||
|     ]); | ||||
|      | ||||
|     const errorIndexBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x00])        // Error Index (0) | ||||
|     ]); | ||||
|      | ||||
|     const oidValueBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]),       // ASN.1 Sequence | ||||
|       Buffer.from([encodedOid.length + 2]), // Length | ||||
|       Buffer.from([0x06]),       // ASN.1 Object Identifier | ||||
|       Buffer.from([encodedOid.length]), // Length | ||||
|       encodedOid,                // OID | ||||
|       Buffer.from([0x05, 0x00])  // Null value | ||||
|     ]); | ||||
|      | ||||
|     const varBindingsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]),       // ASN.1 Sequence | ||||
|       Buffer.from([oidValueBuf.length]), // Length | ||||
|       oidValueBuf                // Variable binding | ||||
|     ]); | ||||
|      | ||||
|     const pduBuf = Buffer.concat([ | ||||
|       Buffer.from([0xa0]),       // ASN.1 Context-specific Constructed 0 (GetRequest) | ||||
|       Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]), // Length | ||||
|       requestIdBuf,              // Request ID | ||||
|       errorStatusBuf,            // Error Status | ||||
|       errorIndexBuf,             // Error Index | ||||
|       varBindingsBuf             // Variable Bindings | ||||
|     ]); | ||||
|      | ||||
|     const messageBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]),       // ASN.1 Sequence | ||||
|       Buffer.from([versionBuf.length + communityBuf.length + pduBuf.length]), // Length | ||||
|       versionBuf,                // Version | ||||
|       communityBuf,              // Community | ||||
|       pduBuf                     // PDU | ||||
|     ]); | ||||
|      | ||||
|     if (debug) { | ||||
|       console.log('SNMP Request buffer:', messageBuf.toString('hex')); | ||||
|     } | ||||
|      | ||||
|     return messageBuf; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create an SNMPv3 GET request | ||||
|    * @param oid OID to query | ||||
|    * @param config SNMP configuration | ||||
|    * @param engineID Engine ID | ||||
|    * @param engineBoots Engine boots counter | ||||
|    * @param engineTime Engine time counter | ||||
|    * @param requestID Request ID | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Buffer containing the SNMP request | ||||
|    */ | ||||
|   public static createSnmpV3GetRequest( | ||||
|     oid: string,  | ||||
|     config: ISnmpConfig,  | ||||
|     engineID: Buffer, | ||||
|     engineBoots: number, | ||||
|     engineTime: number, | ||||
|     requestID: number, | ||||
|     debug: boolean = false | ||||
|   ): Buffer { | ||||
|     if (debug) { | ||||
|       console.log('Creating SNMPv3 GET request for OID:', oid); | ||||
|       console.log('With config:', { | ||||
|         ...config, | ||||
|         authKey: config.authKey ? '***' : undefined, | ||||
|         privKey: config.privKey ? '***' : undefined | ||||
|       }); | ||||
|     } | ||||
|      | ||||
|     const oidArray = SnmpEncoder.oidToArray(oid); | ||||
|     const encodedOid = SnmpEncoder.encodeOID(oidArray); | ||||
|      | ||||
|     if (debug) { | ||||
|       console.log('Using engine ID:', engineID.toString('hex')); | ||||
|       console.log('Engine boots:', engineBoots); | ||||
|       console.log('Engine time:', engineTime); | ||||
|       console.log('Request ID:', requestID); | ||||
|     } | ||||
|      | ||||
|     // Create security parameters | ||||
|     const securityParams: ISnmpV3SecurityParams = { | ||||
|       msgAuthoritativeEngineID: engineID, | ||||
|       msgAuthoritativeEngineBoots: engineBoots, | ||||
|       msgAuthoritativeEngineTime: engineTime, | ||||
|       msgUserName: config.username || '', | ||||
|       msgAuthenticationParameters: Buffer.alloc(12, 0), // Will be filled in later for auth | ||||
|       msgPrivacyParameters: Buffer.alloc(8, 0),  // For privacy | ||||
|     }; | ||||
|  | ||||
|     // Create the PDU (Protocol Data Unit) | ||||
|     // This is wrapped within the security parameters | ||||
|     const requestIdBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(requestID) // Request ID | ||||
|     ]); | ||||
|      | ||||
|     const errorStatusBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x00])        // Error Status (0 = no error) | ||||
|     ]); | ||||
|      | ||||
|     const errorIndexBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x00])        // Error Index (0) | ||||
|     ]); | ||||
|      | ||||
|     const oidValueBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]),       // ASN.1 Sequence | ||||
|       Buffer.from([encodedOid.length + 2]), // Length | ||||
|       Buffer.from([0x06]),       // ASN.1 Object Identifier | ||||
|       Buffer.from([encodedOid.length]), // Length | ||||
|       encodedOid,                // OID | ||||
|       Buffer.from([0x05, 0x00])  // Null value | ||||
|     ]); | ||||
|      | ||||
|     const varBindingsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]),       // ASN.1 Sequence | ||||
|       Buffer.from([oidValueBuf.length]), // Length | ||||
|       oidValueBuf                // Variable binding | ||||
|     ]); | ||||
|      | ||||
|     const pduBuf = Buffer.concat([ | ||||
|       Buffer.from([0xa0]),       // ASN.1 Context-specific Constructed 0 (GetRequest) | ||||
|       Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]), // Length | ||||
|       requestIdBuf,              // Request ID | ||||
|       errorStatusBuf,            // Error Status | ||||
|       errorIndexBuf,             // Error Index | ||||
|       varBindingsBuf             // Variable Bindings | ||||
|     ]); | ||||
|  | ||||
|     // Create the security parameters | ||||
|     const engineIdBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, securityParams.msgAuthoritativeEngineID.length]), // ASN.1 Octet String | ||||
|       securityParams.msgAuthoritativeEngineID | ||||
|     ]); | ||||
|      | ||||
|     const engineBootsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(securityParams.msgAuthoritativeEngineBoots) | ||||
|     ]); | ||||
|      | ||||
|     const engineTimeBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(securityParams.msgAuthoritativeEngineTime) | ||||
|     ]); | ||||
|      | ||||
|     const userNameBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, securityParams.msgUserName.length]), // ASN.1 Octet String | ||||
|       Buffer.from(securityParams.msgUserName) | ||||
|     ]); | ||||
|      | ||||
|     const authParamsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, securityParams.msgAuthenticationParameters.length]), // ASN.1 Octet String | ||||
|       securityParams.msgAuthenticationParameters | ||||
|     ]); | ||||
|      | ||||
|     const privParamsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, securityParams.msgPrivacyParameters.length]), // ASN.1 Octet String | ||||
|       securityParams.msgPrivacyParameters | ||||
|     ]); | ||||
|      | ||||
|     // Security parameters sequence | ||||
|     const securityParamsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]), // ASN.1 Sequence | ||||
|       Buffer.from([engineIdBuf.length + engineBootsBuf.length + engineTimeBuf.length +  | ||||
|                    userNameBuf.length + authParamsBuf.length + privParamsBuf.length]), // Length | ||||
|       engineIdBuf, | ||||
|       engineBootsBuf, | ||||
|       engineTimeBuf, | ||||
|       userNameBuf, | ||||
|       authParamsBuf, | ||||
|       privParamsBuf | ||||
|     ]); | ||||
|  | ||||
|     // Determine security level flags | ||||
|     let securityFlags = 0; | ||||
|     if (config.securityLevel === 'authNoPriv' || config.securityLevel === 'authPriv') { | ||||
|       securityFlags |= 0x01; // Authentication flag | ||||
|     } | ||||
|     if (config.securityLevel === 'authPriv') { | ||||
|       securityFlags |= 0x02; // Privacy flag | ||||
|     } | ||||
|      | ||||
|     // Set reportable flag - required for SNMPv3 | ||||
|     securityFlags |= 0x04; // Reportable flag | ||||
|  | ||||
|     // Create SNMPv3 header | ||||
|     const msgIdBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(requestID) // Message ID (same as request ID for simplicity) | ||||
|     ]); | ||||
|      | ||||
|     const msgMaxSizeBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(65507) // Max message size | ||||
|     ]); | ||||
|      | ||||
|     const msgFlagsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, 0x01]), // ASN.1 Octet String, length 1 | ||||
|       Buffer.from([securityFlags]) | ||||
|     ]); | ||||
|      | ||||
|     const msgSecModelBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x03]) // Security model (3 = USM) | ||||
|     ]); | ||||
|  | ||||
|     // SNMPv3 header | ||||
|     const msgHeaderBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]), // ASN.1 Sequence | ||||
|       Buffer.from([msgIdBuf.length + msgMaxSizeBuf.length + msgFlagsBuf.length + msgSecModelBuf.length]), // Length | ||||
|       msgIdBuf, | ||||
|       msgMaxSizeBuf, | ||||
|       msgFlagsBuf, | ||||
|       msgSecModelBuf | ||||
|     ]); | ||||
|  | ||||
|     // SNMPv3 security parameters | ||||
|     const msgSecurityBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04]), // ASN.1 Octet String | ||||
|       Buffer.from([securityParamsBuf.length]), // Length | ||||
|       securityParamsBuf | ||||
|     ]); | ||||
|  | ||||
|     // Create scopedPDU | ||||
|     // In SNMPv3, the PDU is wrapped in a "scoped PDU" structure | ||||
|     const contextEngineBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, engineID.length]), // ASN.1 Octet String | ||||
|       engineID | ||||
|     ]); | ||||
|      | ||||
|     const contextNameBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, 0x00]), // ASN.1 Octet String, length 0 (empty context name) | ||||
|     ]); | ||||
|      | ||||
|     const scopedPduBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]), // ASN.1 Sequence | ||||
|       Buffer.from([contextEngineBuf.length + contextNameBuf.length + pduBuf.length]), // Length | ||||
|       contextEngineBuf, | ||||
|       contextNameBuf, | ||||
|       pduBuf | ||||
|     ]); | ||||
|  | ||||
|     // For authPriv, we need to encrypt the scopedPDU | ||||
|     let encryptedPdu = scopedPduBuf; | ||||
|     if (config.securityLevel === 'authPriv' && config.privKey) { | ||||
|       // In a real implementation, encryption would be applied here | ||||
|       // For this example, we'll just simulate it | ||||
|       encryptedPdu = this.simulateEncryption(scopedPduBuf, config); | ||||
|     } | ||||
|  | ||||
|     // Final scopedPDU (encrypted or not) | ||||
|     const finalScopedPduBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04]), // ASN.1 Octet String | ||||
|       Buffer.from([encryptedPdu.length]), // Length | ||||
|       encryptedPdu | ||||
|     ]); | ||||
|  | ||||
|     // Combine everything for the final message | ||||
|     const versionBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x03])        // SNMP version 3 (3) | ||||
|     ]); | ||||
|      | ||||
|     const messageBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]), // ASN.1 Sequence | ||||
|       Buffer.from([versionBuf.length + msgHeaderBuf.length + msgSecurityBuf.length + finalScopedPduBuf.length]), // Length | ||||
|       versionBuf, | ||||
|       msgHeaderBuf, | ||||
|       msgSecurityBuf, | ||||
|       finalScopedPduBuf | ||||
|     ]); | ||||
|  | ||||
|     // If using authentication, calculate and insert the authentication parameters | ||||
|     if ((config.securityLevel === 'authNoPriv' || config.securityLevel === 'authPriv') &&  | ||||
|         config.authKey && config.authProtocol) { | ||||
|       const authenticatedMsg = this.addAuthentication(messageBuf, config, authParamsBuf); | ||||
|        | ||||
|       if (debug) { | ||||
|         console.log('Created authenticated SNMPv3 message'); | ||||
|         console.log('Final message length:', authenticatedMsg.length); | ||||
|       } | ||||
|        | ||||
|       return authenticatedMsg; | ||||
|     } | ||||
|  | ||||
|     if (debug) { | ||||
|       console.log('Created SNMPv3 message without authentication'); | ||||
|       console.log('Final message length:', messageBuf.length); | ||||
|     } | ||||
|      | ||||
|     return messageBuf; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Simulate encryption for authPriv security level | ||||
|    * In a real implementation, this would use the specified privacy protocol (DES/AES) | ||||
|    * @param data Data to encrypt | ||||
|    * @param config SNMP configuration | ||||
|    * @returns Encrypted data | ||||
|    */ | ||||
|   private static simulateEncryption(data: Buffer, config: ISnmpConfig): Buffer { | ||||
|     // This is a placeholder - in a real implementation, you would: | ||||
|     // 1. Generate an initialization vector (IV) | ||||
|     // 2. Use the privacy key derived from the privKey | ||||
|     // 3. Apply the appropriate encryption algorithm (DES/AES) | ||||
|      | ||||
|     // For demonstration purposes only | ||||
|     if (config.privProtocol === 'AES' && config.privKey) { | ||||
|       try { | ||||
|         // Create a deterministic IV for demo purposes (not secure for production) | ||||
|         const iv = Buffer.alloc(16, 0); | ||||
|         const engineID = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); | ||||
|         for (let i = 0; i < 8; i++) { | ||||
|           iv[i] = engineID[i % engineID.length]; | ||||
|         } | ||||
|          | ||||
|         // Create a key from the privKey (proper key localization should be used in production) | ||||
|         const key = crypto.createHash('md5').update(config.privKey).digest(); | ||||
|          | ||||
|         // Create cipher and encrypt | ||||
|         const cipher = crypto.createCipheriv('aes-128-cfb', key, iv); | ||||
|         const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); | ||||
|          | ||||
|         return encrypted; | ||||
|       } catch (error) { | ||||
|         console.warn('AES encryption failed, falling back to plaintext:', error); | ||||
|         return data; | ||||
|       } | ||||
|     } else if (config.privProtocol === 'DES' && config.privKey) { | ||||
|       try { | ||||
|         // Create a deterministic IV for demo purposes (not secure for production) | ||||
|         const iv = Buffer.alloc(8, 0); | ||||
|         const engineID = Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); | ||||
|         for (let i = 0; i < 8; i++) { | ||||
|           iv[i] = engineID[i % engineID.length]; | ||||
|         } | ||||
|          | ||||
|         // Create a key from the privKey (proper key localization should be used in production) | ||||
|         const key = crypto.createHash('md5').update(config.privKey).digest().slice(0, 8); | ||||
|          | ||||
|         // Create cipher and encrypt | ||||
|         const cipher = crypto.createCipheriv('des-cbc', key, iv); | ||||
|         const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); | ||||
|          | ||||
|         return encrypted; | ||||
|       } catch (error) { | ||||
|         console.warn('DES encryption failed, falling back to plaintext:', error); | ||||
|         return data; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return data; // Return unencrypted data as fallback | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add authentication to SNMPv3 message | ||||
|    * @param message Message to authenticate | ||||
|    * @param config SNMP configuration | ||||
|    * @param authParamsBuf Authentication parameters buffer | ||||
|    * @returns Authenticated message | ||||
|    */ | ||||
|   private static addAuthentication(message: Buffer, config: ISnmpConfig, authParamsBuf: Buffer): Buffer { | ||||
|     // In a real implementation, this would: | ||||
|     // 1. Zero out the authentication parameters field | ||||
|     // 2. Calculate HMAC-MD5 or HMAC-SHA1 over the entire message | ||||
|     // 3. Insert the HMAC into the authentication parameters field | ||||
|      | ||||
|     if (!config.authKey) { | ||||
|       return message; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Find position of auth parameters in the message | ||||
|       // This is a more reliable way to find the exact position | ||||
|       let authParamsPos = -1; | ||||
|       for (let i = 0; i < message.length - 16; i++) { | ||||
|         // Look for the auth params pattern: 0x04 0x0C 0x00 0x00... | ||||
|         if (message[i] === 0x04 && message[i + 1] === 0x0C) { | ||||
|           // Check if next 12 bytes are all zeros | ||||
|           let allZeros = true; | ||||
|           for (let j = 0; j < 12; j++) { | ||||
|             if (message[i + 2 + j] !== 0) { | ||||
|               allZeros = false; | ||||
|               break; | ||||
|             } | ||||
|           } | ||||
|           if (allZeros) { | ||||
|             authParamsPos = i; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       if (authParamsPos === -1) { | ||||
|         return message; | ||||
|       } | ||||
|        | ||||
|       // Create a copy of the message with zeroed auth parameters | ||||
|       const msgCopy = Buffer.from(message); | ||||
|        | ||||
|       // Prepare the authentication key according to RFC3414 | ||||
|       // We should use the standard key localization process | ||||
|       const localizedKey = this.localizeAuthKey(config.authKey,  | ||||
|         Buffer.from([0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]), | ||||
|         config.authProtocol); | ||||
|        | ||||
|       // Calculate HMAC | ||||
|       let hmac; | ||||
|       if (config.authProtocol === 'SHA') { | ||||
|         hmac = crypto.createHmac('sha1', localizedKey).update(msgCopy).digest().slice(0, 12); | ||||
|       } else { | ||||
|         // Default to MD5 | ||||
|         hmac = crypto.createHmac('md5', localizedKey).update(msgCopy).digest().slice(0, 12); | ||||
|       } | ||||
|        | ||||
|       // Copy HMAC into original message | ||||
|       hmac.copy(message, authParamsPos + 2); | ||||
|        | ||||
|       return message; | ||||
|     } catch (error) { | ||||
|       console.warn('Authentication failed:', error); | ||||
|       return message; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Localize authentication key according to RFC3414 | ||||
|    * @param key Authentication key | ||||
|    * @param engineId Engine ID | ||||
|    * @param authProtocol Authentication protocol | ||||
|    * @returns Localized key | ||||
|    */ | ||||
|   private static localizeAuthKey(key: string, engineId: Buffer, authProtocol: string = 'MD5'): Buffer { | ||||
|     try { | ||||
|       // Convert password to key using hash | ||||
|       let initialHash; | ||||
|       if (authProtocol === 'SHA') { | ||||
|         initialHash = crypto.createHash('sha1'); | ||||
|       } else { | ||||
|         initialHash = crypto.createHash('md5'); | ||||
|       } | ||||
|        | ||||
|       // Generate the initial key - repeated hashing of password + padding | ||||
|       const password = Buffer.from(key); | ||||
|       let passwordIndex = 0; | ||||
|        | ||||
|       // Create a buffer of 1MB (1048576 bytes) filled with the password | ||||
|       const buffer = Buffer.alloc(1048576); | ||||
|       for (let i = 0; i < 1048576; i++) { | ||||
|         buffer[i] = password[passwordIndex]; | ||||
|         passwordIndex = (passwordIndex + 1) % password.length; | ||||
|       } | ||||
|        | ||||
|       initialHash.update(buffer); | ||||
|       let initialKey = initialHash.digest(); | ||||
|        | ||||
|       // Localize the key with engine ID | ||||
|       let localHash; | ||||
|       if (authProtocol === 'SHA') { | ||||
|         localHash = crypto.createHash('sha1'); | ||||
|       } else { | ||||
|         localHash = crypto.createHash('md5'); | ||||
|       } | ||||
|        | ||||
|       localHash.update(initialKey); | ||||
|       localHash.update(engineId); | ||||
|       localHash.update(initialKey); | ||||
|        | ||||
|       return localHash.digest(); | ||||
|     } catch (error) { | ||||
|       console.error('Error localizing auth key:', error); | ||||
|       // Return a fallback key | ||||
|       return Buffer.from(key); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create a discovery message for SNMPv3 engine ID discovery | ||||
|    * @param config SNMP configuration | ||||
|    * @param requestID Request ID | ||||
|    * @returns Discovery message | ||||
|    */ | ||||
|   public static createDiscoveryMessage(config: ISnmpConfig, requestID: number): Buffer { | ||||
|     // Basic SNMPv3 header for discovery | ||||
|     const msgIdBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(requestID) | ||||
|     ]); | ||||
|      | ||||
|     const msgMaxSizeBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(65507) // Max message size | ||||
|     ]); | ||||
|      | ||||
|     const msgFlagsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, 0x01]), // ASN.1 Octet String, length 1 | ||||
|       Buffer.from([0x00]) // No authentication or privacy | ||||
|     ]); | ||||
|      | ||||
|     const msgSecModelBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x03]) // Security model (3 = USM) | ||||
|     ]); | ||||
|  | ||||
|     // SNMPv3 header | ||||
|     const msgHeaderBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]), // ASN.1 Sequence | ||||
|       Buffer.from([msgIdBuf.length + msgMaxSizeBuf.length + msgFlagsBuf.length + msgSecModelBuf.length]), // Length | ||||
|       msgIdBuf, | ||||
|       msgMaxSizeBuf, | ||||
|       msgFlagsBuf, | ||||
|       msgSecModelBuf | ||||
|     ]); | ||||
|      | ||||
|     // Simple security parameters for discovery | ||||
|     const securityBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, 0x00]), // Empty octet string | ||||
|     ]); | ||||
|      | ||||
|     // Simple Get request for discovery | ||||
|     const requestIdBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x04]), // ASN.1 Integer, length 4 | ||||
|       SnmpEncoder.encodeInteger(requestID + 1) | ||||
|     ]); | ||||
|      | ||||
|     const errorStatusBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x00])        // Error Status (0 = no error) | ||||
|     ]); | ||||
|      | ||||
|     const errorIndexBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x00])        // Error Index (0) | ||||
|     ]); | ||||
|      | ||||
|     // Empty varbinds for discovery | ||||
|     const varBindingsBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30, 0x00]), // Empty sequence | ||||
|     ]); | ||||
|      | ||||
|     const pduBuf = Buffer.concat([ | ||||
|       Buffer.from([0xa0]), // GetRequest | ||||
|       Buffer.from([requestIdBuf.length + errorStatusBuf.length + errorIndexBuf.length + varBindingsBuf.length]), | ||||
|       requestIdBuf, | ||||
|       errorStatusBuf, | ||||
|       errorIndexBuf, | ||||
|       varBindingsBuf | ||||
|     ]); | ||||
|      | ||||
|     // Context data | ||||
|     const contextEngineBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, 0x00]), // Empty octet string | ||||
|     ]); | ||||
|      | ||||
|     const contextNameBuf = Buffer.concat([ | ||||
|       Buffer.from([0x04, 0x00]), // Empty octet string | ||||
|     ]); | ||||
|      | ||||
|     const scopedPduBuf = Buffer.concat([ | ||||
|       Buffer.from([0x30]), | ||||
|       Buffer.from([contextEngineBuf.length + contextNameBuf.length + pduBuf.length]), | ||||
|       contextEngineBuf, | ||||
|       contextNameBuf, | ||||
|       pduBuf | ||||
|     ]); | ||||
|      | ||||
|     // Version | ||||
|     const versionBuf = Buffer.concat([ | ||||
|       Buffer.from([0x02, 0x01]), // ASN.1 Integer, length 1 | ||||
|       Buffer.from([0x03])        // SNMP version 3 (3) | ||||
|     ]); | ||||
|      | ||||
|     // Complete message | ||||
|     return Buffer.concat([ | ||||
|       Buffer.from([0x30]), | ||||
|       Buffer.from([versionBuf.length + msgHeaderBuf.length + securityBuf.length + scopedPduBuf.length]), | ||||
|       versionBuf, | ||||
|       msgHeaderBuf, | ||||
|       securityBuf, | ||||
|       scopedPduBuf | ||||
|     ]); | ||||
|   } | ||||
| } | ||||
| @@ -1,553 +0,0 @@ | ||||
| import type { ISnmpConfig } from './types.js'; | ||||
| import { SnmpEncoder } from './encoder.js'; | ||||
|  | ||||
| /** | ||||
|  * SNMP packet parsing utilities | ||||
|  * Parses SNMP response packets | ||||
|  */ | ||||
| export class SnmpPacketParser { | ||||
|   /** | ||||
|    * Parse an SNMP response | ||||
|    * @param buffer Response buffer | ||||
|    * @param config SNMP configuration | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Parsed value or null if parsing failed | ||||
|    */ | ||||
|   public static parseSnmpResponse(buffer: Buffer, config: ISnmpConfig, debug: boolean = false): any { | ||||
|     // Check if we have a response packet | ||||
|     if (buffer[0] !== 0x30) { | ||||
|       throw new Error('Invalid SNMP response format'); | ||||
|     } | ||||
|      | ||||
|     // For SNMPv3, we need to handle the message differently | ||||
|     if (config.version === 3) { | ||||
|       return this.parseSnmpV3Response(buffer, debug); | ||||
|     } | ||||
|      | ||||
|     if (debug) { | ||||
|       console.log('Parsing SNMPv1/v2 response: ', buffer.toString('hex')); | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Enhanced structured parsing approach | ||||
|       // SEQUENCE header | ||||
|       let pos = 0; | ||||
|       if (buffer[pos] !== 0x30) { | ||||
|         throw new Error('Missing SEQUENCE at start of response'); | ||||
|       } | ||||
|       // Skip SEQUENCE header - assume length is in single byte for simplicity | ||||
|       // In a more robust implementation, we'd handle multi-byte lengths | ||||
|       pos += 2; | ||||
|        | ||||
|       // VERSION | ||||
|       if (buffer[pos] !== 0x02) { | ||||
|         throw new Error('Missing INTEGER for version'); | ||||
|       } | ||||
|       const versionLength = buffer[pos + 1]; | ||||
|       pos += 2 + versionLength; | ||||
|        | ||||
|       // COMMUNITY STRING | ||||
|       if (buffer[pos] !== 0x04) { | ||||
|         throw new Error('Missing OCTET STRING for community'); | ||||
|       } | ||||
|       const communityLength = buffer[pos + 1]; | ||||
|       pos += 2 + communityLength; | ||||
|        | ||||
|       // PDU TYPE - should be RESPONSE (0xA2) | ||||
|       if (buffer[pos] !== 0xA2) { | ||||
|         throw new Error(`Unexpected PDU type: 0x${buffer[pos].toString(16)}, expected 0xA2`); | ||||
|       } | ||||
|       // Skip PDU header | ||||
|       pos += 2; | ||||
|        | ||||
|       // REQUEST ID | ||||
|       if (buffer[pos] !== 0x02) { | ||||
|         throw new Error('Missing INTEGER for request ID'); | ||||
|       } | ||||
|       const requestIdLength = buffer[pos + 1]; | ||||
|       pos += 2 + requestIdLength; | ||||
|        | ||||
|       // ERROR STATUS | ||||
|       if (buffer[pos] !== 0x02) { | ||||
|         throw new Error('Missing INTEGER for error status'); | ||||
|       } | ||||
|       const errorStatusLength = buffer[pos + 1]; | ||||
|       const errorStatus = SnmpEncoder.decodeInteger(buffer, pos + 2, errorStatusLength); | ||||
|        | ||||
|       if (errorStatus !== 0) { | ||||
|         throw new Error(`SNMP error status: ${errorStatus}`); | ||||
|       } | ||||
|       pos += 2 + errorStatusLength; | ||||
|        | ||||
|       // ERROR INDEX | ||||
|       if (buffer[pos] !== 0x02) { | ||||
|         throw new Error('Missing INTEGER for error index'); | ||||
|       } | ||||
|       const errorIndexLength = buffer[pos + 1]; | ||||
|       pos += 2 + errorIndexLength; | ||||
|        | ||||
|       // VARBIND LIST | ||||
|       if (buffer[pos] !== 0x30) { | ||||
|         throw new Error('Missing SEQUENCE for varbind list'); | ||||
|       } | ||||
|       // Skip varbind list header | ||||
|       pos += 2; | ||||
|        | ||||
|       // VARBIND | ||||
|       if (buffer[pos] !== 0x30) { | ||||
|         throw new Error('Missing SEQUENCE for varbind'); | ||||
|       } | ||||
|       // Skip varbind header | ||||
|       pos += 2; | ||||
|        | ||||
|       // OID | ||||
|       if (buffer[pos] !== 0x06) { | ||||
|         throw new Error('Missing OBJECT IDENTIFIER for OID'); | ||||
|       } | ||||
|       const oidLength = buffer[pos + 1]; | ||||
|       pos += 2 + oidLength; | ||||
|        | ||||
|       // VALUE - this is what we want | ||||
|       const valueType = buffer[pos]; | ||||
|       const valueLength = buffer[pos + 1]; | ||||
|        | ||||
|       if (debug) { | ||||
|         console.log(`Found value type: 0x${valueType.toString(16)}, length: ${valueLength}`); | ||||
|       } | ||||
|        | ||||
|       return this.parseValueByType(valueType, valueLength, buffer, pos, debug); | ||||
|     } catch (error) { | ||||
|       if (debug) { | ||||
|         console.error('Error in structured parsing:', error); | ||||
|         console.error('Falling back to scan-based parsing method'); | ||||
|       } | ||||
|        | ||||
|       return this.scanBasedParsing(buffer, debug); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Parse value by ASN.1 type | ||||
|    * @param valueType ASN.1 type | ||||
|    * @param valueLength Value length | ||||
|    * @param buffer Buffer containing the value | ||||
|    * @param pos Position of the value in the buffer | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Parsed value | ||||
|    */ | ||||
|   private static parseValueByType( | ||||
|     valueType: number,  | ||||
|     valueLength: number,  | ||||
|     buffer: Buffer,  | ||||
|     pos: number,  | ||||
|     debug: boolean | ||||
|   ): any { | ||||
|     switch (valueType) { | ||||
|       case 0x02: // INTEGER | ||||
|         { | ||||
|           const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength); | ||||
|           if (debug) { | ||||
|             console.log('Parsed INTEGER value:', value); | ||||
|           } | ||||
|           return value; | ||||
|         } | ||||
|          | ||||
|       case 0x04: // OCTET STRING | ||||
|         { | ||||
|           const value = buffer.slice(pos + 2, pos + 2 + valueLength).toString(); | ||||
|           if (debug) { | ||||
|             console.log('Parsed OCTET STRING value:', value); | ||||
|           } | ||||
|           return value; | ||||
|         } | ||||
|          | ||||
|       case 0x05: // NULL | ||||
|         if (debug) { | ||||
|           console.log('Parsed NULL value'); | ||||
|         } | ||||
|         return null; | ||||
|          | ||||
|       case 0x06: // OBJECT IDENTIFIER (rare in a value position) | ||||
|         { | ||||
|           // Usually this would be encoded as a string representation | ||||
|           const value = buffer.slice(pos + 2, pos + 2 + valueLength).toString('hex'); | ||||
|           if (debug) { | ||||
|             console.log('Parsed OBJECT IDENTIFIER value (hex):', value); | ||||
|           } | ||||
|           return value; | ||||
|         } | ||||
|          | ||||
|       case 0x40: // IP ADDRESS | ||||
|         { | ||||
|           if (valueLength !== 4) { | ||||
|             throw new Error(`Invalid IP address length: ${valueLength}, expected 4`); | ||||
|           } | ||||
|           const octets = []; | ||||
|           for (let i = 0; i < 4; i++) { | ||||
|             octets.push(buffer[pos + 2 + i]); | ||||
|           } | ||||
|           const value = octets.join('.'); | ||||
|           if (debug) { | ||||
|             console.log('Parsed IP ADDRESS value:', value); | ||||
|           } | ||||
|           return value; | ||||
|         } | ||||
|          | ||||
|       case 0x41: // COUNTER | ||||
|       case 0x42: // GAUGE32 | ||||
|       case 0x43: // TIMETICKS | ||||
|       case 0x44: // OPAQUE | ||||
|         { | ||||
|           // All these are essentially unsigned 32-bit integers | ||||
|           const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength); | ||||
|           if (debug) { | ||||
|             console.log(`Parsed ${valueType === 0x41 ? 'COUNTER'  | ||||
|                         : valueType === 0x42 ? 'GAUGE32' | ||||
|                         : valueType === 0x43 ? 'TIMETICKS' | ||||
|                         : 'OPAQUE'} value:`, value); | ||||
|           } | ||||
|           return value; | ||||
|         } | ||||
|          | ||||
|       default: | ||||
|         if (debug) { | ||||
|           console.log(`Unknown value type: 0x${valueType.toString(16)}`); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Fallback scan-based parsing method | ||||
|    * @param buffer Buffer containing the SNMP response | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Parsed value or null if parsing failed | ||||
|    */ | ||||
|   private static scanBasedParsing(buffer: Buffer, debug: boolean): any { | ||||
|     // Look for various data types in the response | ||||
|     // The value is near the end of the packet after the OID | ||||
|      | ||||
|     // We're looking for one of these: | ||||
|     // 0x02 - Integer - can be at the end of a varbind | ||||
|     // 0x04 - OctetString | ||||
|     // 0x05 - Null | ||||
|     // 0x42 - Gauge32 - special type for unsigned 32-bit integers | ||||
|     // 0x43 - Timeticks - special type for time values | ||||
|  | ||||
|     // This algorithm performs a thorough search for data types | ||||
|     // by iterating from the start and watching for varbind structures | ||||
|      | ||||
|     // Walk through the buffer looking for varbinds | ||||
|     let i = 0; | ||||
|      | ||||
|     // First, find the varbinds section (0x30 sequence) | ||||
|     while (i < buffer.length - 2) { | ||||
|       // Look for a varbinds sequence | ||||
|       if (buffer[i] === 0x30) { | ||||
|         const varbindsLength = buffer[i + 1]; | ||||
|         const varbindsEnd = i + 2 + varbindsLength; | ||||
|          | ||||
|         // Now search within the varbinds for the value | ||||
|         let j = i + 2; | ||||
|         while (j < varbindsEnd - 2) { | ||||
|           // Look for a varbind (0x30 sequence) | ||||
|           if (buffer[j] === 0x30) { | ||||
|             const varbindLength = buffer[j + 1]; | ||||
|             const varbindEnd = j + 2 + varbindLength; | ||||
|              | ||||
|             // Skip over the OID and find the value within this varbind | ||||
|             let k = j + 2; | ||||
|             while (k < varbindEnd - 1) { | ||||
|               // First find the OID | ||||
|               if (buffer[k] === 0x06) { // OID | ||||
|                 const oidLength = buffer[k + 1]; | ||||
|                 k += 2 + oidLength; // Skip past the OID | ||||
|                  | ||||
|                 // We should now be at the value | ||||
|                 // Check what type it is | ||||
|                 if (k < varbindEnd - 1) { | ||||
|                   return this.parseValueAtPosition(buffer, k, debug); | ||||
|                 } | ||||
|                  | ||||
|                 // If we didn't find a value, move to next byte | ||||
|                 k++; | ||||
|               } else { | ||||
|                 // Move to next byte | ||||
|                 k++; | ||||
|               } | ||||
|             } | ||||
|              | ||||
|             // Move to next varbind | ||||
|             j = varbindEnd; | ||||
|           } else { | ||||
|             // Move to next byte | ||||
|             j++; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         // Move to next sequence | ||||
|         i = varbindsEnd; | ||||
|       } else { | ||||
|         // Move to next byte | ||||
|         i++; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     if (debug) { | ||||
|       console.log('No valid value found in SNMP response'); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Parse value at a specific position in the buffer | ||||
|    * @param buffer Buffer containing the SNMP response | ||||
|    * @param pos Position of the value in the buffer | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Parsed value or null if parsing failed | ||||
|    */ | ||||
|   private static parseValueAtPosition(buffer: Buffer, pos: number, debug: boolean): any { | ||||
|     if (buffer[pos] === 0x02) { // Integer | ||||
|       const valueLength = buffer[pos + 1]; | ||||
|       const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength); | ||||
|       if (debug) { | ||||
|         console.log('Found Integer value:', value); | ||||
|       } | ||||
|       return value; | ||||
|     } else if (buffer[pos] === 0x42) { // Gauge32 | ||||
|       const valueLength = buffer[pos + 1]; | ||||
|       const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength); | ||||
|       if (debug) { | ||||
|         console.log('Found Gauge32 value:', value); | ||||
|       } | ||||
|       return value; | ||||
|     } else if (buffer[pos] === 0x43) { // TimeTicks | ||||
|       const valueLength = buffer[pos + 1]; | ||||
|       const value = SnmpEncoder.decodeInteger(buffer, pos + 2, valueLength); | ||||
|       if (debug) { | ||||
|         console.log('Found Timeticks value:', value); | ||||
|       } | ||||
|       return value; | ||||
|     } else if (buffer[pos] === 0x04) { // OctetString | ||||
|       const valueLength = buffer[pos + 1]; | ||||
|       if (debug) { | ||||
|         console.log('Found OctetString value'); | ||||
|       } | ||||
|       // Just return the string value as-is | ||||
|       return buffer.slice(pos + 2, pos + 2 + valueLength).toString(); | ||||
|     } else if (buffer[pos] === 0x05) { // Null | ||||
|       if (debug) { | ||||
|         console.log('Found Null value'); | ||||
|       } | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Parse an SNMPv3 response | ||||
|    * @param buffer Buffer containing the SNMP response | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Parsed value or null if parsing failed | ||||
|    */ | ||||
|   public static parseSnmpV3Response(buffer: Buffer, debug: boolean = false): any { | ||||
|     // SNMPv3 parsing is complex. In a real implementation, we would: | ||||
|     // 1. Parse the header and get the security parameters | ||||
|     // 2. Verify authentication if used | ||||
|     // 3. Decrypt the PDU if privacy was used | ||||
|     // 4. Extract the PDU and parse it | ||||
|      | ||||
|     if (debug) { | ||||
|       console.log('Parsing SNMPv3 response: ', buffer.toString('hex')); | ||||
|     } | ||||
|      | ||||
|     // Find the scopedPDU - it should be the last OCTET STRING in the message | ||||
|     let scopedPduPos = -1; | ||||
|     for (let i = buffer.length - 50; i >= 0; i--) { | ||||
|       if (buffer[i] === 0x04 && buffer[i + 1] > 10) { // OCTET STRING with reasonable length | ||||
|         scopedPduPos = i; | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     if (scopedPduPos === -1) { | ||||
|       if (debug) { | ||||
|         console.log('Could not find scoped PDU in SNMPv3 response'); | ||||
|       } | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     // Skip to the PDU content | ||||
|     let pduContent = buffer.slice(scopedPduPos + 2); // Skip OCTET STRING header | ||||
|      | ||||
|     // This improved algorithm performs a more thorough search for varbinds  | ||||
|     // in the scoped PDU | ||||
|      | ||||
|     // First, look for the response PDU (sequence with tag 0xa2) | ||||
|     let responsePdu = null; | ||||
|     for (let i = 0; i < pduContent.length - 3; i++) { | ||||
|       if (pduContent[i] === 0xa2) { | ||||
|         // Found the response PDU | ||||
|         const pduLength = pduContent[i + 1]; | ||||
|         responsePdu = pduContent.slice(i, i + 2 + pduLength); | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     if (!responsePdu) { | ||||
|       // Try to find the varbinds directly | ||||
|       for (let i = 0; i < pduContent.length - 3; i++) { | ||||
|         if (pduContent[i] === 0x30) { | ||||
|           const seqLength = pduContent[i + 1]; | ||||
|           if (i + 2 + seqLength <= pduContent.length) { | ||||
|             // Check if this sequence might be the varbinds | ||||
|             const possibleVarbinds = pduContent.slice(i, i + 2 + seqLength); | ||||
|              | ||||
|             // Look for varbind structure inside | ||||
|             for (let j = 0; j < possibleVarbinds.length - 3; j++) { | ||||
|               if (possibleVarbinds[j] === 0x30) { | ||||
|                 // Might be a varbind - look for an OID inside | ||||
|                 for (let k = j; k < j + 10 && k < possibleVarbinds.length - 1; k++) { | ||||
|                   if (possibleVarbinds[k] === 0x06) { | ||||
|                     // Found an OID, so this is likely the varbinds sequence | ||||
|                     responsePdu = possibleVarbinds; | ||||
|                     break; | ||||
|                   } | ||||
|                 } | ||||
|                  | ||||
|                 if (responsePdu) break; | ||||
|               } | ||||
|             } | ||||
|              | ||||
|             if (responsePdu) break; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     if (!responsePdu) { | ||||
|       if (debug) { | ||||
|         console.log('Could not find response PDU in SNMPv3 response'); | ||||
|       } | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     // Now that we have the response PDU, search for varbinds | ||||
|     // Skip the first few bytes to get past the header fields | ||||
|     let varbindsPos = -1; | ||||
|     for (let i = 10; i < responsePdu.length - 3; i++) { | ||||
|       if (responsePdu[i] === 0x30) { | ||||
|         // Check if this is the start of the varbinds | ||||
|         // by seeing if it contains a varbind sequence | ||||
|         for (let j = i + 2; j < i + 10 && j < responsePdu.length - 3; j++) { | ||||
|           if (responsePdu[j] === 0x30) { | ||||
|             varbindsPos = i; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         if (varbindsPos !== -1) break; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     if (varbindsPos === -1) { | ||||
|       if (debug) { | ||||
|         console.log('Could not find varbinds in SNMPv3 response'); | ||||
|       } | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     // Get the varbinds | ||||
|     const varbindsLength = responsePdu[varbindsPos + 1]; | ||||
|     const varbinds = responsePdu.slice(varbindsPos, varbindsPos + 2 + varbindsLength); | ||||
|      | ||||
|     // Now search for values inside the varbinds | ||||
|     for (let i = 2; i < varbinds.length - 3; i++) { | ||||
|       // Look for a varbind sequence | ||||
|       if (varbinds[i] === 0x30) { | ||||
|         const varbindLength = varbinds[i + 1]; | ||||
|         const varbind = varbinds.slice(i, i + 2 + varbindLength); | ||||
|          | ||||
|         // Inside the varbind, look for the OID and then the value | ||||
|         for (let j = 0; j < varbind.length - 3; j++) { | ||||
|           if (varbind[j] === 0x06) { // OID | ||||
|             const oidLength = varbind[j + 1]; | ||||
|              | ||||
|             // The value should be right after the OID | ||||
|             const valuePos = j + 2 + oidLength; | ||||
|             if (valuePos < varbind.length - 1) { | ||||
|               // Check what type of value it is | ||||
|               if (varbind[valuePos] === 0x02) { // INTEGER | ||||
|                 const valueLength = varbind[valuePos + 1]; | ||||
|                 const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength); | ||||
|                 if (debug) { | ||||
|                   console.log('Found INTEGER value in SNMPv3 response:', value); | ||||
|                 } | ||||
|                 return value; | ||||
|               } else if (varbind[valuePos] === 0x42) { // Gauge32 | ||||
|                 const valueLength = varbind[valuePos + 1]; | ||||
|                 const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength); | ||||
|                 if (debug) { | ||||
|                   console.log('Found Gauge32 value in SNMPv3 response:', value); | ||||
|                 } | ||||
|                 return value; | ||||
|               } else if (varbind[valuePos] === 0x43) { // TimeTicks | ||||
|                 const valueLength = varbind[valuePos + 1]; | ||||
|                 const value = SnmpEncoder.decodeInteger(varbind, valuePos + 2, valueLength); | ||||
|                 if (debug) { | ||||
|                   console.log('Found TimeTicks value in SNMPv3 response:', value); | ||||
|                 } | ||||
|                 return value; | ||||
|               } else if (varbind[valuePos] === 0x04) { // OctetString | ||||
|                 const valueLength = varbind[valuePos + 1]; | ||||
|                 const value = varbind.slice(valuePos + 2, valuePos + 2 + valueLength).toString(); | ||||
|                 if (debug) { | ||||
|                   console.log('Found OctetString value in SNMPv3 response:', value); | ||||
|                 } | ||||
|                 return value; | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     if (debug) { | ||||
|       console.log('No valid value found in SNMPv3 response'); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extract engine ID from SNMPv3 response | ||||
|    * @param buffer Buffer containing the SNMP response | ||||
|    * @param debug Whether to enable debug output | ||||
|    * @returns Extracted engine ID or null if extraction failed | ||||
|    */ | ||||
|   public static extractEngineId(buffer: Buffer, debug: boolean = false): Buffer | null { | ||||
|     try { | ||||
|       // Simple parsing to find the engine ID | ||||
|       // Look for the first octet string with appropriate length | ||||
|       for (let i = 0; i < buffer.length - 10; i++) { | ||||
|         if (buffer[i] === 0x04) { // Octet string | ||||
|           const len = buffer[i + 1]; | ||||
|           if (len >= 5 && len <= 32) { // Engine IDs are typically 5-32 bytes | ||||
|             // Verify this looks like an engine ID (usually starts with 0x80) | ||||
|             if (buffer[i + 2] === 0x80) { | ||||
|               if (debug) { | ||||
|                 console.log('Found engine ID at position', i); | ||||
|                 console.log('Engine ID:', buffer.slice(i + 2, i + 2 + len).toString('hex')); | ||||
|               } | ||||
|               return buffer.slice(i + 2, i + 2 + len); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       return null; | ||||
|     } catch (error) { | ||||
|       console.error('Error extracting engine ID:', error); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -2,6 +2,8 @@ | ||||
|  * Type definitions for SNMP module | ||||
|  */ | ||||
|  | ||||
| import { Buffer } from 'node:buffer'; | ||||
|  | ||||
| /** | ||||
|  * UPS status interface | ||||
|  */ | ||||
| @@ -12,6 +14,14 @@ export interface IUpsStatus { | ||||
|   batteryCapacity: number; | ||||
|   /** Remaining runtime in minutes */ | ||||
|   batteryRuntime: number; | ||||
|   /** Output load percentage (0-100) */ | ||||
|   outputLoad: number; | ||||
|   /** Output power in watts */ | ||||
|   outputPower: number; | ||||
|   /** Output voltage in volts */ | ||||
|   outputVoltage: number; | ||||
|   /** Output current in amps */ | ||||
|   outputCurrent: number; | ||||
|   /** Raw values from SNMP responses */ | ||||
|   raw: Record<string, any>; | ||||
| } | ||||
| @@ -26,6 +36,21 @@ export interface IOidSet { | ||||
|   BATTERY_CAPACITY: string; | ||||
|   /** OID for battery runtime */ | ||||
|   BATTERY_RUNTIME: string; | ||||
|   /** OID for output load percentage */ | ||||
|   OUTPUT_LOAD: string; | ||||
|   /** OID for output power in watts */ | ||||
|   OUTPUT_POWER: string; | ||||
|   /** OID for output voltage */ | ||||
|   OUTPUT_VOLTAGE: string; | ||||
|   /** OID for output current */ | ||||
|   OUTPUT_CURRENT: string; | ||||
|   /** Power status value mappings */ | ||||
|   POWER_STATUS_VALUES?: { | ||||
|     /** SNMP value that indicates UPS is online (on AC power) */ | ||||
|     online: number; | ||||
|     /** SNMP value that indicates UPS is on battery */ | ||||
|     onBattery: number; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -45,11 +70,13 @@ export interface ISnmpConfig { | ||||
|   version: number; | ||||
|   /** Timeout in milliseconds */ | ||||
|   timeout: number; | ||||
|    | ||||
|  | ||||
|   context?: string; | ||||
|  | ||||
|   // SNMPv1/v2c | ||||
|   /** Community string for SNMPv1/v2c */ | ||||
|   community?: string; | ||||
|    | ||||
|  | ||||
|   // SNMPv3 | ||||
|   /** Security level for SNMPv3 */ | ||||
|   securityLevel?: 'noAuthNoPriv' | 'authNoPriv' | 'authPriv'; | ||||
| @@ -63,7 +90,7 @@ export interface ISnmpConfig { | ||||
|   privProtocol?: 'DES' | 'AES'; | ||||
|   /** Privacy key for SNMPv3 */ | ||||
|   privKey?: string; | ||||
|    | ||||
|  | ||||
|   // UPS model and custom OIDs | ||||
|   /** UPS model for OID selection */ | ||||
|   upsModel?: TUpsModel; | ||||
| @@ -87,4 +114,4 @@ export interface ISnmpV3SecurityParams { | ||||
|   msgAuthenticationParameters: Buffer; | ||||
|   /** Privacy parameters */ | ||||
|   msgPrivacyParameters: Buffer; | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										422
									
								
								ts/systemd.ts
									
									
									
									
									
								
							
							
						
						
									
										422
									
								
								ts/systemd.ts
									
									
									
									
									
								
							| @@ -1,6 +1,10 @@ | ||||
| import { promises as fs } from 'fs'; | ||||
| import { execSync } from 'child_process'; | ||||
| import { NupstDaemon } from './daemon.js'; | ||||
| 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 { NupstSnmp } from './snmp/manager.ts'; | ||||
| import { logger } from './logger.ts'; | ||||
| import { theme, symbols, getBatteryColor, getRuntimeColor, formatPowerStatus } from './colors.ts'; | ||||
|  | ||||
| /** | ||||
|  * Class for managing systemd service | ||||
| @@ -13,17 +17,17 @@ export class NupstSystemd { | ||||
|  | ||||
|   /** Template for the systemd service file */ | ||||
|   private readonly serviceTemplate = `[Unit] | ||||
| Description=Node.js UPS Shutdown Tool | ||||
| Description=NUPST - Deno-powered UPS Monitoring Tool | ||||
| After=network.target | ||||
|  | ||||
| [Service] | ||||
| ExecStart=/opt/nupst/bin/nupst daemon-start | ||||
| ExecStart=/usr/local/bin/nupst service start-daemon | ||||
| Restart=always | ||||
| RestartSec=10 | ||||
| User=root | ||||
| Group=root | ||||
| Environment=PATH=/usr/bin:/usr/local/bin | ||||
| Environment=NODE_ENV=production | ||||
| WorkingDirectory=/tmp | ||||
| WorkingDirectory=/opt/nupst | ||||
|  | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
| @@ -47,10 +51,11 @@ WantedBy=multi-user.target | ||||
|     try { | ||||
|       await fs.access(configPath); | ||||
|     } catch (error) { | ||||
|       console.error('┌─ Configuration Error ─────────────────────┐'); | ||||
|       console.error(`│ No configuration file found at ${configPath}`); | ||||
|       console.error('│ Please run \'nupst setup\' first to create a configuration.'); | ||||
|       console.error('└──────────────────────────────────────────┘'); | ||||
|       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(''); | ||||
|       throw new Error('Configuration not found'); | ||||
|     } | ||||
|   } | ||||
| @@ -63,26 +68,27 @@ WantedBy=multi-user.target | ||||
|     try { | ||||
|       // Check if configuration exists before installing | ||||
|       await this.checkConfigExists(); | ||||
|        | ||||
|  | ||||
|       // Write the service file | ||||
|       await fs.writeFile(this.serviceFilePath, this.serviceTemplate); | ||||
|       console.log('┌─ Service Installation ─────────────────────┐'); | ||||
|       console.log(`│ Service file created at ${this.serviceFilePath}`); | ||||
|       const boxWidth = 50; | ||||
|       logger.logBoxTitle('Service Installation', boxWidth); | ||||
|       logger.logBoxLine(`Service file created at ${this.serviceFilePath}`); | ||||
|  | ||||
|       // Reload systemd daemon | ||||
|       execSync('systemctl daemon-reload'); | ||||
|       console.log('│ Systemd daemon reloaded'); | ||||
|       logger.logBoxLine('Systemd daemon reloaded'); | ||||
|  | ||||
|       // Enable the service | ||||
|       execSync('systemctl enable nupst.service'); | ||||
|       console.log('│ Service enabled to start on boot'); | ||||
|       console.log('└──────────────────────────────────────────┘'); | ||||
|       logger.logBoxLine('Service enabled to start on boot'); | ||||
|       logger.logBoxEnd(); | ||||
|     } catch (error) { | ||||
|       if (error.message === 'Configuration not found') { | ||||
|       if (error instanceof Error && error.message === 'Configuration not found') { | ||||
|         // Just rethrow the error as the message has already been displayed | ||||
|         throw error; | ||||
|       } | ||||
|       console.error('Failed to install systemd service:', error); | ||||
|       logger.error(`Failed to install systemd service: ${error}`); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
| @@ -95,17 +101,18 @@ WantedBy=multi-user.target | ||||
|     try { | ||||
|       // Check if configuration exists before starting | ||||
|       await this.checkConfigExists(); | ||||
|        | ||||
|  | ||||
|       execSync('systemctl start nupst.service'); | ||||
|       console.log('┌─ Service Status ─────────────────────────┐'); | ||||
|       console.log('│ NUPST service started successfully'); | ||||
|       console.log('└──────────────────────────────────────────┘'); | ||||
|       const boxWidth = 45; | ||||
|       logger.logBoxTitle('Service Status', boxWidth); | ||||
|       logger.logBoxLine('NUPST service started successfully'); | ||||
|       logger.logBoxEnd(); | ||||
|     } catch (error) { | ||||
|       if (error.message === 'Configuration not found') { | ||||
|       if (error instanceof Error && error.message === 'Configuration not found') { | ||||
|         // Exit with error code since configuration is required | ||||
|         process.exit(1); | ||||
|       } | ||||
|       console.error('Failed to start service:', error); | ||||
|       logger.error(`Failed to start service: ${error}`); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
| @@ -114,12 +121,12 @@ WantedBy=multi-user.target | ||||
|    * Stop the systemd service | ||||
|    * @throws Error if stop fails | ||||
|    */ | ||||
|   public async stop(): Promise<void> { | ||||
|   public stop(): void { | ||||
|     try { | ||||
|       execSync('systemctl stop nupst.service'); | ||||
|       console.log('NUPST service stopped'); | ||||
|       logger.success('NUPST service stopped'); | ||||
|     } catch (error) { | ||||
|       console.error('Failed to stop service:', error); | ||||
|       logger.error(`Failed to stop service: ${error}`); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
| @@ -128,31 +135,72 @@ WantedBy=multi-user.target | ||||
|    * Get status of the systemd service and UPS | ||||
|    * @param debugMode Whether to enable debug mode for SNMP | ||||
|    */ | ||||
|   /** | ||||
|    * Display version information and update status | ||||
|    * @private | ||||
|    */ | ||||
|   private async displayVersionInfo(): Promise<void> { | ||||
|     try { | ||||
|       const nupst = this.daemon.getNupstSnmp().getNupst(); | ||||
|       const version = nupst.getVersion(); | ||||
|        | ||||
|       // Check for updates | ||||
|       const updateAvailable = await nupst.checkForUpdates(); | ||||
|        | ||||
|       // Display version info | ||||
|       if (updateAvailable) { | ||||
|         const updateStatus = nupst.getUpdateStatus(); | ||||
|         logger.log(''); | ||||
|         logger.log( | ||||
|           `${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')}`); | ||||
|       } else { | ||||
|         logger.log(''); | ||||
|         logger.log( | ||||
|           `${theme.dim('NUPST')} ${theme.dim('v' + version)}  ${symbols.success} ${theme.success('Up to date')}`, | ||||
|         ); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       // If version check fails, show at least the current version | ||||
|       try { | ||||
|         const nupst = this.daemon.getNupstSnmp().getNupst(); | ||||
|         const version = nupst.getVersion(); | ||||
|         logger.log(''); | ||||
|         logger.log(`${theme.dim('NUPST')} ${theme.dim('v' + version)}`); | ||||
|       } catch (_innerError) { | ||||
|         // Silently fail if we can't even get the version | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async getStatus(debugMode: boolean = false): Promise<void> { | ||||
|     try { | ||||
|       // Enable debug mode if requested | ||||
|       if (debugMode) { | ||||
|         console.log('┌─ Debug Mode ─────────────────────────────┐'); | ||||
|         console.log('│ SNMP debugging enabled - detailed logs will be shown'); | ||||
|         console.log('└──────────────────────────────────────────┘'); | ||||
|         console.log(''); | ||||
|         logger.info('Debug Mode: SNMP debugging enabled'); | ||||
|         console.log(''); | ||||
|         this.daemon.getNupstSnmp().enableDebug(); | ||||
|       } | ||||
|        | ||||
|       // Display version information | ||||
|       this.daemon.getNupstSnmp().getNupst().logVersionInfo(); | ||||
|        | ||||
|       // Check if config exists first | ||||
|  | ||||
|       // Display version and update status first | ||||
|       await this.displayVersionInfo(); | ||||
|  | ||||
|       // Check if config exists | ||||
|       try { | ||||
|         await this.checkConfigExists(); | ||||
|       } catch (error) { | ||||
|         // Error message already displayed by checkConfigExists | ||||
|         return; | ||||
|       } | ||||
|        | ||||
|  | ||||
|       await this.displayServiceStatus(); | ||||
|       await this.displayUpsStatus(); | ||||
|       await this.displayAllUpsStatus(); | ||||
|     } catch (error) { | ||||
|       console.error(`Failed to get status: ${error.message}`); | ||||
|       logger.error( | ||||
|         `Failed to get status: ${error instanceof Error ? error.message : String(error)}`, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -160,52 +208,258 @@ WantedBy=multi-user.target | ||||
|    * Display the systemd service status | ||||
|    * @private | ||||
|    */ | ||||
|   private async displayServiceStatus(): Promise<void> { | ||||
|   private displayServiceStatus(): void { | ||||
|     try { | ||||
|       const serviceStatus = execSync('systemctl status nupst.service').toString(); | ||||
|       console.log('┌─ Service Status ─────────────────────────┐'); | ||||
|       console.log(serviceStatus.split('\n').map(line => `│ ${line}`).join('\n')); | ||||
|       console.log('└──────────────────────────────────────────┘'); | ||||
|       const lines = serviceStatus.split('\n'); | ||||
|  | ||||
|       // Parse key information from systemctl output | ||||
|       let isActive = false; | ||||
|       let pid = ''; | ||||
|       let memory = ''; | ||||
|       let cpu = ''; | ||||
|  | ||||
|       for (const line of lines) { | ||||
|         if (line.includes('Active:')) { | ||||
|           isActive = line.includes('active (running)'); | ||||
|         } else if (line.includes('Main PID:')) { | ||||
|           const match = line.match(/Main PID:\s+(\d+)/); | ||||
|           if (match) pid = match[1]; | ||||
|         } else if (line.includes('Memory:')) { | ||||
|           const match = line.match(/Memory:\s+([\d.]+[A-Z])/); | ||||
|           if (match) memory = match[1]; | ||||
|         } else if (line.includes('CPU:')) { | ||||
|           const match = line.match(/CPU:\s+([\d.]+(?:ms|s))/); | ||||
|           if (match) cpu = match[1]; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // Display beautiful status | ||||
|       logger.log(''); | ||||
|       if (isActive) { | ||||
|         logger.log(`${symbols.running} ${theme.success('Service:')} ${theme.statusActive('active (running)')}`); | ||||
|       } else { | ||||
|         logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('inactive')}`); | ||||
|       } | ||||
|  | ||||
|       if (pid || memory || cpu) { | ||||
|         const details = []; | ||||
|         if (pid) details.push(`PID: ${theme.dim(pid)}`); | ||||
|         if (memory) details.push(`Memory: ${theme.dim(memory)}`); | ||||
|         if (cpu) details.push(`CPU: ${theme.dim(cpu)}`); | ||||
|         logger.log(`  ${details.join('  ')}`); | ||||
|       } | ||||
|       logger.log(''); | ||||
|  | ||||
|     } catch (error) { | ||||
|       console.error('┌─ Service Status ─────────────────────────┐'); | ||||
|       console.error('│ Service is not running'); | ||||
|       console.error('└──────────────────────────────────────────┘'); | ||||
|       logger.log(''); | ||||
|       logger.log(`${symbols.stopped} ${theme.dim('Service:')} ${theme.statusInactive('not installed')}`); | ||||
|       logger.log(''); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Display the UPS status | ||||
|    * Display all UPS statuses | ||||
|    * @private | ||||
|    */ | ||||
|   private async displayUpsStatus(): Promise<void> { | ||||
|   private async displayAllUpsStatus(): Promise<void> { | ||||
|     try { | ||||
|       // Explicitly load the configuration first to ensure it's up-to-date | ||||
|       await this.daemon.loadConfig(); | ||||
|       const config = this.daemon.getConfig(); | ||||
|       const snmp = this.daemon.getNupstSnmp(); | ||||
|        | ||||
|       // Create a test config with appropriate timeout, similar to the test command | ||||
|       const snmpConfig = {  | ||||
|         ...config.snmp, | ||||
|         timeout: Math.min(config.snmp.timeout, 10000) // Use at most 10 seconds for status check | ||||
|       }; | ||||
|        | ||||
|       console.log('┌─ Connecting to UPS... ────────────────────┐'); | ||||
|       console.log(`│ Host: ${config.snmp.host}:${config.snmp.port}`); | ||||
|       console.log(`│ UPS Model: ${config.snmp.upsModel || 'cyberpower'}`); | ||||
|       console.log('└──────────────────────────────────────────┘'); | ||||
|        | ||||
|       const status = await snmp.getUpsStatus(snmpConfig); | ||||
|        | ||||
|       console.log('┌─ UPS Status ───────────────────────────────┐'); | ||||
|       console.log(`│ Power Status: ${status.powerStatus}`); | ||||
|       console.log(`│ Battery Capacity: ${status.batteryCapacity}%`); | ||||
|       console.log(`│ Runtime Remaining: ${status.batteryRuntime} minutes`); | ||||
|       console.log('└──────────────────────────────────────────┘'); | ||||
|  | ||||
|       // Check if we have the new multi-UPS config format | ||||
|       if (config.upsDevices && Array.isArray(config.upsDevices) && config.upsDevices.length > 0) { | ||||
|         logger.info(`UPS Devices (${config.upsDevices.length}):`); | ||||
|  | ||||
|         // Show status for each UPS | ||||
|         for (const ups of config.upsDevices) { | ||||
|           await this.displaySingleUpsStatus(ups, snmp); | ||||
|         } | ||||
|  | ||||
|         // Display groups after UPS devices | ||||
|         this.displayGroupsStatus(); | ||||
|       } else if (config.snmp) { | ||||
|         // Legacy single UPS configuration (v1/v2 format) | ||||
|         logger.info('UPS Devices (1):'); | ||||
|         const legacyUps: IUpsConfig = { | ||||
|           id: 'default', | ||||
|           name: 'Default UPS', | ||||
|           snmp: config.snmp, | ||||
|           groups: [], | ||||
|           actions: config.thresholds | ||||
|             ? [ | ||||
|                 { | ||||
|                   type: 'shutdown', | ||||
|                   thresholds: config.thresholds, | ||||
|                   triggerMode: 'onlyThresholds', | ||||
|                   shutdownDelay: 5, | ||||
|                 }, | ||||
|               ] | ||||
|             : [], | ||||
|         }; | ||||
|  | ||||
|         await this.displaySingleUpsStatus(legacyUps, snmp); | ||||
|       } 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(''); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error('┌─ UPS Status ───────────────────────────────┐'); | ||||
|       console.error(`│ Failed to retrieve UPS status: ${error.message}`); | ||||
|       console.error('└──────────────────────────────────────────┘'); | ||||
|       logger.log(''); | ||||
|       logger.error('Failed to retrieve UPS status'); | ||||
|       logger.log(`  ${theme.dim(error instanceof Error ? error.message : String(error))}`); | ||||
|       logger.log(''); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Display status of a single UPS | ||||
|    * @param ups UPS configuration | ||||
|    * @param snmp SNMP manager | ||||
|    */ | ||||
|   private async displaySingleUpsStatus(ups: IUpsConfig, snmp: NupstSnmp): Promise<void> { | ||||
|     try { | ||||
|       // Create a test config with a short timeout | ||||
|       const testConfig = { | ||||
|         ...ups.snmp, | ||||
|         timeout: Math.min(ups.snmp.timeout, 10000), // Use at most 10 seconds for status check | ||||
|       }; | ||||
|  | ||||
|       const status = await snmp.getUpsStatus(testConfig); | ||||
|  | ||||
|       // Determine status symbol based on power status | ||||
|       let statusSymbol = symbols.unknown; | ||||
|       if (status.powerStatus === 'online') { | ||||
|         statusSymbol = symbols.running; | ||||
|       } else if (status.powerStatus === 'onBattery') { | ||||
|         statusSymbol = symbols.warning; | ||||
|       } | ||||
|  | ||||
|       // Display UPS name and power status | ||||
|       logger.log(`  ${statusSymbol} ${theme.highlight(ups.name)} - ${formatPowerStatus(status.powerStatus)}`); | ||||
|  | ||||
|       // Display battery with color coding | ||||
|       const batteryColor = getBatteryColor(status.batteryCapacity); | ||||
|  | ||||
|       // 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 | ||||
|         : ''; | ||||
|  | ||||
|       logger.log(`    Battery: ${batteryColor(status.batteryCapacity + '%')} ${batterySymbol}  Runtime: ${getRuntimeColor(status.batteryRuntime)(status.batteryRuntime + ' min')}`); | ||||
|  | ||||
|       // Display host info | ||||
|       logger.log(`    ${theme.dim(`Host: ${ups.snmp.host}:${ups.snmp.port}`)}`); | ||||
|  | ||||
|       // Display groups if any | ||||
|       if (ups.groups && ups.groups.length > 0) { | ||||
|         const config = this.daemon.getConfig(); | ||||
|         const groupNames = ups.groups.map((groupId: string) => { | ||||
|           const group = config.groups?.find((g: { id: string }) => g.id === groupId); | ||||
|           return group ? group.name : groupId; | ||||
|         }); | ||||
|         logger.log(`    ${theme.dim(`Groups: ${groupNames.join(', ')}`)}`); | ||||
|       } | ||||
|  | ||||
|       // Display actions if any | ||||
|       if (ups.actions && ups.actions.length > 0) { | ||||
|         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`; | ||||
|             if (action.shutdownDelay) { | ||||
|               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||
|             } | ||||
|             actionDesc += ')'; | ||||
|           } else { | ||||
|             actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; | ||||
|             if (action.shutdownDelay) { | ||||
|               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||
|             } | ||||
|             actionDesc += ')'; | ||||
|           } | ||||
|           logger.log(`    ${theme.dim('Action:')} ${theme.info(actionDesc)}`); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       logger.log(''); | ||||
|  | ||||
|     } catch (error) { | ||||
|       // Display error for this UPS | ||||
|       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(''); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Display status of all groups | ||||
|    * @private | ||||
|    */ | ||||
|   private displayGroupsStatus(): void { | ||||
|     const config = this.daemon.getConfig(); | ||||
|  | ||||
|     if (!config.groups || config.groups.length === 0) { | ||||
|       return; // No groups to display | ||||
|     } | ||||
|  | ||||
|     logger.log(''); | ||||
|     logger.info(`Groups (${config.groups.length}):`); | ||||
|  | ||||
|     for (const group of config.groups) { | ||||
|       // 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)})`)}`, | ||||
|       ); | ||||
|  | ||||
|       // Display description if present | ||||
|       if (group.description) { | ||||
|         logger.log(`    ${theme.dim(group.description)}`); | ||||
|       } | ||||
|  | ||||
|       // Display UPS devices in this group | ||||
|       const upsInGroup = config.upsDevices.filter((ups) => | ||||
|         ups.groups && ups.groups.includes(group.id) | ||||
|       ); | ||||
|  | ||||
|       if (upsInGroup.length > 0) { | ||||
|         const upsNames = upsInGroup.map((ups) => ups.name).join(', '); | ||||
|         logger.log(`    ${theme.dim(`UPS Devices (${upsInGroup.length}):`)} ${upsNames}`); | ||||
|       } else { | ||||
|         logger.log(`    ${theme.dim('UPS Devices: None')}`); | ||||
|       } | ||||
|  | ||||
|       // Display actions if any | ||||
|       if (group.actions && group.actions.length > 0) { | ||||
|         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`; | ||||
|             if (action.shutdownDelay) { | ||||
|               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||
|             } | ||||
|             actionDesc += ')'; | ||||
|           } else { | ||||
|             actionDesc += ` (${action.triggerMode || 'onlyPowerChanges'}`; | ||||
|             if (action.shutdownDelay) { | ||||
|               actionDesc += `, delay=${action.shutdownDelay}s`; | ||||
|             } | ||||
|             actionDesc += ')'; | ||||
|           } | ||||
|           logger.log(`    ${theme.dim('Action:')} ${theme.info(actionDesc)}`); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       logger.log(''); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -218,13 +472,13 @@ WantedBy=multi-user.target | ||||
|       await this.stopService(); | ||||
|       await this.disableService(); | ||||
|       await this.removeServiceFile(); | ||||
|        | ||||
|  | ||||
|       // Reload systemd daemon | ||||
|       execSync('systemctl daemon-reload'); | ||||
|       console.log('Systemd daemon reloaded'); | ||||
|       console.log('NUPST service has been successfully uninstalled'); | ||||
|       logger.log('Systemd daemon reloaded'); | ||||
|       logger.success('NUPST service has been successfully uninstalled'); | ||||
|     } catch (error) { | ||||
|       console.error('Failed to disable and uninstall service:', error); | ||||
|       logger.error(`Failed to disable and uninstall service: ${error}`); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
| @@ -233,13 +487,13 @@ WantedBy=multi-user.target | ||||
|    * Stop the service if it's running | ||||
|    * @private | ||||
|    */ | ||||
|   private async stopService(): Promise<void> { | ||||
|   private stopService(): void { | ||||
|     try { | ||||
|       console.log('Stopping NUPST service...'); | ||||
|       logger.log('Stopping NUPST service...'); | ||||
|       execSync('systemctl stop nupst.service'); | ||||
|     } catch (error) { | ||||
|       // Service might not be running, that's okay | ||||
|       console.log('Service was not running or could not be stopped'); | ||||
|       logger.log('Service was not running or could not be stopped'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -247,12 +501,12 @@ WantedBy=multi-user.target | ||||
|    * Disable the service | ||||
|    * @private | ||||
|    */ | ||||
|   private async disableService(): Promise<void> { | ||||
|   private disableService(): void { | ||||
|     try { | ||||
|       console.log('Disabling NUPST service...'); | ||||
|       logger.log('Disabling NUPST service...'); | ||||
|       execSync('systemctl disable nupst.service'); | ||||
|     } catch (error) { | ||||
|       console.log('Service was not enabled or could not be disabled'); | ||||
|       logger.log('Service was not enabled or could not be disabled'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -262,11 +516,11 @@ WantedBy=multi-user.target | ||||
|    */ | ||||
|   private async removeServiceFile(): Promise<void> { | ||||
|     if (await fs.stat(this.serviceFilePath).catch(() => null)) { | ||||
|       console.log(`Removing service file ${this.serviceFilePath}...`); | ||||
|       logger.log(`Removing service file ${this.serviceFilePath}...`); | ||||
|       await fs.unlink(this.serviceFilePath); | ||||
|       console.log('Service file removed'); | ||||
|       logger.log('Service file removed'); | ||||
|     } else { | ||||
|       console.log('Service file did not exist'); | ||||
|       logger.log('Service file did not exist'); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,15 +0,0 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "experimentalDecorators": true, | ||||
|     "emitDecoratorMetadata": true, | ||||
|     "useDefineForClassFields": false, | ||||
|     "target": "ES2022", | ||||
|     "module": "NodeNext", | ||||
|     "moduleResolution": "NodeNext", | ||||
|     "esModuleInterop": true, | ||||
|     "verbatimModuleSyntax": true | ||||
|   }, | ||||
|   "exclude": [ | ||||
|     "dist_*/**/*.d.ts" | ||||
|   ] | ||||
| } | ||||
		Reference in New Issue
	
	Block a user