import process from 'node:process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { execSync } from 'node:child_process'; import { Nupst } from '../nupst.ts'; import { logger } from '../logger.ts'; import { theme } from '../colors.ts'; import { PAUSE } from '../constants.ts'; import type { IPauseState } from '../daemon.ts'; import * as helpers from '../helpers/index.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 { 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 { 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 { 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((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 { await this.nupst.getSystemd().stop(); } /** * Start the systemd service */ public async start(): Promise { 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 { // Extract debug options from args array const debugOptions = this.extractDebugOptions(process.argv); await this.nupst.getSystemd().getStatus(debugOptions.debugMode); } /** * Pause action monitoring * @param args Command arguments (e.g., ['--duration', '30m']) */ public async pause(args: string[]): Promise { try { // Parse --duration argument let resumeAt: number | null = null; const durationIdx = args.indexOf('--duration'); if (durationIdx !== -1 && args[durationIdx + 1]) { const durationStr = args[durationIdx + 1]; const durationMs = this.parseDuration(durationStr); if (durationMs === null) { logger.error(`Invalid duration format: ${durationStr}`); logger.dim(' Valid formats: 30m, 2h, 1d (minutes, hours, days)'); return; } if (durationMs > PAUSE.MAX_DURATION_MS) { logger.error(`Duration exceeds maximum of 24 hours`); return; } resumeAt = Date.now() + durationMs; } // Check if already paused if (fs.existsSync(PAUSE.FILE_PATH)) { logger.warn('Monitoring is already paused'); try { const data = fs.readFileSync(PAUSE.FILE_PATH, 'utf8'); const state = JSON.parse(data) as IPauseState; logger.dim(` Paused at: ${new Date(state.pausedAt).toISOString()}`); if (state.resumeAt) { const remaining = Math.round((state.resumeAt - Date.now()) / 1000); logger.dim(` Auto-resume in: ${remaining > 0 ? remaining : 0} seconds`); } } catch (_e) { // Ignore parse errors } logger.dim(' Run "nupst resume" to resume monitoring'); return; } // Create pause state const pauseState: IPauseState = { pausedAt: Date.now(), pausedBy: 'cli', resumeAt, }; // Ensure config directory exists const pauseDir = path.dirname(PAUSE.FILE_PATH); if (!fs.existsSync(pauseDir)) { fs.mkdirSync(pauseDir, { recursive: true }); } fs.writeFileSync(PAUSE.FILE_PATH, JSON.stringify(pauseState, null, 2)); logger.log(''); logger.logBoxTitle('Monitoring Paused', 45, 'warning'); logger.logBoxLine('UPS polling continues but actions are suppressed'); if (resumeAt) { const durationStr = args[args.indexOf('--duration') + 1]; logger.logBoxLine(`Auto-resume after: ${durationStr}`); logger.logBoxLine(`Resume at: ${new Date(resumeAt).toISOString()}`); } else { logger.logBoxLine('Duration: Indefinite'); logger.logBoxLine('Run "nupst resume" to resume'); } logger.logBoxEnd(); logger.log(''); } catch (error) { logger.error( `Failed to pause: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Resume action monitoring */ public async resume(): Promise { try { if (!fs.existsSync(PAUSE.FILE_PATH)) { logger.info('Monitoring is not paused'); return; } fs.unlinkSync(PAUSE.FILE_PATH); logger.log(''); logger.logBoxTitle('Monitoring Resumed', 45, 'success'); logger.logBoxLine('Action monitoring has been resumed'); logger.logBoxEnd(); logger.log(''); } catch (error) { logger.error( `Failed to resume: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Parse a duration string like '30m', '2h', '1d' into milliseconds */ private parseDuration(duration: string): number | null { const match = duration.match(/^(\d+)\s*(m|h|d)$/i); if (!match) return null; const value = parseInt(match[1], 10); const unit = match[2].toLowerCase(); switch (unit) { case 'm': return value * 60 * 1000; case 'h': return value * 60 * 60 * 1000; case 'd': return value * 24 * 60 * 60 * 1000; default: return null; } } /** * Disable the service (requires root) */ public async disable(): Promise { 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 update(): 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 { // Check if running as root this.checkRootAccess('This command must be run as root.'); try { const { prompt, close } = await helpers.createPrompt(); 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.'); close(); process.exit(1); } } // Close prompt before executing script close(); // 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 }; } }