initial
This commit is contained in:
314
ts/cli/config-handler.ts
Normal file
314
ts/cli/config-handler.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Config Handler
|
||||
*
|
||||
* CLI commands for configuration management.
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import { PATHS } from '../constants.ts';
|
||||
import type { IModelGridConfig } from '../interfaces/config.ts';
|
||||
import type { ITableColumn } from '../logger.ts';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Handler for configuration-related CLI commands
|
||||
*/
|
||||
export class ConfigHandler {
|
||||
/**
|
||||
* Show current configuration
|
||||
*/
|
||||
public async show(): Promise<void> {
|
||||
logger.log('');
|
||||
|
||||
try {
|
||||
const configPath = PATHS.CONFIG_FILE;
|
||||
const configContent = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(configContent) as IModelGridConfig;
|
||||
|
||||
// Overview
|
||||
logger.logBox(
|
||||
'ModelGrid Configuration',
|
||||
[
|
||||
`Version: ${theme.highlight(config.version)}`,
|
||||
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
|
||||
'',
|
||||
theme.dim('Configuration File:'),
|
||||
` ${theme.path(configPath)}`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
|
||||
// API Configuration
|
||||
logger.log('');
|
||||
logger.logBox(
|
||||
'API Server',
|
||||
[
|
||||
`Host: ${theme.info(config.api.host)}`,
|
||||
`Port: ${theme.highlight(String(config.api.port))}`,
|
||||
`API Keys: ${config.api.apiKeys.length} configured`,
|
||||
...(config.api.rateLimit
|
||||
? [`Rate Limit: ${config.api.rateLimit} req/min`]
|
||||
: []),
|
||||
'',
|
||||
theme.dim('Endpoint:'),
|
||||
` http://${config.api.host}:${config.api.port}/v1/chat/completions`,
|
||||
],
|
||||
60,
|
||||
'info',
|
||||
);
|
||||
|
||||
// Docker Configuration
|
||||
logger.log('');
|
||||
logger.logBox(
|
||||
'Docker',
|
||||
[
|
||||
`Runtime: ${theme.info(config.docker.runtime)}`,
|
||||
`Network: ${config.docker.networkName}`,
|
||||
],
|
||||
60,
|
||||
'default',
|
||||
);
|
||||
|
||||
// GPU Configuration
|
||||
logger.log('');
|
||||
logger.logBox(
|
||||
'GPU',
|
||||
[
|
||||
`Auto Detect: ${config.gpus.autoDetect ? theme.success('Yes') : theme.dim('No')}`,
|
||||
`Assignments: ${Object.keys(config.gpus.assignments).length} GPU(s)`,
|
||||
],
|
||||
60,
|
||||
'default',
|
||||
);
|
||||
|
||||
// Model Configuration
|
||||
logger.log('');
|
||||
logger.logBox(
|
||||
'Models',
|
||||
[
|
||||
`Auto Pull: ${config.models.autoPull ? theme.success('Enabled') : theme.dim('Disabled')}`,
|
||||
`Default Container: ${config.models.defaultContainer}`,
|
||||
`Auto Load: ${config.models.autoLoad.length} model(s)`,
|
||||
'',
|
||||
theme.dim('Greenlist URL:'),
|
||||
` ${config.models.greenlistUrl}`,
|
||||
],
|
||||
70,
|
||||
'default',
|
||||
);
|
||||
|
||||
// Containers
|
||||
if (config.containers.length > 0) {
|
||||
logger.log('');
|
||||
logger.info(`Containers (${config.containers.length}):`);
|
||||
logger.log('');
|
||||
|
||||
const rows = config.containers.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
image: c.image.length > 40 ? c.image.substring(0, 37) + '...' : c.image,
|
||||
port: c.port,
|
||||
gpus: c.gpuIds.length > 0 ? c.gpuIds.join(',') : theme.dim('None'),
|
||||
}));
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id', align: 'left' },
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'Type', key: 'type', align: 'left' },
|
||||
{ header: 'Image', key: 'image', align: 'left', color: theme.dim },
|
||||
{ header: 'Port', key: 'port', align: 'right' },
|
||||
{ header: 'GPUs', key: 'gpus', align: 'left' },
|
||||
];
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.logBox(
|
||||
'No Configuration',
|
||||
[
|
||||
'No configuration file found.',
|
||||
'',
|
||||
theme.dim('Create configuration with:'),
|
||||
` ${theme.command('modelgrid service enable')}`,
|
||||
'',
|
||||
theme.dim('Or manually create:'),
|
||||
` ${PATHS.CONFIG_FILE}`,
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
} else {
|
||||
logger.error(`Failed to read configuration: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default configuration
|
||||
*/
|
||||
public async init(): Promise<void> {
|
||||
const configPath = PATHS.CONFIG_FILE;
|
||||
|
||||
// Check if config already exists
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
logger.warn('Configuration file already exists');
|
||||
logger.dim(` ${configPath}`);
|
||||
return;
|
||||
} catch {
|
||||
// File doesn't exist, continue
|
||||
}
|
||||
|
||||
// Create config directory
|
||||
const configDir = PATHS.CONFIG_DIR;
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Create default config
|
||||
const defaultConfig: IModelGridConfig = {
|
||||
version: '1.0.0',
|
||||
api: {
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
apiKeys: [],
|
||||
cors: true,
|
||||
corsOrigins: ['*'],
|
||||
},
|
||||
docker: {
|
||||
networkName: 'modelgrid',
|
||||
runtime: 'docker',
|
||||
},
|
||||
gpus: {
|
||||
autoDetect: true,
|
||||
assignments: {},
|
||||
},
|
||||
containers: [],
|
||||
models: {
|
||||
greenlistUrl: 'https://code.foss.global/modelgrid.com/model_lists/raw/branch/main/greenlit.json',
|
||||
autoPull: true,
|
||||
defaultContainer: 'ollama',
|
||||
autoLoad: [],
|
||||
},
|
||||
checkInterval: 30000,
|
||||
};
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(defaultConfig, null, 2));
|
||||
|
||||
logger.success('Configuration initialized');
|
||||
logger.dim(` ${configPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an API key
|
||||
*/
|
||||
public async addApiKey(key?: string): Promise<void> {
|
||||
const configPath = PATHS.CONFIG_FILE;
|
||||
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(configContent) as IModelGridConfig;
|
||||
|
||||
// Generate key if not provided
|
||||
const apiKey = key || this.generateApiKey();
|
||||
|
||||
if (config.api.apiKeys.includes(apiKey)) {
|
||||
logger.warn('API key already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
config.api.apiKeys.push(apiKey);
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
logger.success('API key added:');
|
||||
logger.log(` ${theme.highlight(apiKey)}`);
|
||||
logger.log('');
|
||||
logger.dim('Use with Authorization header:');
|
||||
logger.dim(` curl -H "Authorization: Bearer ${apiKey}" ...`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add API key: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an API key
|
||||
*/
|
||||
public async removeApiKey(key: string): Promise<void> {
|
||||
if (!key) {
|
||||
logger.error('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = PATHS.CONFIG_FILE;
|
||||
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(configContent) as IModelGridConfig;
|
||||
|
||||
const index = config.api.apiKeys.indexOf(key);
|
||||
if (index === -1) {
|
||||
logger.warn('API key not found');
|
||||
return;
|
||||
}
|
||||
|
||||
config.api.apiKeys.splice(index, 1);
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
logger.success('API key removed');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove API key: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List API keys
|
||||
*/
|
||||
public async listApiKeys(): Promise<void> {
|
||||
const configPath = PATHS.CONFIG_FILE;
|
||||
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(configContent) as IModelGridConfig;
|
||||
|
||||
if (config.api.apiKeys.length === 0) {
|
||||
logger.warn('No API keys configured');
|
||||
logger.dim('Add a key with: modelgrid config apikey add');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`API Keys (${config.api.apiKeys.length}):`);
|
||||
logger.log('');
|
||||
|
||||
for (const key of config.api.apiKeys) {
|
||||
// Show partial key for security
|
||||
const masked = key.substring(0, 8) + '...' + key.substring(key.length - 4);
|
||||
logger.log(` ${masked}`);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list API keys: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random API key
|
||||
*/
|
||||
private generateApiKey(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const length = 48;
|
||||
let key = 'sk-';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
317
ts/cli/container-handler.ts
Normal file
317
ts/cli/container-handler.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Container Handler
|
||||
*
|
||||
* CLI commands for container management.
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import { ContainerManager } from '../containers/container-manager.ts';
|
||||
import { DockerManager } from '../docker/docker-manager.ts';
|
||||
import type { IContainerConfig } from '../interfaces/container.ts';
|
||||
import type { ITableColumn } from '../logger.ts';
|
||||
import * as helpers from '../helpers/index.ts';
|
||||
|
||||
/**
|
||||
* Handler for container-related CLI commands
|
||||
*/
|
||||
export class ContainerHandler {
|
||||
private containerManager: ContainerManager;
|
||||
private dockerManager: DockerManager;
|
||||
|
||||
constructor(containerManager: ContainerManager) {
|
||||
this.containerManager = containerManager;
|
||||
this.dockerManager = new DockerManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured containers
|
||||
*/
|
||||
public async list(): Promise<void> {
|
||||
logger.log('');
|
||||
logger.info('Containers');
|
||||
logger.log('');
|
||||
|
||||
const containers = this.containerManager.getAllContainers();
|
||||
|
||||
if (containers.length === 0) {
|
||||
logger.logBox(
|
||||
'No Containers',
|
||||
[
|
||||
'No containers are configured.',
|
||||
'',
|
||||
theme.dim('Add a container with:'),
|
||||
` ${theme.command('modelgrid container add')}`,
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (const container of containers) {
|
||||
const status = await container.getStatus();
|
||||
const config = container.getConfig();
|
||||
|
||||
rows.push({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
type: this.formatContainerType(container.type),
|
||||
status: status.running
|
||||
? theme.success('Running')
|
||||
: theme.dim('Stopped'),
|
||||
health: status.running
|
||||
? this.formatHealth(status.health)
|
||||
: theme.dim('N/A'),
|
||||
port: config.externalPort || config.port,
|
||||
models: status.loadedModels.length,
|
||||
gpus: config.gpuIds.length > 0 ? config.gpuIds.join(',') : theme.dim('None'),
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id', align: 'left' },
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'Type', key: 'type', align: 'left' },
|
||||
{ header: 'Status', key: 'status', align: 'left' },
|
||||
{ header: 'Health', key: 'health', align: 'left' },
|
||||
{ header: 'Port', key: 'port', align: 'right', color: theme.info },
|
||||
{ header: 'Models', key: 'models', align: 'right' },
|
||||
{ header: 'GPUs', key: 'gpus', align: 'left' },
|
||||
];
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new container interactively
|
||||
*/
|
||||
public async add(): Promise<void> {
|
||||
const { prompt, close, select } = await helpers.createPrompt();
|
||||
|
||||
try {
|
||||
logger.log('');
|
||||
logger.highlight('Add Container');
|
||||
logger.dim('Configure a new AI model container');
|
||||
logger.log('');
|
||||
|
||||
// Select container type
|
||||
const typeIndex = await select('Select container type:', [
|
||||
'Ollama - Easy to use, good for local models',
|
||||
'vLLM - High performance, OpenAI compatible',
|
||||
'TGI - HuggingFace Text Generation Inference',
|
||||
]);
|
||||
|
||||
const types = ['ollama', 'vllm', 'tgi'] as const;
|
||||
const containerType = types[typeIndex];
|
||||
|
||||
// Container name
|
||||
const name = await prompt('Container name: ');
|
||||
if (!name.trim()) {
|
||||
logger.error('Container name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate ID from name
|
||||
const id = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Port
|
||||
const defaultPorts = { ollama: 11434, vllm: 8000, tgi: 8080 };
|
||||
const portStr = await prompt(`Port [${defaultPorts[containerType]}]: `);
|
||||
const port = portStr ? parseInt(portStr, 10) : defaultPorts[containerType];
|
||||
|
||||
// GPU assignment
|
||||
const gpuStr = await prompt('GPU IDs (comma-separated, or "all", or empty for none): ');
|
||||
let gpuIds: string[] = [];
|
||||
|
||||
if (gpuStr.trim().toLowerCase() === 'all') {
|
||||
const { GpuDetector } = await import('../hardware/gpu-detector.ts');
|
||||
const detector = new GpuDetector();
|
||||
const gpus = await detector.detectGpus();
|
||||
gpuIds = gpus.map((g) => g.id);
|
||||
} else if (gpuStr.trim()) {
|
||||
gpuIds = gpuStr.split(',').map((s) => s.trim());
|
||||
}
|
||||
|
||||
// Build config
|
||||
const config: IContainerConfig = {
|
||||
id,
|
||||
type: containerType,
|
||||
name,
|
||||
image: this.getDefaultImage(containerType),
|
||||
port,
|
||||
gpuIds,
|
||||
models: [],
|
||||
};
|
||||
|
||||
// Add container
|
||||
await this.containerManager.addContainer(config);
|
||||
|
||||
logger.log('');
|
||||
logger.success(`Container "${name}" added successfully`);
|
||||
logger.log('');
|
||||
logger.dim('Start the container with:');
|
||||
logger.log(` ${theme.command(`modelgrid container start ${id}`)}`);
|
||||
logger.log('');
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a container
|
||||
*/
|
||||
public async remove(containerId: string): Promise<void> {
|
||||
if (!containerId) {
|
||||
logger.error('Container ID is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
|
||||
try {
|
||||
const confirm = await prompt(`Remove container "${containerId}"? (y/N): `);
|
||||
|
||||
if (confirm.toLowerCase() !== 'y') {
|
||||
logger.log('Aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await this.containerManager.removeContainer(containerId);
|
||||
|
||||
if (success) {
|
||||
logger.success(`Container "${containerId}" removed`);
|
||||
} else {
|
||||
logger.error(`Failed to remove container "${containerId}"`);
|
||||
}
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a container
|
||||
*/
|
||||
public async start(containerId?: string): Promise<void> {
|
||||
if (containerId) {
|
||||
// Start specific container
|
||||
const container = this.containerManager.getContainer(containerId);
|
||||
if (!container) {
|
||||
logger.error(`Container "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Starting container "${containerId}"...`);
|
||||
const success = await container.start();
|
||||
|
||||
if (success) {
|
||||
logger.success(`Container "${containerId}" started`);
|
||||
} else {
|
||||
logger.error(`Failed to start container "${containerId}"`);
|
||||
}
|
||||
} else {
|
||||
// Start all containers
|
||||
logger.info('Starting all containers...');
|
||||
await this.containerManager.startAll();
|
||||
logger.success('All containers started');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a container
|
||||
*/
|
||||
public async stop(containerId?: string): Promise<void> {
|
||||
if (containerId) {
|
||||
// Stop specific container
|
||||
const container = this.containerManager.getContainer(containerId);
|
||||
if (!container) {
|
||||
logger.error(`Container "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Stopping container "${containerId}"...`);
|
||||
const success = await container.stop();
|
||||
|
||||
if (success) {
|
||||
logger.success(`Container "${containerId}" stopped`);
|
||||
} else {
|
||||
logger.error(`Failed to stop container "${containerId}"`);
|
||||
}
|
||||
} else {
|
||||
// Stop all containers
|
||||
logger.info('Stopping all containers...');
|
||||
await this.containerManager.stopAll();
|
||||
logger.success('All containers stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show container logs
|
||||
*/
|
||||
public async logs(containerId: string, lines: number = 100): Promise<void> {
|
||||
if (!containerId) {
|
||||
logger.error('Container ID is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.containerManager.getContainer(containerId);
|
||||
if (!container) {
|
||||
logger.error(`Container "${containerId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = await container.getLogs(lines);
|
||||
console.log(logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format container type for display
|
||||
*/
|
||||
private formatContainerType(type: string): string {
|
||||
switch (type) {
|
||||
case 'ollama':
|
||||
return theme.containerOllama('Ollama');
|
||||
case 'vllm':
|
||||
return theme.containerVllm('vLLM');
|
||||
case 'tgi':
|
||||
return theme.containerTgi('TGI');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format health status
|
||||
*/
|
||||
private formatHealth(health: string): string {
|
||||
switch (health) {
|
||||
case 'healthy':
|
||||
return theme.success('Healthy');
|
||||
case 'unhealthy':
|
||||
return theme.error('Unhealthy');
|
||||
case 'starting':
|
||||
return theme.warning('Starting');
|
||||
default:
|
||||
return theme.dim(health);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default image for container type
|
||||
*/
|
||||
private getDefaultImage(type: string): string {
|
||||
switch (type) {
|
||||
case 'ollama':
|
||||
return 'ollama/ollama:latest';
|
||||
case 'vllm':
|
||||
return 'vllm/vllm-openai:latest';
|
||||
case 'tgi':
|
||||
return 'ghcr.io/huggingface/text-generation-inference:latest';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
255
ts/cli/gpu-handler.ts
Normal file
255
ts/cli/gpu-handler.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* GPU Handler
|
||||
*
|
||||
* CLI commands for GPU management.
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import { GpuDetector } from '../hardware/gpu-detector.ts';
|
||||
import { SystemInfo } from '../hardware/system-info.ts';
|
||||
import { DriverManager } from '../drivers/driver-manager.ts';
|
||||
import type { ITableColumn } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Handler for GPU-related CLI commands
|
||||
*/
|
||||
export class GpuHandler {
|
||||
private gpuDetector: GpuDetector;
|
||||
private systemInfo: SystemInfo;
|
||||
private driverManager: DriverManager;
|
||||
|
||||
constructor() {
|
||||
this.gpuDetector = new GpuDetector();
|
||||
this.systemInfo = new SystemInfo();
|
||||
this.driverManager = new DriverManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* List detected GPUs
|
||||
*/
|
||||
public async list(): Promise<void> {
|
||||
logger.log('');
|
||||
logger.info('Detecting GPUs...');
|
||||
logger.log('');
|
||||
|
||||
const gpus = await this.gpuDetector.detectGpus();
|
||||
|
||||
if (gpus.length === 0) {
|
||||
logger.logBox(
|
||||
'No GPUs Detected',
|
||||
[
|
||||
'No GPUs were found on this system.',
|
||||
'',
|
||||
theme.dim('Possible reasons:'),
|
||||
' - No discrete GPU installed',
|
||||
' - GPU drivers not installed',
|
||||
' - GPU not properly connected',
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = gpus.map((gpu) => ({
|
||||
id: gpu.id,
|
||||
vendor: this.formatVendor(gpu.vendor),
|
||||
model: gpu.model,
|
||||
vram: `${Math.round(gpu.vram / 1024)} GB`,
|
||||
driver: gpu.driverVersion || theme.dim('N/A'),
|
||||
cuda: gpu.cudaVersion || theme.dim('N/A'),
|
||||
pci: gpu.pciSlot,
|
||||
}));
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'ID', key: 'id', align: 'left' },
|
||||
{ header: 'Vendor', key: 'vendor', align: 'left' },
|
||||
{ header: 'Model', key: 'model', align: 'left', color: theme.highlight },
|
||||
{ header: 'VRAM', key: 'vram', align: 'right', color: theme.info },
|
||||
{ header: 'Driver', key: 'driver', align: 'left' },
|
||||
{ header: 'CUDA', key: 'cuda', align: 'left' },
|
||||
{ header: 'PCI', key: 'pci', align: 'left', color: theme.dim },
|
||||
];
|
||||
|
||||
logger.info(`Found ${gpus.length} GPU(s):`);
|
||||
logger.log('');
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show GPU status and utilization
|
||||
*/
|
||||
public async status(): Promise<void> {
|
||||
logger.log('');
|
||||
logger.info('GPU Status');
|
||||
logger.log('');
|
||||
|
||||
const gpuStatus = await this.gpuDetector.getGpuStatus();
|
||||
|
||||
if (gpuStatus.length === 0) {
|
||||
logger.warn('No GPUs detected');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const gpu of gpuStatus) {
|
||||
const utilizationBar = this.createProgressBar(gpu.utilization, 30);
|
||||
const memoryBar = this.createProgressBar(gpu.memoryUsed / gpu.memoryTotal * 100, 30);
|
||||
|
||||
logger.logBoxTitle(`GPU ${gpu.id}: ${gpu.name}`, 70, 'info');
|
||||
logger.logBoxLine(`Utilization: ${utilizationBar} ${gpu.utilization.toFixed(1)}%`);
|
||||
logger.logBoxLine(`Memory: ${memoryBar} ${Math.round(gpu.memoryUsed)}/${Math.round(gpu.memoryTotal)} MB`);
|
||||
logger.logBoxLine(`Temperature: ${this.formatTemperature(gpu.temperature)}`);
|
||||
logger.logBoxLine(`Power: ${gpu.powerDraw.toFixed(0)}W / ${gpu.powerLimit.toFixed(0)}W`);
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and install GPU drivers
|
||||
*/
|
||||
public async drivers(): Promise<void> {
|
||||
logger.log('');
|
||||
logger.info('GPU Driver Status');
|
||||
logger.log('');
|
||||
|
||||
// Get system info first
|
||||
const sysInfo = await this.systemInfo.getSystemInfo();
|
||||
|
||||
// Detect GPUs
|
||||
const gpus = await this.gpuDetector.detectGpus();
|
||||
|
||||
if (gpus.length === 0) {
|
||||
logger.warn('No GPUs detected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check driver status for each vendor
|
||||
const vendors = new Set(gpus.map((g) => g.vendor));
|
||||
|
||||
for (const vendor of vendors) {
|
||||
const driver = this.driverManager.getDriver(vendor);
|
||||
if (!driver) {
|
||||
logger.warn(`No driver support for ${vendor}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = await driver.getStatus();
|
||||
|
||||
logger.logBoxTitle(`${this.formatVendor(vendor)} Driver`, 60, status.installed ? 'success' : 'warning');
|
||||
logger.logBoxLine(`Installed: ${status.installed ? theme.success('Yes') : theme.error('No')}`);
|
||||
|
||||
if (status.installed) {
|
||||
logger.logBoxLine(`Version: ${status.version || 'Unknown'}`);
|
||||
logger.logBoxLine(`Runtime: ${status.runtimeVersion || 'Unknown'}`);
|
||||
logger.logBoxLine(`Container Support: ${status.containerSupport ? theme.success('Yes') : theme.warning('No')}`);
|
||||
} else {
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(theme.dim('Run `modelgrid gpu install` to install drivers'));
|
||||
}
|
||||
|
||||
logger.logBoxEnd();
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GPU drivers
|
||||
*/
|
||||
public async install(): Promise<void> {
|
||||
logger.log('');
|
||||
logger.info('Installing GPU Drivers');
|
||||
logger.log('');
|
||||
|
||||
// Detect GPUs
|
||||
const gpus = await this.gpuDetector.detectGpus();
|
||||
|
||||
if (gpus.length === 0) {
|
||||
logger.error('No GPUs detected - cannot install drivers');
|
||||
return;
|
||||
}
|
||||
|
||||
// Install drivers for each vendor
|
||||
const vendors = new Set(gpus.map((g) => g.vendor));
|
||||
|
||||
for (const vendor of vendors) {
|
||||
const driver = this.driverManager.getDriver(vendor);
|
||||
if (!driver) {
|
||||
logger.warn(`No driver installer for ${vendor}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Installing ${this.formatVendor(vendor)} drivers...`);
|
||||
|
||||
const success = await driver.install();
|
||||
|
||||
if (success) {
|
||||
logger.success(`${this.formatVendor(vendor)} drivers installed successfully`);
|
||||
|
||||
// Setup container support
|
||||
logger.info('Setting up container support...');
|
||||
const containerSuccess = await driver.setupContainer();
|
||||
|
||||
if (containerSuccess) {
|
||||
logger.success('Container support configured');
|
||||
} else {
|
||||
logger.warn('Container support setup failed - GPU passthrough may not work');
|
||||
}
|
||||
} else {
|
||||
logger.error(`Failed to install ${this.formatVendor(vendor)} drivers`);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format vendor name for display
|
||||
*/
|
||||
private formatVendor(vendor: string): string {
|
||||
switch (vendor) {
|
||||
case 'nvidia':
|
||||
return theme.gpuNvidia('NVIDIA');
|
||||
case 'amd':
|
||||
return theme.gpuAmd('AMD');
|
||||
case 'intel':
|
||||
return theme.gpuIntel('Intel');
|
||||
default:
|
||||
return vendor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a progress bar
|
||||
*/
|
||||
private createProgressBar(percent: number, width: number): string {
|
||||
const filled = Math.round((percent / 100) * width);
|
||||
const empty = width - filled;
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(empty);
|
||||
|
||||
if (percent >= 90) {
|
||||
return theme.error(bar);
|
||||
} else if (percent >= 70) {
|
||||
return theme.warning(bar);
|
||||
} else {
|
||||
return theme.success(bar);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format temperature with color coding
|
||||
*/
|
||||
private formatTemperature(temp: number): string {
|
||||
const tempStr = `${temp}°C`;
|
||||
|
||||
if (temp >= 85) {
|
||||
return theme.error(tempStr);
|
||||
} else if (temp >= 70) {
|
||||
return theme.warning(tempStr);
|
||||
} else {
|
||||
return theme.success(tempStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
202
ts/cli/model-handler.ts
Normal file
202
ts/cli/model-handler.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Model Handler
|
||||
*
|
||||
* CLI commands for model management.
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import { ContainerManager } from '../containers/container-manager.ts';
|
||||
import { ModelRegistry } from '../models/registry.ts';
|
||||
import { ModelLoader } from '../models/loader.ts';
|
||||
import type { ITableColumn } from '../logger.ts';
|
||||
|
||||
/**
|
||||
* Handler for model-related CLI commands
|
||||
*/
|
||||
export class ModelHandler {
|
||||
private containerManager: ContainerManager;
|
||||
private modelRegistry: ModelRegistry;
|
||||
private modelLoader: ModelLoader;
|
||||
|
||||
constructor(
|
||||
containerManager: ContainerManager,
|
||||
modelRegistry: ModelRegistry,
|
||||
) {
|
||||
this.containerManager = containerManager;
|
||||
this.modelRegistry = modelRegistry;
|
||||
this.modelLoader = new ModelLoader(modelRegistry, containerManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available models
|
||||
*/
|
||||
public async list(): Promise<void> {
|
||||
logger.log('');
|
||||
logger.info('Models');
|
||||
logger.log('');
|
||||
|
||||
// Get loaded models from containers
|
||||
const loadedModels = await this.containerManager.getAllAvailableModels();
|
||||
|
||||
// Get greenlit models
|
||||
const greenlitModels = await this.modelRegistry.getAllGreenlitModels();
|
||||
|
||||
if (loadedModels.size === 0 && greenlitModels.length === 0) {
|
||||
logger.logBox(
|
||||
'No Models',
|
||||
[
|
||||
'No models are loaded or greenlit.',
|
||||
'',
|
||||
theme.dim('Pull a model with:'),
|
||||
` ${theme.command('modelgrid model pull <name>')}`,
|
||||
],
|
||||
60,
|
||||
'warning',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loaded models
|
||||
if (loadedModels.size > 0) {
|
||||
logger.info(`Loaded Models (${loadedModels.size}):`);
|
||||
logger.log('');
|
||||
|
||||
const rows = [];
|
||||
for (const [name, info] of loadedModels) {
|
||||
rows.push({
|
||||
name,
|
||||
container: info.container,
|
||||
size: info.size ? this.formatSize(info.size) : theme.dim('N/A'),
|
||||
format: info.format || theme.dim('N/A'),
|
||||
modified: info.modifiedAt
|
||||
? new Date(info.modifiedAt).toLocaleDateString()
|
||||
: theme.dim('N/A'),
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
|
||||
{ header: 'Container', key: 'container', align: 'left' },
|
||||
{ header: 'Size', key: 'size', align: 'right', color: theme.info },
|
||||
{ header: 'Format', key: 'format', align: 'left' },
|
||||
{ header: 'Modified', key: 'modified', align: 'left', color: theme.dim },
|
||||
];
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
// Show greenlit models (not yet loaded)
|
||||
const loadedNames = new Set(loadedModels.keys());
|
||||
const unloadedGreenlit = greenlitModels.filter((m) => !loadedNames.has(m.name));
|
||||
|
||||
if (unloadedGreenlit.length > 0) {
|
||||
logger.info(`Available to Pull (${unloadedGreenlit.length}):`);
|
||||
logger.log('');
|
||||
|
||||
const rows = unloadedGreenlit.map((m) => ({
|
||||
name: m.name,
|
||||
container: m.container,
|
||||
vram: `${m.minVram} GB`,
|
||||
tags: m.tags?.join(', ') || theme.dim('None'),
|
||||
}));
|
||||
|
||||
const columns: ITableColumn[] = [
|
||||
{ header: 'Name', key: 'name', align: 'left' },
|
||||
{ header: 'Container', key: 'container', align: 'left' },
|
||||
{ header: 'Min VRAM', key: 'vram', align: 'right', color: theme.info },
|
||||
{ header: 'Tags', key: 'tags', align: 'left', color: theme.dim },
|
||||
];
|
||||
|
||||
logger.logTable(columns, rows);
|
||||
logger.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a model
|
||||
*/
|
||||
public async pull(modelName: string): Promise<void> {
|
||||
if (!modelName) {
|
||||
logger.error('Model name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
logger.info(`Pulling model: ${modelName}`);
|
||||
logger.log('');
|
||||
|
||||
const result = await this.modelLoader.loadModel(modelName);
|
||||
|
||||
if (result.success) {
|
||||
if (result.alreadyLoaded) {
|
||||
logger.success(`Model "${modelName}" is already loaded`);
|
||||
} else {
|
||||
logger.success(`Model "${modelName}" pulled successfully`);
|
||||
}
|
||||
if (result.container) {
|
||||
logger.dim(`Container: ${result.container}`);
|
||||
}
|
||||
} else {
|
||||
logger.error(`Failed to pull model: ${result.error}`);
|
||||
}
|
||||
|
||||
logger.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a model
|
||||
*/
|
||||
public async remove(modelName: string): Promise<void> {
|
||||
if (!modelName) {
|
||||
logger.error('Model name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Removing model: ${modelName}`);
|
||||
|
||||
const success = await this.modelLoader.unloadModel(modelName);
|
||||
|
||||
if (success) {
|
||||
logger.success(`Model "${modelName}" removed`);
|
||||
} else {
|
||||
logger.error(`Failed to remove model "${modelName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show model loading status and recommendations
|
||||
*/
|
||||
public async status(): Promise<void> {
|
||||
logger.log('');
|
||||
await this.modelLoader.printStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh greenlist cache
|
||||
*/
|
||||
public async refresh(): Promise<void> {
|
||||
logger.info('Refreshing greenlist...');
|
||||
|
||||
await this.modelRegistry.refreshGreenlist();
|
||||
|
||||
logger.success('Greenlist refreshed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
*/
|
||||
private formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
}
|
||||
252
ts/cli/service-handler.ts
Normal file
252
ts/cli/service-handler.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Service Handler
|
||||
*
|
||||
* CLI commands for systemd service management.
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { logger } from '../logger.ts';
|
||||
import { theme } from '../colors.ts';
|
||||
import { PATHS } from '../constants.ts';
|
||||
import type { ModelGrid } from '../modelgrid.ts';
|
||||
|
||||
/**
|
||||
* Handler for service-related CLI commands
|
||||
*/
|
||||
export class ServiceHandler {
|
||||
private readonly modelgrid: ModelGrid;
|
||||
|
||||
constructor(modelgrid: ModelGrid) {
|
||||
this.modelgrid = modelgrid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the service (requires root)
|
||||
*/
|
||||
public async enable(): Promise<void> {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
await this.modelgrid.getSystemd().install();
|
||||
logger.log('ModelGrid service has been installed. Use "modelgrid service start" to start the service.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon directly
|
||||
*/
|
||||
public async daemonStart(debugMode: boolean = false): Promise<void> {
|
||||
logger.log('Starting ModelGrid daemon...');
|
||||
try {
|
||||
if (debugMode) {
|
||||
logger.log('Debug mode enabled');
|
||||
}
|
||||
await this.modelgrid.getDaemon().start();
|
||||
} catch (error) {
|
||||
logger.error(`Daemon start failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show logs of the systemd service
|
||||
*/
|
||||
public async logs(): Promise<void> {
|
||||
try {
|
||||
const { spawn } = await import('child_process');
|
||||
logger.log('Tailing modelgrid service logs (Ctrl+C to exit)...\n');
|
||||
|
||||
const journalctl = spawn('journalctl', ['-u', 'modelgrid.service', '-n', '50', '-f'], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
journalctl.kill('SIGINT');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
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.modelgrid.getSystemd().stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the systemd service
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
try {
|
||||
await this.modelgrid.getSystemd().start();
|
||||
} catch (error) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show status of the systemd service
|
||||
*/
|
||||
public async status(): Promise<void> {
|
||||
await this.modelgrid.getSystemd().getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the service (requires root)
|
||||
*/
|
||||
public async disable(): Promise<void> {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
await this.modelgrid.getSystemd().disable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has root access
|
||||
*/
|
||||
private checkRootAccess(errorMessage: string): void {
|
||||
if (process.getuid && process.getuid() !== 0) {
|
||||
logger.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ModelGrid from repository
|
||||
*/
|
||||
public async update(): Promise<void> {
|
||||
try {
|
||||
this.checkRootAccess('This command must be run as root to update ModelGrid.');
|
||||
|
||||
console.log('');
|
||||
logger.info('Checking for updates...');
|
||||
|
||||
try {
|
||||
const currentVersion = this.modelgrid.getVersion();
|
||||
const apiUrl = 'https://code.foss.global/api/v1/repos/modelgrid.com/modelgrid/releases/latest';
|
||||
const response = execSync(`curl -sSL ${apiUrl}`).toString();
|
||||
const release = JSON.parse(response);
|
||||
const latestVersion = release.tag_name;
|
||||
|
||||
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('');
|
||||
|
||||
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('');
|
||||
|
||||
const installUrl = 'https://code.foss.global/modelgrid.com/modelgrid/raw/branch/main/install.sh';
|
||||
|
||||
execSync(`curl -sSL ${installUrl} | bash`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
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 ModelGrid from the system
|
||||
*/
|
||||
public async uninstall(): Promise<void> {
|
||||
this.checkRootAccess('This command must be run as root.');
|
||||
|
||||
try {
|
||||
const helpers = await import('../helpers/index.ts');
|
||||
const { prompt, close } = await helpers.createPrompt();
|
||||
|
||||
logger.log('');
|
||||
logger.highlight('ModelGrid Uninstaller');
|
||||
logger.dim('=====================');
|
||||
logger.log('This will completely remove ModelGrid from your system.');
|
||||
logger.log('');
|
||||
|
||||
const removeConfig = await prompt('Do you want to remove configuration files? (y/N): ');
|
||||
const removeContainers = await prompt('Do you want to remove Docker containers? (y/N): ');
|
||||
|
||||
close();
|
||||
|
||||
// Stop service first
|
||||
try {
|
||||
await this.modelgrid.getSystemd().stop();
|
||||
} catch {
|
||||
// Service might not be running
|
||||
}
|
||||
|
||||
// Disable service
|
||||
try {
|
||||
await this.modelgrid.getSystemd().disable();
|
||||
} catch {
|
||||
// Service might not be installed
|
||||
}
|
||||
|
||||
// Remove containers if requested
|
||||
if (removeContainers.toLowerCase() === 'y') {
|
||||
logger.info('Removing Docker containers...');
|
||||
try {
|
||||
execSync('docker rm -f $(docker ps -aq --filter "name=modelgrid")', { stdio: 'pipe' });
|
||||
} catch {
|
||||
// No containers to remove
|
||||
}
|
||||
}
|
||||
|
||||
// Remove configuration if requested
|
||||
if (removeConfig.toLowerCase() === 'y') {
|
||||
logger.info('Removing configuration...');
|
||||
try {
|
||||
const { rm } = await import('node:fs/promises');
|
||||
await rm(PATHS.CONFIG_DIR, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Config might not exist
|
||||
}
|
||||
}
|
||||
|
||||
// Run uninstall script
|
||||
const { dirname, join } = await import('path');
|
||||
const binPath = process.argv[1];
|
||||
const modulePath = dirname(dirname(binPath));
|
||||
const uninstallScriptPath = join(modulePath, 'uninstall.sh');
|
||||
|
||||
logger.log('');
|
||||
logger.log(`Running uninstaller from ${uninstallScriptPath}...`);
|
||||
|
||||
execSync(`sudo bash ${uninstallScriptPath}`, {
|
||||
env: {
|
||||
...process.env,
|
||||
REMOVE_CONFIG: removeConfig.toLowerCase() === 'y' ? 'yes' : 'no',
|
||||
MODELGRID_CLI_CALL: 'true',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user