mirror of
https://github.com/unraid/api.git
synced 2025-12-21 16:49:37 -06:00
fix: enhance plugin management with interactive removal prompts (#1549)
- Add RemovePluginQuestionSet for interactive plugin removal - Update plugin commands to use PluginManagementService - Improve plugin installation error handling and warnings - Clean up test fixtures and update plugin command tests - Reset dev config to clean state (v4.11.0, no plugins) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Improved plugin management in the CLI with interactive prompts for plugin removal and enhanced error handling. * CLI plugin commands now provide clearer user feedback and warnings for missing plugins. * Added log suppression capability and dedicated plugin log file support. * **Refactor** * Plugin CLI commands now use dedicated management services and interactive prompts instead of direct GraphQL operations, streamlining workflows and improving reliability. * Simplified CLI imports and logging for more straightforward error handling. * Deferred plugin module logging to application bootstrap for improved lifecycle management. * Updated logging service to respect global log suppression and added unconditional logging method. * **Tests** * Refactored plugin CLI command tests for better isolation and coverage, using service mocks and enhanced prompt simulations. * Updated report command tests to reflect new logging behavior. * **Chores** * Updated API configuration settings and removed outdated test fixture files and timestamp data. * Simplified test file logic by removing remote file download and cache functionality. * Adjusted build configuration to conditionally set CLI shebang based on environment. * Enhanced configuration file handler to optionally accept external logging. * Updated CLI command script to set environment variable for testing. * Added environment variables for log file paths and log suppression. * Updated logging setup to conditionally suppress logs and write plugin logs to file. * Improved error and output logging consistency across CLI commands. * Added placeholder file to ensure log directory version control tracking. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Pujit Mehrotra <pujit@lime-technology.com>
This commit is contained in:
@@ -15,6 +15,7 @@ PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PATHS_RCLONE_SOCKET=./dev/rclone-socket
|
||||
PATHS_LOG_BASE=./dev/log # Where we store logs
|
||||
PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
|
||||
ENVIRONMENT="development"
|
||||
NODE_ENV="development"
|
||||
|
||||
@@ -13,5 +13,6 @@ PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
|
||||
PATHS_CONFIG_MODULES=./dev/configs
|
||||
PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||
PORT=5000
|
||||
NODE_ENV="test"
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"version": "4.10.0",
|
||||
"extraOrigins": [
|
||||
"https://google.com",
|
||||
"https://test.com"
|
||||
],
|
||||
"sandbox": true,
|
||||
"version": "4.11.0",
|
||||
"extraOrigins": [],
|
||||
"sandbox": false,
|
||||
"ssoSubIds": [],
|
||||
"plugins": [
|
||||
"unraid-api-plugin-connect"
|
||||
]
|
||||
"plugins": []
|
||||
}
|
||||
1
api/dev/log/.gitkeep
Normal file
1
api/dev/log/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# custom log directory for tests & development
|
||||
@@ -17,7 +17,7 @@
|
||||
"start": "node dist/main.js",
|
||||
"dev": "vite",
|
||||
"dev:debug": "NODE_OPTIONS='--inspect-brk=9229 --enable-source-maps' vite",
|
||||
"command": "pnpm run build && clear && ./dist/cli.js",
|
||||
"command": "COMMAND_TESTER=true pnpm run build > /dev/null 2>&1 && NODE_ENV=development ./dist/cli.js",
|
||||
"command:raw": "./dist/cli.js",
|
||||
"// Build and Deploy": "",
|
||||
"build": "vite build --mode=production",
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import '@app/dotenv.js';
|
||||
|
||||
import { execa } from 'execa';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { CommandFactory } from 'nest-commander';
|
||||
|
||||
import { internalLogger, logger } from '@app/core/log.js';
|
||||
import { LOG_LEVEL } from '@app/environment.js';
|
||||
import { CliModule } from '@app/unraid-api/cli/cli.module.js';
|
||||
import { LOG_LEVEL, SUPPRESS_LOGS } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
const getUnraidApiLocation = async () => {
|
||||
const { execa } = await import('execa');
|
||||
try {
|
||||
const shellToUse = await execa('which unraid-api');
|
||||
return shellToUse.stdout.trim();
|
||||
} catch (err) {
|
||||
logger.debug('Could not find unraid-api in PATH, using default location');
|
||||
|
||||
return '/usr/bin/unraid-api';
|
||||
}
|
||||
};
|
||||
|
||||
const getLogger = () => {
|
||||
if (LOG_LEVEL === 'TRACE' && !SUPPRESS_LOGS) {
|
||||
return new LogService();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const logger = getLogger();
|
||||
try {
|
||||
await import('json-bigint-patch');
|
||||
const { CliModule } = await import('@app/unraid-api/cli/cli.module.js');
|
||||
|
||||
await CommandFactory.run(CliModule, {
|
||||
cliName: 'unraid-api',
|
||||
logger: LOG_LEVEL === 'TRACE' ? new LogService() : false, // - enable this to see nest initialization issues
|
||||
logger: logger, // - enable this to see nest initialization issues
|
||||
completion: {
|
||||
fig: false,
|
||||
cmd: 'completion-script',
|
||||
@@ -32,10 +40,8 @@ try {
|
||||
});
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('ERROR:', error);
|
||||
internalLogger.error({
|
||||
message: 'Failed to start unraid-api',
|
||||
error,
|
||||
});
|
||||
if (logger) {
|
||||
logger.error('ERROR:', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pino } from 'pino';
|
||||
import pretty from 'pino-pretty';
|
||||
|
||||
import { API_VERSION, LOG_LEVEL, LOG_TYPE } from '@app/environment.js';
|
||||
import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js';
|
||||
|
||||
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
|
||||
|
||||
@@ -9,18 +9,30 @@ export type LogLevel = (typeof levels)[number];
|
||||
|
||||
const level = levels[levels.indexOf(LOG_LEVEL.toLowerCase() as LogLevel)] ?? 'info';
|
||||
|
||||
export const logDestination = pino.destination();
|
||||
const nullDestination = pino.destination({
|
||||
write() {
|
||||
// Suppress all logs
|
||||
},
|
||||
});
|
||||
|
||||
const stream =
|
||||
LOG_TYPE === 'pretty'
|
||||
? pretty({
|
||||
singleLine: true,
|
||||
hideObject: false,
|
||||
colorize: true,
|
||||
ignore: 'hostname,pid',
|
||||
destination: logDestination,
|
||||
})
|
||||
: logDestination;
|
||||
export const logDestination =
|
||||
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
|
||||
const localFileDestination = pino.destination({
|
||||
dest: PATHS_LOGS_FILE,
|
||||
sync: true,
|
||||
});
|
||||
|
||||
const stream = SUPPRESS_LOGS
|
||||
? nullDestination
|
||||
: LOG_TYPE === 'pretty'
|
||||
? pretty({
|
||||
singleLine: true,
|
||||
hideObject: false,
|
||||
colorize: true,
|
||||
ignore: 'hostname,pid',
|
||||
destination: logDestination,
|
||||
})
|
||||
: logDestination;
|
||||
|
||||
export const logger = pino(
|
||||
{
|
||||
@@ -70,6 +82,7 @@ export const keyServerLogger = logger.child({ logger: 'key-server' });
|
||||
export const remoteAccessLogger = logger.child({ logger: 'remote-access' });
|
||||
export const remoteQueryLogger = logger.child({ logger: 'remote-query' });
|
||||
export const apiLogger = logger.child({ logger: 'api' });
|
||||
export const pluginLogger = logger.child({ logger: 'plugin', stream: localFileDestination });
|
||||
|
||||
export const loggers = [
|
||||
internalLogger,
|
||||
|
||||
@@ -92,6 +92,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL
|
||||
: process.env.ENVIRONMENT === 'production'
|
||||
? 'INFO'
|
||||
: 'DEBUG';
|
||||
export const SUPPRESS_LOGS = process.env.SUPPRESS_LOGS === 'true';
|
||||
export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
|
||||
? process.env.MOTHERSHIP_GRAPHQL_LINK
|
||||
: ENVIRONMENT === 'staging'
|
||||
@@ -101,7 +102,9 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
|
||||
export const PM2_HOME = process.env.PM2_HOME ?? join(homedir(), '.pm2');
|
||||
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2');
|
||||
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
|
||||
export const LOGS_DIR = process.env.LOGS_DIR ?? '/var/log/unraid-api';
|
||||
export const PATHS_LOGS_DIR =
|
||||
process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api';
|
||||
export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log';
|
||||
|
||||
export const PATHS_CONFIG_MODULES =
|
||||
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';
|
||||
|
||||
@@ -1,35 +1,65 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { ILogService, LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
InstallPluginCommand,
|
||||
ListPluginCommand,
|
||||
RemovePluginCommand,
|
||||
} from '@app/unraid-api/cli/plugins/plugin.command.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
const mockLogger: ILogService = {
|
||||
clear: vi.fn(),
|
||||
shouldLog: vi.fn(),
|
||||
log: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
table: vi.fn(),
|
||||
always: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRestartCommand = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPluginManagementService = {
|
||||
addPlugin: vi.fn(),
|
||||
addBundledPlugin: vi.fn(),
|
||||
removePlugin: vi.fn(),
|
||||
removeBundledPlugin: vi.fn(),
|
||||
plugins: [] as string[],
|
||||
};
|
||||
|
||||
const mockInquirerService = {
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApiConfigPersistence = {
|
||||
persist: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@app/unraid-api/plugin/plugin.service.js', () => ({
|
||||
PluginService: {
|
||||
listPlugins: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Plugin Commands', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mocks before each test
|
||||
vi.clearAllMocks();
|
||||
// Reset process.exitCode
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
describe('InstallPluginCommand', () => {
|
||||
@@ -39,9 +69,10 @@ describe('Plugin Commands', () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
InstallPluginCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
{ provide: PluginManagementService, useValue: mockPluginManagementService },
|
||||
{ provide: ApiConfigPersistence, useValue: mockApiConfigPersistence },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -49,88 +80,39 @@ describe('Plugin Commands', () => {
|
||||
});
|
||||
|
||||
it('should install a plugin successfully', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
addPlugin: false, // No manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/plugin-example'], { bundled: false, restart: true });
|
||||
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
names: ['@unraid/plugin-example'],
|
||||
bundled: false,
|
||||
restart: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockPluginManagementService.addPlugin).toHaveBeenCalledWith('@unraid/plugin-example');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Added plugin @unraid/plugin-example');
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled(); // Because addPlugin returned false
|
||||
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle bundled plugin installation', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
addPlugin: true, // Manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/bundled-plugin'], { bundled: true, restart: true });
|
||||
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
names: ['@unraid/bundled-plugin'],
|
||||
bundled: true,
|
||||
restart: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockPluginManagementService.addBundledPlugin).toHaveBeenCalledWith(
|
||||
'@unraid/bundled-plugin'
|
||||
);
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Added bundled plugin @unraid/bundled-plugin');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled(); // Because addPlugin returned true
|
||||
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not restart when restart option is false', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
addPlugin: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/plugin'], { bundled: false, restart: false });
|
||||
|
||||
expect(mockPluginManagementService.addPlugin).toHaveBeenCalledWith('@unraid/plugin');
|
||||
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await command.run(['@unraid/plugin'], { bundled: false, restart: true });
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Failed to add plugin:', expect.any(Error));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should error when no package name provided', async () => {
|
||||
await command.run([], { bundled: false, restart: true });
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Package name is required.');
|
||||
expect(mockApiConfigPersistence.persist).not.toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -142,57 +124,62 @@ describe('Plugin Commands', () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
RemovePluginCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: PluginManagementService, useValue: mockPluginManagementService },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
{ provide: InquirerService, useValue: mockInquirerService },
|
||||
{ provide: ApiConfigPersistence, useValue: mockApiConfigPersistence },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<RemovePluginCommand>(RemovePluginCommand);
|
||||
});
|
||||
|
||||
it('should remove a plugin successfully', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
removePlugin: false, // No manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/plugin-example'], { bundled: false, restart: true });
|
||||
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
names: ['@unraid/plugin-example'],
|
||||
bundled: false,
|
||||
restart: true,
|
||||
},
|
||||
},
|
||||
it('should remove plugins successfully', async () => {
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
plugins: ['@unraid/plugin-example', '@unraid/plugin-test'],
|
||||
restart: true,
|
||||
});
|
||||
|
||||
await command.run([], { restart: true });
|
||||
|
||||
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
|
||||
'@unraid/plugin-example',
|
||||
'@unraid/plugin-test'
|
||||
);
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Removed plugin @unraid/plugin-example');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Removed plugin @unraid/plugin-test');
|
||||
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle when no plugins are selected', async () => {
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
plugins: [],
|
||||
restart: true,
|
||||
});
|
||||
|
||||
await command.run([], { restart: true });
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith('No plugins selected for removal.');
|
||||
expect(mockPluginManagementService.removePlugin).not.toHaveBeenCalled();
|
||||
expect(mockApiConfigPersistence.persist).not.toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle removing bundled plugins', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
removePlugin: true, // Manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
it('should skip restart when --no-restart is specified', async () => {
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
plugins: ['@unraid/plugin-example'],
|
||||
restart: false,
|
||||
});
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
await command.run([], { restart: false });
|
||||
|
||||
await command.run(['@unraid/bundled-plugin'], { bundled: true, restart: true });
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Removed bundled plugin @unraid/bundled-plugin');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
|
||||
'@unraid/plugin-example'
|
||||
);
|
||||
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,8 +190,8 @@ describe('Plugin Commands', () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ListPluginCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: PluginManagementService, useValue: mockPluginManagementService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -212,63 +199,37 @@ describe('Plugin Commands', () => {
|
||||
});
|
||||
|
||||
it('should list installed plugins', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
name: '@unraid/plugin-1',
|
||||
version: '1.0.0',
|
||||
hasApiModule: true,
|
||||
hasCliModule: false,
|
||||
},
|
||||
{
|
||||
name: '@unraid/plugin-2',
|
||||
version: '2.0.0',
|
||||
hasApiModule: true,
|
||||
hasCliModule: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
vi.mocked(PluginService.listPlugins).mockResolvedValue([
|
||||
['@unraid/plugin-1', '1.0.0'],
|
||||
['@unraid/plugin-2', '2.0.0'],
|
||||
]);
|
||||
mockPluginManagementService.plugins = ['@unraid/plugin-1', '@unraid/plugin-2'];
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalledWith({
|
||||
query: expect.anything(),
|
||||
});
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Installed plugins:\n');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-1@1.0.0 [API]');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-2@2.0.0 [API, CLI]');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-1@1.0.0');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-2@2.0.0');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should handle no plugins installed', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
plugins: [],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
vi.mocked(PluginService.listPlugins).mockResolvedValue([]);
|
||||
mockPluginManagementService.plugins = [];
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('No plugins installed.');
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
it('should warn about plugins not installed', async () => {
|
||||
vi.mocked(PluginService.listPlugins).mockResolvedValue([['@unraid/plugin-1', '1.0.0']]);
|
||||
mockPluginManagementService.plugins = ['@unraid/plugin-1', '@unraid/plugin-2'];
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Failed to list plugins:', expect.any(Error));
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith('1 plugins are not installed:');
|
||||
expect(mockLogger.table).toHaveBeenCalledWith('warn', ['@unraid/plugin-2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,17 +2,23 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ILogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
||||
|
||||
// Mock log service
|
||||
const mockLogService = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
const mockLogService: ILogService = {
|
||||
shouldLog: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
always: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
log: vi.fn(),
|
||||
table: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock ApiReportService
|
||||
@@ -101,7 +107,7 @@ describe('ReportCommand', () => {
|
||||
|
||||
// Verify report was logged
|
||||
expect(mockLogService.clear).toHaveBeenCalled();
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(JSON.stringify(mockReport, null, 2));
|
||||
expect(mockLogService.always).toHaveBeenCalledWith(JSON.stringify(mockReport, null, 2));
|
||||
});
|
||||
|
||||
it('should handle API not running gracefully', async () => {
|
||||
@@ -113,7 +119,7 @@ describe('ReportCommand', () => {
|
||||
expect(mockApiReportService.generateReport).not.toHaveBeenCalled();
|
||||
|
||||
// Verify warning was logged
|
||||
expect(mockLogService.warn).toHaveBeenCalledWith(
|
||||
expect(mockLogService.always).toHaveBeenCalledWith(
|
||||
expect.stringContaining('API is not running')
|
||||
);
|
||||
});
|
||||
@@ -128,7 +134,7 @@ describe('ReportCommand', () => {
|
||||
expect(mockLogService.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error generating report via GraphQL')
|
||||
);
|
||||
expect(mockLogService.warn).toHaveBeenCalledWith(
|
||||
expect(mockLogService.always).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to generate system report')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
PluginCommand,
|
||||
RemovePluginCommand,
|
||||
} from '@app/unraid-api/cli/plugins/plugin.command.js';
|
||||
import { RemovePluginQuestionSet } from '@app/unraid-api/cli/plugins/remove-plugin.questions.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
@@ -72,6 +74,7 @@ const DEFAULT_PROVIDERS = [
|
||||
DeleteApiKeyQuestionSet,
|
||||
AddSSOUserQuestionSet,
|
||||
RemoveSSOUserQuestionSet,
|
||||
RemovePluginQuestionSet,
|
||||
DeveloperQuestions,
|
||||
DeveloperToolsService,
|
||||
LogService,
|
||||
@@ -85,7 +88,12 @@ const DEFAULT_PROVIDERS = [
|
||||
] as const;
|
||||
|
||||
@Module({
|
||||
imports: [LegacyConfigModule, ApiConfigModule, GlobalDepsModule, PluginCliModule.register()],
|
||||
imports: [
|
||||
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
|
||||
ApiConfigModule,
|
||||
GlobalDepsModule,
|
||||
PluginCliModule.register(),
|
||||
],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||
exports: [ApiReportService],
|
||||
})
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { levels, LogLevel } from '@app/core/log.js';
|
||||
import { LOG_LEVEL } from '@app/environment.js';
|
||||
import { LOG_LEVEL, SUPPRESS_LOGS } from '@app/environment.js';
|
||||
|
||||
export interface ILogService {
|
||||
clear(): void;
|
||||
shouldLog(level: LogLevel): boolean;
|
||||
table(level: LogLevel, data: unknown, columns?: string[]): void;
|
||||
log(...messages: unknown[]): void;
|
||||
info(...messages: unknown[]): void;
|
||||
warn(...messages: unknown[]): void;
|
||||
error(...messages: unknown[]): void;
|
||||
always(...messages: unknown[]): void;
|
||||
debug(...messages: unknown[]): void;
|
||||
trace(...messages: unknown[]): void;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LogService {
|
||||
export class LogService implements ILogService {
|
||||
private logger = console;
|
||||
private suppressLogs = SUPPRESS_LOGS;
|
||||
|
||||
clear(): void {
|
||||
this.logger.clear();
|
||||
if (!this.suppressLogs) {
|
||||
this.logger.clear();
|
||||
}
|
||||
}
|
||||
|
||||
shouldLog(level: LogLevel): boolean {
|
||||
if (this.suppressLogs) {
|
||||
return false;
|
||||
}
|
||||
const logLevelsLowToHigh = levels;
|
||||
const shouldLog =
|
||||
logLevelsLowToHigh.indexOf(level) >=
|
||||
@@ -49,6 +68,11 @@ export class LogService {
|
||||
}
|
||||
}
|
||||
|
||||
always(...messages: unknown[]): void {
|
||||
// Always output to stdout, regardless of log level or suppression
|
||||
console.log(...messages);
|
||||
}
|
||||
|
||||
debug(...messages: unknown[]): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
this.logger.debug(...messages);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option, SubCommand } from 'nest-commander';
|
||||
import { Command, CommandRunner, InquirerService, Option, SubCommand } from 'nest-commander';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { ADD_PLUGIN_MUTATION } from '@app/unraid-api/cli/mutations/add-plugin.mutation.js';
|
||||
import { REMOVE_PLUGIN_MUTATION } from '@app/unraid-api/cli/mutations/remove-plugin.mutation.js';
|
||||
import { PLUGINS_QUERY } from '@app/unraid-api/cli/queries/plugins.query.js';
|
||||
import {
|
||||
NoPluginsFoundError,
|
||||
RemovePluginQuestionSet,
|
||||
} from '@app/unraid-api/cli/plugins/remove-plugin.questions.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
import { parsePackageArg } from '@app/utils.js';
|
||||
|
||||
interface InstallPluginCommandOptions {
|
||||
bundled: boolean;
|
||||
@@ -24,7 +28,8 @@ export class InstallPluginCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logService: LogService,
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
private readonly pluginManagementService: PluginManagementService,
|
||||
private readonly apiConfigPersistence: ApiConfigPersistence
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -35,35 +40,16 @@ export class InstallPluginCommand extends CommandRunner {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await this.internalClient.getClient();
|
||||
|
||||
const result = await client.mutate({
|
||||
mutation: ADD_PLUGIN_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
names: passedParams,
|
||||
bundled: options.bundled,
|
||||
restart: options.restart,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requiresManualRestart = result.data?.addPlugin;
|
||||
|
||||
if (options.bundled) {
|
||||
this.logService.log(`Added bundled plugin ${passedParams.join(', ')}`);
|
||||
} else {
|
||||
this.logService.log(`Added plugin ${passedParams.join(', ')}`);
|
||||
}
|
||||
|
||||
if (requiresManualRestart && options.restart) {
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error('Failed to add plugin:', error);
|
||||
process.exitCode = 1;
|
||||
if (options.bundled) {
|
||||
await this.pluginManagementService.addBundledPlugin(...passedParams);
|
||||
this.logService.log(`Added bundled plugin ${passedParams.join(', ')}`);
|
||||
} else {
|
||||
await this.pluginManagementService.addPlugin(...passedParams);
|
||||
this.logService.log(`Added plugin ${passedParams.join(', ')}`);
|
||||
}
|
||||
await this.apiConfigPersistence.persist();
|
||||
if (options.restart) {
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,66 +72,57 @@ export class InstallPluginCommand extends CommandRunner {
|
||||
}
|
||||
}
|
||||
|
||||
interface RemovePluginCommandOptions {
|
||||
plugins?: string[];
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
@SubCommand({
|
||||
name: 'remove',
|
||||
aliases: ['rm'],
|
||||
description: 'Remove a plugin peer dependency.',
|
||||
arguments: '<package>',
|
||||
description: 'Remove plugin peer dependencies.',
|
||||
})
|
||||
export class RemovePluginCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logService: LogService,
|
||||
private readonly internalClient: CliInternalClientService,
|
||||
private readonly restartCommand: RestartCommand
|
||||
private readonly pluginManagementService: PluginManagementService,
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly inquirerService: InquirerService,
|
||||
private readonly apiConfigPersistence: ApiConfigPersistence
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
|
||||
if (passedParams.length === 0) {
|
||||
this.logService.error('Package name is required.');
|
||||
process.exitCode = 1;
|
||||
async run(_passedParams: string[], options?: RemovePluginCommandOptions): Promise<void> {
|
||||
try {
|
||||
options = await this.inquirerService.prompt(RemovePluginQuestionSet.name, options);
|
||||
} catch (error) {
|
||||
if (error instanceof NoPluginsFoundError) {
|
||||
this.logService.error(error.message);
|
||||
process.exit(0);
|
||||
} else if (error instanceof Error) {
|
||||
this.logService.error('Failed to fetch plugins: %s', error.message);
|
||||
process.exit(1);
|
||||
} else {
|
||||
this.logService.error('An unexpected error occurred');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.plugins || options.plugins.length === 0) {
|
||||
this.logService.warn('No plugins selected for removal.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await this.internalClient.getClient();
|
||||
|
||||
const result = await client.mutate({
|
||||
mutation: REMOVE_PLUGIN_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
names: passedParams,
|
||||
bundled: options.bundled,
|
||||
restart: options.restart,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requiresManualRestart = result.data?.removePlugin;
|
||||
|
||||
if (options.bundled) {
|
||||
this.logService.log(`Removed bundled plugin ${passedParams.join(', ')}`);
|
||||
} else {
|
||||
this.logService.log(`Removed plugin ${passedParams.join(', ')}`);
|
||||
}
|
||||
|
||||
if (requiresManualRestart && options.restart) {
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error('Failed to remove plugin:', error);
|
||||
process.exitCode = 1;
|
||||
await this.pluginManagementService.removePlugin(...options.plugins);
|
||||
for (const plugin of options.plugins) {
|
||||
this.logService.log(`Removed plugin ${plugin}`);
|
||||
}
|
||||
}
|
||||
await this.apiConfigPersistence.persist();
|
||||
|
||||
@Option({
|
||||
flags: '-b, --bundled',
|
||||
description: 'Uninstall a bundled plugin',
|
||||
defaultValue: false,
|
||||
})
|
||||
parseBundled(): boolean {
|
||||
return true;
|
||||
if (options.restart) {
|
||||
await this.restartCommand.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
@@ -166,39 +143,34 @@ export class RemovePluginCommand extends CommandRunner {
|
||||
export class ListPluginCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly logService: LogService,
|
||||
private readonly internalClient: CliInternalClientService
|
||||
private readonly pluginManagementService: PluginManagementService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const client = await this.internalClient.getClient();
|
||||
const configPlugins = this.pluginManagementService.plugins;
|
||||
const installedPlugins = await PluginService.listPlugins();
|
||||
|
||||
const result = await client.query({
|
||||
query: PLUGINS_QUERY,
|
||||
});
|
||||
|
||||
const plugins = result.data?.plugins || [];
|
||||
|
||||
if (plugins.length === 0) {
|
||||
this.logService.log('No plugins installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.log('Installed plugins:\n');
|
||||
plugins.forEach((plugin) => {
|
||||
const moduleInfo: string[] = [];
|
||||
if (plugin.hasApiModule) moduleInfo.push('API');
|
||||
if (plugin.hasCliModule) moduleInfo.push('CLI');
|
||||
const modules = moduleInfo.length > 0 ? ` [${moduleInfo.join(', ')}]` : '';
|
||||
this.logService.log(`☑️ ${plugin.name}@${plugin.version}${modules}`);
|
||||
});
|
||||
this.logService.log(); // for spacing
|
||||
} catch (error) {
|
||||
this.logService.error('Failed to list plugins:', error);
|
||||
process.exitCode = 1;
|
||||
// this can happen if configPlugins is a super set of installedPlugins
|
||||
if (installedPlugins.length !== configPlugins.length) {
|
||||
const configSet = new Set(configPlugins.map((plugin) => parsePackageArg(plugin).name));
|
||||
const installedSet = new Set(installedPlugins.map(([name]) => name));
|
||||
const notInstalled = Array.from(configSet.difference(installedSet));
|
||||
this.logService.warn(`${notInstalled.length} plugins are not installed:`);
|
||||
this.logService.table('warn', notInstalled);
|
||||
}
|
||||
|
||||
if (installedPlugins.length === 0) {
|
||||
this.logService.log('No plugins installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.log('Installed plugins:\n');
|
||||
installedPlugins.forEach(([name, version]) => {
|
||||
this.logService.log(`☑️ ${name}@${version}`);
|
||||
});
|
||||
this.logService.log(); // for spacing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
api/src/unraid-api/cli/plugins/remove-plugin.questions.ts
Normal file
38
api/src/unraid-api/cli/plugins/remove-plugin.questions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ChoicesFor, Question, QuestionSet } from 'nest-commander';
|
||||
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
|
||||
export class NoPluginsFoundError extends Error {
|
||||
constructor() {
|
||||
super('No plugins found to remove');
|
||||
this.name = 'NoPluginsFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'remove-plugin' })
|
||||
export class RemovePluginQuestionSet {
|
||||
static name = 'remove-plugin';
|
||||
|
||||
@Question({
|
||||
message: `Please select plugins to remove:\n`,
|
||||
name: 'plugins',
|
||||
type: 'checkbox',
|
||||
})
|
||||
parsePlugins(val: string[]) {
|
||||
return val;
|
||||
}
|
||||
|
||||
@ChoicesFor({ name: 'plugins' })
|
||||
async choicesForPlugins() {
|
||||
const installedPlugins = await PluginService.listPlugins();
|
||||
|
||||
if (installedPlugins.length === 0) {
|
||||
throw new NoPluginsFoundError();
|
||||
}
|
||||
|
||||
return installedPlugins.map(([name, version]) => ({
|
||||
name: `${name}@${version}`,
|
||||
value: name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { Options, Result, ResultPromise } from 'execa';
|
||||
import { execa, ExecaError } from 'execa';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js';
|
||||
import { PATHS_LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
type CmdContext = Options & {
|
||||
@@ -102,6 +102,6 @@ export class PM2Service {
|
||||
* Ensures that the dependencies necessary for PM2 to start and operate are present.
|
||||
*/
|
||||
async ensurePm2Dependencies() {
|
||||
await mkdir(LOGS_DIR, { recursive: true });
|
||||
await mkdir(PATHS_LOGS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class ReportCommand extends CommandRunner {
|
||||
});
|
||||
|
||||
if (!apiRunning) {
|
||||
this.logger.warn(
|
||||
this.logger.always(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: 'API is not running. Please start the API server before running a report.',
|
||||
@@ -56,10 +56,10 @@ export class ReportCommand extends CommandRunner {
|
||||
const report = await this.apiReportService.generateReport(apiRunning);
|
||||
|
||||
this.logger.clear();
|
||||
this.logger.info(JSON.stringify(report, null, 2));
|
||||
this.logger.always(JSON.stringify(report, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.debug('Error generating report via GraphQL: ' + error);
|
||||
this.logger.warn(
|
||||
this.logger.always(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: 'Failed to generate system report. Please ensure the API is running and properly configured.',
|
||||
|
||||
@@ -26,7 +26,7 @@ export class ValidateTokenCommand extends CommandRunner {
|
||||
}
|
||||
|
||||
private createErrorAndExit = (errorMessage: string) => {
|
||||
this.logger.error(
|
||||
this.logger.always(
|
||||
JSON.stringify({
|
||||
error: errorMessage,
|
||||
valid: false,
|
||||
@@ -104,7 +104,7 @@ export class ValidateTokenCommand extends CommandRunner {
|
||||
);
|
||||
}
|
||||
if (ssoUsers.includes(username)) {
|
||||
this.logger.info(JSON.stringify({ error: null, valid: true, username }));
|
||||
this.logger.always(JSON.stringify({ error: null, valid: true, username }));
|
||||
process.exit(0);
|
||||
} else {
|
||||
this.createErrorAndExit('Username on token does not match');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, Module, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Injectable, Module, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService, registerAs } from '@nestjs/config';
|
||||
import path from 'path';
|
||||
|
||||
@@ -10,8 +10,6 @@ import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';
|
||||
|
||||
export { type ApiConfig };
|
||||
|
||||
const logger = new Logger('ApiConfig');
|
||||
|
||||
const createDefaultConfig = (): ApiConfig => ({
|
||||
version: API_VERSION,
|
||||
extraOrigins: [],
|
||||
@@ -23,17 +21,14 @@ const createDefaultConfig = (): ApiConfig => ({
|
||||
/**
|
||||
* Simple file-based config loading for plugin discovery (outside of nestjs DI container).
|
||||
* This avoids complex DI container instantiation during module loading.
|
||||
*
|
||||
* @throws {Error} if the config file is not found or cannot be parsed
|
||||
*/
|
||||
export const loadApiConfig = async () => {
|
||||
const defaultConfig = createDefaultConfig();
|
||||
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();
|
||||
|
||||
let diskConfig: Partial<ApiConfig> = {};
|
||||
try {
|
||||
diskConfig = await apiHandler.loadConfig();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load API config from disk:', error);
|
||||
}
|
||||
const diskConfig: Partial<ApiConfig> = await apiHandler.loadConfig();
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
|
||||
@@ -17,8 +17,6 @@ import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
console.log('ENVIRONMENT', ENVIRONMENT);
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GlobalDepsModule,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class PluginManagementService {
|
||||
static WORKSPACE_PACKAGES_TO_VENDOR = ['@unraid/shared', 'unraid-api-plugin-connect'];
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService<{ api: ApiConfig }, true>,
|
||||
private readonly dependencyService: DependencyService
|
||||
@@ -22,6 +24,15 @@ export class PluginManagementService {
|
||||
await this.dependencyService.rebuildVendorArchive();
|
||||
}
|
||||
|
||||
isBundled(plugin: string) {
|
||||
return PluginManagementService.WORKSPACE_PACKAGES_TO_VENDOR.includes(plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes plugins from the config and uninstalls them from node_modules.
|
||||
*
|
||||
* @param plugins - The npm package names to remove.
|
||||
*/
|
||||
async removePlugin(...plugins: string[]) {
|
||||
const removed = this.removePluginFromConfig(...plugins);
|
||||
await this.uninstallPlugins(...removed);
|
||||
@@ -64,13 +75,20 @@ export class PluginManagementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs plugins using npm.
|
||||
* Install bundle / unbundled plugins using npm or direct with the config.
|
||||
*
|
||||
* @param plugins - The plugins to install.
|
||||
* @returns The execa result of the npm command.
|
||||
*/
|
||||
private installPlugins(...plugins: string[]) {
|
||||
return this.dependencyService.npm('i', '--save-peer', '--save-exact', ...plugins);
|
||||
private async installPlugins(...plugins: string[]) {
|
||||
const bundled = plugins.filter((plugin) => this.isBundled(plugin));
|
||||
const unbundled = plugins.filter((plugin) => !this.isBundled(plugin));
|
||||
if (unbundled.length > 0) {
|
||||
await this.dependencyService.npm('i', '--save-peer', '--save-exact', ...unbundled);
|
||||
}
|
||||
if (bundled.length > 0) {
|
||||
await this.addBundledPlugin(...bundled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,8 +97,15 @@ export class PluginManagementService {
|
||||
* @param plugins - The plugins to uninstall.
|
||||
* @returns The execa result of the npm command.
|
||||
*/
|
||||
private uninstallPlugins(...plugins: string[]) {
|
||||
return this.dependencyService.npm('uninstall', ...plugins);
|
||||
private async uninstallPlugins(...plugins: string[]) {
|
||||
const bundled = plugins.filter((plugin) => this.isBundled(plugin));
|
||||
const unbundled = plugins.filter((plugin) => !this.isBundled(plugin));
|
||||
if (unbundled.length > 0) {
|
||||
await this.dependencyService.npm('uninstall', ...unbundled);
|
||||
}
|
||||
if (bundled.length > 0) {
|
||||
await this.removeBundledPlugin(...bundled);
|
||||
}
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicModule, Logger, Module } from '@nestjs/common';
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
@@ -10,7 +10,15 @@ import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
|
||||
@Module({})
|
||||
export class PluginModule {
|
||||
private static readonly logger = new Logger(PluginModule.name);
|
||||
private static apiList: string[];
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
const { Logger } = await import('@nestjs/common');
|
||||
const logger = new Logger(PluginModule.name);
|
||||
logger.debug(
|
||||
`Found ${PluginModule.apiList.length} API plugins: ${PluginModule.apiList.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
static async register(): Promise<DynamicModule> {
|
||||
const plugins = await PluginService.getPlugins();
|
||||
@@ -18,9 +26,7 @@ export class PluginModule {
|
||||
.filter((plugin) => plugin.ApiModule)
|
||||
.map((plugin) => plugin.ApiModule!);
|
||||
|
||||
const pluginList = apiModules.map((plugin) => plugin.name).join(', ');
|
||||
PluginModule.logger.log(`Found ${apiModules.length} API plugins: ${pluginList}`);
|
||||
|
||||
PluginModule.apiList = apiModules.map((plugin) => plugin.name);
|
||||
return {
|
||||
module: PluginModule,
|
||||
imports: [GlobalDepsModule, ResolversModule, ApiConfigModule, ...apiModules],
|
||||
@@ -32,7 +38,15 @@ export class PluginModule {
|
||||
|
||||
@Module({})
|
||||
export class PluginCliModule {
|
||||
private static readonly logger = new Logger(PluginCliModule.name);
|
||||
private static cliList: string[];
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
const { Logger } = await import('@nestjs/common');
|
||||
const logger = new Logger(PluginCliModule.name);
|
||||
logger.debug(
|
||||
`Found ${PluginCliModule.cliList.length} CLI plugins: ${PluginCliModule.cliList.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
static async register(): Promise<DynamicModule> {
|
||||
const plugins = await PluginService.getPlugins();
|
||||
@@ -40,9 +54,7 @@ export class PluginCliModule {
|
||||
.filter((plugin) => plugin.CliModule)
|
||||
.map((plugin) => plugin.CliModule!);
|
||||
|
||||
const cliList = cliModules.map((plugin) => plugin.name).join(', ');
|
||||
PluginCliModule.logger.debug(`Found ${cliModules.length} CLI plugins: ${cliList}`);
|
||||
|
||||
PluginCliModule.cliList = cliModules.map((plugin) => plugin.name);
|
||||
return {
|
||||
module: PluginCliModule,
|
||||
imports: [GlobalDepsModule, ApiConfigModule, ...cliModules],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { pluginLogger } from '@app/core/log.js';
|
||||
import { getPackageJson } from '@app/environment.js';
|
||||
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
|
||||
@@ -15,7 +16,7 @@ type Plugin = ApiNestPluginDefinition & {
|
||||
|
||||
@Injectable()
|
||||
export class PluginService {
|
||||
private static readonly logger = new Logger(PluginService.name);
|
||||
private static readonly logger = pluginLogger;
|
||||
private static plugins: Promise<Plugin[]> | undefined;
|
||||
|
||||
static async getPlugins() {
|
||||
@@ -55,7 +56,6 @@ export class PluginService {
|
||||
if (plugins.errorOccurred) {
|
||||
PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`);
|
||||
}
|
||||
PluginService.logger.log(`Loaded ${plugins.data.length} plugins.`);
|
||||
return plugins.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ vi.mock('@app/core/log.js', () => ({
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
pluginLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
1753135397044
|
||||
@@ -1 +0,0 @@
|
||||
1753135396799
|
||||
@@ -1 +0,0 @@
|
||||
1753135396931
|
||||
@@ -1 +0,0 @@
|
||||
1753135397144
|
||||
@@ -1 +0,0 @@
|
||||
1753135397303
|
||||
@@ -57,37 +57,10 @@ const patchTestCases: ModificationTestCase[] = [
|
||||
/** Modifications that simply add a new file & remove it on rollback. */
|
||||
const simpleTestCases: ModificationTestCase[] = [];
|
||||
|
||||
const downloadOrRetrieveOriginalFile = async (filePath: string, fileUrl: string): Promise<string> => {
|
||||
let originalContent = '';
|
||||
// Check last download time, if > than 1 week and not in CI, download the file from Github
|
||||
const lastDownloadTime = await readFile(`${filePath}.last-download-time`, 'utf-8')
|
||||
.catch(() => 0)
|
||||
.then(Number);
|
||||
const shouldDownload = lastDownloadTime < Date.now() - 1000 * 60 * 60 * 24 * 7 && !process.env.CI;
|
||||
if (shouldDownload) {
|
||||
try {
|
||||
console.log('Downloading file', fileUrl);
|
||||
originalContent = await fetch(fileUrl).then((response) => response.text());
|
||||
if (!originalContent) {
|
||||
throw new Error('Failed to download file');
|
||||
}
|
||||
await writeFile(filePath, originalContent);
|
||||
await writeFile(`${filePath}.last-download-time`, Date.now().toString());
|
||||
return originalContent;
|
||||
} catch (error) {
|
||||
console.error('Error downloading file', error);
|
||||
console.error(
|
||||
`Failed to download file - using version created at ${new Date(lastDownloadTime).toISOString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return await readFile(filePath, 'utf-8').catch(() => '');
|
||||
};
|
||||
|
||||
async function testModification(testCase: ModificationTestCase) {
|
||||
const fileName = basename(testCase.fileUrl);
|
||||
const filePath = getPathToFixture(fileName);
|
||||
const originalContent = await downloadOrRetrieveOriginalFile(filePath, testCase.fileUrl);
|
||||
const originalContent = await readFile(filePath, 'utf-8').catch(() => '');
|
||||
const logger = new Logger();
|
||||
const patcher = await new testCase.ModificationClass(logger);
|
||||
const originalPath = patcher.filePath;
|
||||
|
||||
@@ -110,6 +110,9 @@ export default defineConfig(({ mode }): ViteUserConfig => {
|
||||
interop: 'auto',
|
||||
banner: (chunk) => {
|
||||
if (chunk.fileName === 'main.js' || chunk.fileName === 'cli.js') {
|
||||
if (process.env.COMMAND_TESTER) {
|
||||
return '#!/usr/bin/env node\n';
|
||||
}
|
||||
return '#!/usr/local/bin/node\n';
|
||||
}
|
||||
return '';
|
||||
|
||||
@@ -29,13 +29,13 @@ import { fileExists } from "./file.js";
|
||||
* ```
|
||||
*/
|
||||
export class ConfigFileHandler<T extends object> {
|
||||
private readonly logger: Logger;
|
||||
private readonly logger: Logger
|
||||
|
||||
/**
|
||||
* @param definition The configuration definition that provides behavior
|
||||
*/
|
||||
constructor(private readonly definition: ConfigDefinition<T>) {
|
||||
this.logger = new Logger(`ConfigFileHandler:${definition.fileName()}`);
|
||||
constructor(private readonly definition: ConfigDefinition<T>, logger?: Logger) {
|
||||
this.logger = logger ?? new Logger(`ConfigFileHandler:${definition.fileName()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user