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:
Eli Bosley
2025-07-30 16:38:08 -04:00
committed by GitHub
parent 6ea94f061d
commit 23ef760d76
31 changed files with 407 additions and 369 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
View File

@@ -0,0 +1 @@
# custom log directory for tests & development

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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';

View File

@@ -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']);
});
});
});

View File

@@ -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')
);
});

View File

@@ -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],
})

View File

@@ -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);

View File

@@ -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
}
}

View 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,
}));
}
}

View File

@@ -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 });
}
}

View File

@@ -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.',

View File

@@ -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');

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
}
/**------------------------------------------------------------------------

View File

@@ -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],

View File

@@ -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;
}

View File

@@ -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', () => ({

View File

@@ -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;

View File

@@ -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 '';

View File

@@ -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()}`);
}
/**