Files
nupst/ts/cli/service-handler.ts

415 lines
13 KiB
TypeScript

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<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);
}
/**
* Pause action monitoring
* @param args Command arguments (e.g., ['--duration', '30m'])
*/
public async pause(args: string[]): Promise<void> {
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<void> {
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<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 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<void> {
// 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 };
}
}