import * as path from 'node:path'; import * as fs from 'node:fs'; 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 { // 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 { // 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; } } }