2025-10-28 13:05:42 +00:00
|
|
|
/**
|
|
|
|
|
* Daemon Manager for Onebox
|
|
|
|
|
*
|
|
|
|
|
* Handles background monitoring, metrics collection, and automatic tasks
|
|
|
|
|
*/
|
|
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
import * as plugins from '../plugins.ts';
|
|
|
|
|
import { logger } from '../logging.ts';
|
|
|
|
|
import { projectInfo } from '../info.ts';
|
|
|
|
|
import type { Onebox } from './onebox.ts';
|
|
|
|
|
|
|
|
|
|
// PID file constants
|
|
|
|
|
const PID_FILE_PATH = '/var/run/onebox/onebox.pid';
|
|
|
|
|
const PID_DIR = '/var/run/onebox';
|
|
|
|
|
const FALLBACK_PID_DIR = `${Deno.env.get('HOME')}/.onebox`;
|
|
|
|
|
const FALLBACK_PID_FILE = `${FALLBACK_PID_DIR}/onebox.pid`;
|
2025-10-28 13:05:42 +00:00
|
|
|
|
|
|
|
|
export class OneboxDaemon {
|
|
|
|
|
private oneboxRef: Onebox;
|
2025-11-18 00:03:24 +00:00
|
|
|
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
|
2025-10-28 13:05:42 +00:00
|
|
|
private running = false;
|
|
|
|
|
private monitoringInterval: number | null = null;
|
|
|
|
|
private metricsInterval = 60000; // 1 minute
|
2025-11-18 00:03:24 +00:00
|
|
|
private pidFilePath: string = PID_FILE_PATH;
|
2025-11-24 01:31:15 +00:00
|
|
|
private lastDomainSync = 0; // Timestamp of last Cloudflare domain sync
|
|
|
|
|
private domainSyncInterval = 6 * 60 * 60 * 1000; // 6 hours
|
2025-10-28 13:05:42 +00:00
|
|
|
|
|
|
|
|
constructor(oneboxRef: Onebox) {
|
|
|
|
|
this.oneboxRef = oneboxRef;
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
/**
|
|
|
|
|
* Load settings from database (call after database init)
|
|
|
|
|
*/
|
|
|
|
|
private loadSettings(): void {
|
|
|
|
|
try {
|
|
|
|
|
const customInterval = this.oneboxRef.database.getSetting('metricsInterval');
|
|
|
|
|
if (customInterval) {
|
|
|
|
|
this.metricsInterval = parseInt(customInterval, 10);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Database not initialized yet - use defaults
|
2025-10-28 13:05:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Install systemd service
|
|
|
|
|
*/
|
|
|
|
|
async installService(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info('Installing Onebox daemon service...');
|
|
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
// Initialize smartdaemon if needed
|
|
|
|
|
if (!this.smartdaemon) {
|
|
|
|
|
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
// Get installation directory
|
|
|
|
|
const execPath = Deno.execPath();
|
|
|
|
|
|
|
|
|
|
const service = await this.smartdaemon.addService({
|
|
|
|
|
name: 'onebox',
|
|
|
|
|
version: projectInfo.version,
|
|
|
|
|
command: `${execPath} run --allow-all ${Deno.cwd()}/mod.ts daemon start`,
|
|
|
|
|
description: 'Onebox - Self-hosted container platform',
|
|
|
|
|
workingDir: Deno.cwd(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await service.save();
|
|
|
|
|
await service.enable();
|
|
|
|
|
|
|
|
|
|
logger.success('Onebox daemon service installed');
|
|
|
|
|
logger.info('Start with: sudo systemctl start smartdaemon_onebox');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to install daemon service: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Uninstall systemd service
|
|
|
|
|
*/
|
|
|
|
|
async uninstallService(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info('Uninstalling Onebox daemon service...');
|
|
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
// Initialize smartdaemon if needed
|
|
|
|
|
if (!this.smartdaemon) {
|
|
|
|
|
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
const service = await this.smartdaemon.getService('onebox');
|
|
|
|
|
|
|
|
|
|
if (service) {
|
|
|
|
|
await service.stop();
|
|
|
|
|
await service.disable();
|
|
|
|
|
await service.delete();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.success('Onebox daemon service uninstalled');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to uninstall daemon service: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start daemon mode (background monitoring)
|
|
|
|
|
*/
|
|
|
|
|
async start(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
if (this.running) {
|
|
|
|
|
logger.warn('Daemon already running');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info('Starting Onebox daemon...');
|
|
|
|
|
|
|
|
|
|
this.running = true;
|
|
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
// Load settings from database
|
|
|
|
|
this.loadSettings();
|
|
|
|
|
|
|
|
|
|
// Write PID file
|
|
|
|
|
await this.writePidFile();
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
// Start monitoring loop
|
|
|
|
|
this.startMonitoring();
|
|
|
|
|
|
|
|
|
|
// Start HTTP server
|
|
|
|
|
const httpPort = parseInt(this.oneboxRef.database.getSetting('httpPort') || '3000', 10);
|
|
|
|
|
await this.oneboxRef.httpServer.start(httpPort);
|
|
|
|
|
|
|
|
|
|
logger.success('Onebox daemon started');
|
|
|
|
|
logger.info(`Web UI available at http://localhost:${httpPort}`);
|
|
|
|
|
|
|
|
|
|
// Keep process alive
|
|
|
|
|
await this.keepAlive();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to start daemon: ${error.message}`);
|
|
|
|
|
this.running = false;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stop daemon mode
|
|
|
|
|
*/
|
|
|
|
|
async stop(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.running) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info('Stopping Onebox daemon...');
|
|
|
|
|
|
|
|
|
|
this.running = false;
|
|
|
|
|
|
|
|
|
|
// Stop monitoring
|
|
|
|
|
this.stopMonitoring();
|
|
|
|
|
|
|
|
|
|
// Stop HTTP server
|
|
|
|
|
await this.oneboxRef.httpServer.stop();
|
|
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
// Remove PID file
|
|
|
|
|
await this.removePidFile();
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
logger.success('Onebox daemon stopped');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to stop daemon: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start monitoring loop
|
|
|
|
|
*/
|
2025-11-18 00:03:24 +00:00
|
|
|
public startMonitoring(): void {
|
2025-10-28 13:05:42 +00:00
|
|
|
logger.info('Starting monitoring loop...');
|
|
|
|
|
|
|
|
|
|
this.monitoringInterval = setInterval(async () => {
|
|
|
|
|
await this.monitoringTick();
|
|
|
|
|
}, this.metricsInterval);
|
|
|
|
|
|
|
|
|
|
// Run first tick immediately
|
|
|
|
|
this.monitoringTick();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stop monitoring loop
|
|
|
|
|
*/
|
2025-11-18 00:03:24 +00:00
|
|
|
public stopMonitoring(): void {
|
2025-10-28 13:05:42 +00:00
|
|
|
if (this.monitoringInterval !== null) {
|
|
|
|
|
clearInterval(this.monitoringInterval);
|
|
|
|
|
this.monitoringInterval = null;
|
|
|
|
|
logger.debug('Monitoring loop stopped');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Single monitoring tick
|
|
|
|
|
*/
|
|
|
|
|
private async monitoringTick(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.debug('Running monitoring tick...');
|
|
|
|
|
|
|
|
|
|
// Collect metrics for all services
|
|
|
|
|
await this.collectMetrics();
|
|
|
|
|
|
|
|
|
|
// Sync service statuses
|
|
|
|
|
await this.oneboxRef.services.syncAllServiceStatuses();
|
|
|
|
|
|
|
|
|
|
// Check SSL certificate expiration
|
|
|
|
|
await this.checkSSLExpiration();
|
|
|
|
|
|
2025-11-24 01:31:15 +00:00
|
|
|
// Process pending certificate requirements
|
|
|
|
|
await this.processCertRequirements();
|
|
|
|
|
|
|
|
|
|
// Check for certificate renewal (every tick)
|
|
|
|
|
await this.checkCertificateRenewal();
|
|
|
|
|
|
|
|
|
|
// Clean up old certificates (every tick, but cleanup has built-in 90-day threshold)
|
|
|
|
|
await this.cleanupOldCertificates();
|
|
|
|
|
|
|
|
|
|
// Sync Cloudflare domains (less frequently - every 6 hours)
|
|
|
|
|
await this.syncCloudflareDomainsIfNeeded();
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
// Check service health (TODO: implement health checks)
|
|
|
|
|
|
|
|
|
|
logger.debug('Monitoring tick complete');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Monitoring tick failed: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Collect metrics for all services
|
|
|
|
|
*/
|
|
|
|
|
private async collectMetrics(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const services = this.oneboxRef.services.listServices();
|
|
|
|
|
|
|
|
|
|
for (const service of services) {
|
|
|
|
|
if (service.status === 'running' && service.containerID) {
|
|
|
|
|
try {
|
|
|
|
|
const stats = await this.oneboxRef.docker.getContainerStats(service.containerID);
|
|
|
|
|
|
|
|
|
|
if (stats) {
|
|
|
|
|
this.oneboxRef.database.addMetric({
|
|
|
|
|
serviceId: service.id!,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
cpuPercent: stats.cpuPercent,
|
|
|
|
|
memoryUsed: stats.memoryUsed,
|
|
|
|
|
memoryLimit: stats.memoryLimit,
|
|
|
|
|
networkRxBytes: stats.networkRx,
|
|
|
|
|
networkTxBytes: stats.networkTx,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.debug(`Failed to collect metrics for ${service.name}: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to collect metrics: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check SSL certificate expiration
|
|
|
|
|
*/
|
|
|
|
|
private async checkSSLExpiration(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.oneboxRef.ssl.isConfigured()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.oneboxRef.ssl.renewExpiring();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to check SSL expiration: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-24 01:31:15 +00:00
|
|
|
/**
|
|
|
|
|
* Process pending certificate requirements
|
|
|
|
|
*/
|
|
|
|
|
private async processCertRequirements(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await this.oneboxRef.certRequirementManager.processPendingRequirements();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to process cert requirements: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check certificates for renewal (30-day threshold)
|
|
|
|
|
*/
|
|
|
|
|
private async checkCertificateRenewal(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await this.oneboxRef.certRequirementManager.checkCertificateRenewal();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to check certificate renewal: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clean up old invalid certificates (90+ days old)
|
|
|
|
|
*/
|
|
|
|
|
private async cleanupOldCertificates(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await this.oneboxRef.certRequirementManager.cleanupOldCertificates();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to cleanup old certificates: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sync Cloudflare domains if needed (every 6 hours)
|
|
|
|
|
*/
|
|
|
|
|
private async syncCloudflareDomainsIfNeeded(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
// Check if it's time to sync (every 6 hours)
|
|
|
|
|
if (now - this.lastDomainSync < this.domainSyncInterval) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.oneboxRef.cloudflareDomainSync.isConfigured()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.oneboxRef.cloudflareDomainSync.syncZones();
|
|
|
|
|
this.lastDomainSync = now;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to sync Cloudflare domains: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
/**
|
|
|
|
|
* Keep process alive
|
|
|
|
|
*/
|
|
|
|
|
private async keepAlive(): Promise<void> {
|
|
|
|
|
// Set up signal handlers
|
|
|
|
|
const signalHandler = () => {
|
|
|
|
|
logger.info('Received shutdown signal');
|
|
|
|
|
this.stop().then(() => {
|
|
|
|
|
Deno.exit(0);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Deno.addSignalListener('SIGINT', signalHandler);
|
|
|
|
|
Deno.addSignalListener('SIGTERM', signalHandler);
|
|
|
|
|
|
|
|
|
|
// Keep event loop alive
|
|
|
|
|
while (this.running) {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get daemon status
|
|
|
|
|
*/
|
|
|
|
|
isRunning(): boolean {
|
|
|
|
|
return this.running;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
/**
|
|
|
|
|
* Write PID file
|
|
|
|
|
*/
|
|
|
|
|
private async writePidFile(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
// Try primary location first
|
|
|
|
|
try {
|
|
|
|
|
await Deno.mkdir(PID_DIR, { recursive: true });
|
|
|
|
|
await Deno.writeTextFile(PID_FILE_PATH, Deno.pid.toString());
|
|
|
|
|
this.pidFilePath = PID_FILE_PATH;
|
|
|
|
|
logger.debug(`PID file written: ${PID_FILE_PATH}`);
|
|
|
|
|
return;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Permission denied - try fallback location
|
|
|
|
|
logger.debug(`Cannot write to ${PID_DIR}, using fallback location`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to user directory
|
|
|
|
|
await Deno.mkdir(FALLBACK_PID_DIR, { recursive: true });
|
|
|
|
|
await Deno.writeTextFile(FALLBACK_PID_FILE, Deno.pid.toString());
|
|
|
|
|
this.pidFilePath = FALLBACK_PID_FILE;
|
|
|
|
|
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.warn(`Failed to write PID file: ${error.message}`);
|
|
|
|
|
// Non-fatal - daemon can still run
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove PID file
|
|
|
|
|
*/
|
|
|
|
|
private async removePidFile(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await Deno.remove(this.pidFilePath);
|
|
|
|
|
logger.debug(`PID file removed: ${this.pidFilePath}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Ignore errors - file might not exist
|
|
|
|
|
logger.debug(`Could not remove PID file: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if daemon is running
|
|
|
|
|
*/
|
|
|
|
|
static async isDaemonRunning(): Promise<boolean> {
|
|
|
|
|
const pid = await OneboxDaemon.getDaemonPid();
|
|
|
|
|
if (!pid) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Check if process exists
|
|
|
|
|
await Deno.stat(`/proc/${pid}`);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
// Process doesn't exist - clean up stale PID file
|
|
|
|
|
logger.debug(`Cleaning up stale PID file`);
|
|
|
|
|
try {
|
|
|
|
|
await Deno.remove(PID_FILE_PATH);
|
|
|
|
|
} catch {
|
|
|
|
|
try {
|
|
|
|
|
await Deno.remove(FALLBACK_PID_FILE);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get daemon PID
|
|
|
|
|
*/
|
|
|
|
|
static async getDaemonPid(): Promise<number | null> {
|
|
|
|
|
try {
|
|
|
|
|
// Try primary location
|
|
|
|
|
try {
|
|
|
|
|
const pidText = await Deno.readTextFile(PID_FILE_PATH);
|
|
|
|
|
return parseInt(pidText.trim(), 10);
|
|
|
|
|
} catch {
|
|
|
|
|
// Try fallback location
|
|
|
|
|
const pidText = await Deno.readTextFile(FALLBACK_PID_FILE);
|
|
|
|
|
return parseInt(pidText.trim(), 10);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure no daemon is running
|
|
|
|
|
*/
|
|
|
|
|
static async ensureNoDaemon(): Promise<void> {
|
|
|
|
|
const running = await OneboxDaemon.isDaemonRunning();
|
|
|
|
|
if (running) {
|
|
|
|
|
throw new Error('Daemon is already running. Please stop it first with: onebox daemon stop');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
/**
|
|
|
|
|
* Get service status from systemd
|
|
|
|
|
*/
|
|
|
|
|
async getServiceStatus(): Promise<string> {
|
|
|
|
|
try {
|
2025-11-18 00:03:24 +00:00
|
|
|
// Don't need smartdaemon to check status, just use systemctl directly
|
2025-10-28 13:05:42 +00:00
|
|
|
const command = new Deno.Command('systemctl', {
|
|
|
|
|
args: ['status', 'smartdaemon_onebox'],
|
|
|
|
|
stdout: 'piped',
|
|
|
|
|
stderr: 'piped',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { code, stdout } = await command.output();
|
|
|
|
|
const output = new TextDecoder().decode(stdout);
|
|
|
|
|
|
|
|
|
|
if (code === 0 || output.includes('active (running)')) {
|
|
|
|
|
return 'running';
|
|
|
|
|
} else if (output.includes('inactive') || output.includes('dead')) {
|
|
|
|
|
return 'stopped';
|
|
|
|
|
} else if (output.includes('failed')) {
|
|
|
|
|
return 'failed';
|
|
|
|
|
} else {
|
|
|
|
|
return 'unknown';
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return 'not-installed';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|