mirror of
https://github.com/unraid/api.git
synced 2026-01-01 06:01:18 -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_PASSWD=./dev/passwd
|
||||||
PATHS_RCLONE_SOCKET=./dev/rclone-socket
|
PATHS_RCLONE_SOCKET=./dev/rclone-socket
|
||||||
PATHS_LOG_BASE=./dev/log # Where we store logs
|
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
|
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
|
||||||
ENVIRONMENT="development"
|
ENVIRONMENT="development"
|
||||||
NODE_ENV="development"
|
NODE_ENV="development"
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
|
|||||||
PATHS_CONFIG_MODULES=./dev/configs
|
PATHS_CONFIG_MODULES=./dev/configs
|
||||||
PATHS_ACTIVATION_BASE=./dev/activation
|
PATHS_ACTIVATION_BASE=./dev/activation
|
||||||
PATHS_PASSWD=./dev/passwd
|
PATHS_PASSWD=./dev/passwd
|
||||||
|
PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||||
PORT=5000
|
PORT=5000
|
||||||
NODE_ENV="test"
|
NODE_ENV="test"
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"extraOrigins": [
|
"extraOrigins": [],
|
||||||
"https://google.com",
|
"sandbox": false,
|
||||||
"https://test.com"
|
|
||||||
],
|
|
||||||
"sandbox": true,
|
|
||||||
"ssoSubIds": [],
|
"ssoSubIds": [],
|
||||||
"plugins": [
|
"plugins": []
|
||||||
"unraid-api-plugin-connect"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
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",
|
"start": "node dist/main.js",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:debug": "NODE_OPTIONS='--inspect-brk=9229 --enable-source-maps' 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",
|
"command:raw": "./dist/cli.js",
|
||||||
"// Build and Deploy": "",
|
"// Build and Deploy": "",
|
||||||
"build": "vite build --mode=production",
|
"build": "vite build --mode=production",
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
import '@app/dotenv.js';
|
import '@app/dotenv.js';
|
||||||
|
|
||||||
import { execa } from 'execa';
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { CommandFactory } from 'nest-commander';
|
import { CommandFactory } from 'nest-commander';
|
||||||
|
|
||||||
import { internalLogger, logger } from '@app/core/log.js';
|
import { LOG_LEVEL, SUPPRESS_LOGS } from '@app/environment.js';
|
||||||
import { LOG_LEVEL } from '@app/environment.js';
|
|
||||||
import { CliModule } from '@app/unraid-api/cli/cli.module.js';
|
|
||||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||||
|
|
||||||
const getUnraidApiLocation = async () => {
|
const getUnraidApiLocation = async () => {
|
||||||
|
const { execa } = await import('execa');
|
||||||
try {
|
try {
|
||||||
const shellToUse = await execa('which unraid-api');
|
const shellToUse = await execa('which unraid-api');
|
||||||
return shellToUse.stdout.trim();
|
return shellToUse.stdout.trim();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug('Could not find unraid-api in PATH, using default location');
|
|
||||||
|
|
||||||
return '/usr/bin/unraid-api';
|
return '/usr/bin/unraid-api';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLogger = () => {
|
||||||
|
if (LOG_LEVEL === 'TRACE' && !SUPPRESS_LOGS) {
|
||||||
|
return new LogService();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
try {
|
try {
|
||||||
await import('json-bigint-patch');
|
await import('json-bigint-patch');
|
||||||
|
const { CliModule } = await import('@app/unraid-api/cli/cli.module.js');
|
||||||
|
|
||||||
await CommandFactory.run(CliModule, {
|
await CommandFactory.run(CliModule, {
|
||||||
cliName: 'unraid-api',
|
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: {
|
completion: {
|
||||||
fig: false,
|
fig: false,
|
||||||
cmd: 'completion-script',
|
cmd: 'completion-script',
|
||||||
@@ -32,10 +40,8 @@ try {
|
|||||||
});
|
});
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ERROR:', error);
|
if (logger) {
|
||||||
internalLogger.error({
|
logger.error('ERROR:', error);
|
||||||
message: 'Failed to start unraid-api',
|
}
|
||||||
error,
|
|
||||||
});
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pino } from 'pino';
|
import { pino } from 'pino';
|
||||||
import pretty from 'pino-pretty';
|
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;
|
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';
|
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 =
|
export const logDestination =
|
||||||
LOG_TYPE === 'pretty'
|
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
|
||||||
? pretty({
|
const localFileDestination = pino.destination({
|
||||||
singleLine: true,
|
dest: PATHS_LOGS_FILE,
|
||||||
hideObject: false,
|
sync: true,
|
||||||
colorize: true,
|
});
|
||||||
ignore: 'hostname,pid',
|
|
||||||
destination: logDestination,
|
const stream = SUPPRESS_LOGS
|
||||||
})
|
? nullDestination
|
||||||
: logDestination;
|
: LOG_TYPE === 'pretty'
|
||||||
|
? pretty({
|
||||||
|
singleLine: true,
|
||||||
|
hideObject: false,
|
||||||
|
colorize: true,
|
||||||
|
ignore: 'hostname,pid',
|
||||||
|
destination: logDestination,
|
||||||
|
})
|
||||||
|
: logDestination;
|
||||||
|
|
||||||
export const logger = pino(
|
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 remoteAccessLogger = logger.child({ logger: 'remote-access' });
|
||||||
export const remoteQueryLogger = logger.child({ logger: 'remote-query' });
|
export const remoteQueryLogger = logger.child({ logger: 'remote-query' });
|
||||||
export const apiLogger = logger.child({ logger: 'api' });
|
export const apiLogger = logger.child({ logger: 'api' });
|
||||||
|
export const pluginLogger = logger.child({ logger: 'plugin', stream: localFileDestination });
|
||||||
|
|
||||||
export const loggers = [
|
export const loggers = [
|
||||||
internalLogger,
|
internalLogger,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL
|
|||||||
: process.env.ENVIRONMENT === 'production'
|
: process.env.ENVIRONMENT === 'production'
|
||||||
? 'INFO'
|
? 'INFO'
|
||||||
: 'DEBUG';
|
: 'DEBUG';
|
||||||
|
export const SUPPRESS_LOGS = process.env.SUPPRESS_LOGS === 'true';
|
||||||
export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
|
export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
|
||||||
? process.env.MOTHERSHIP_GRAPHQL_LINK
|
? process.env.MOTHERSHIP_GRAPHQL_LINK
|
||||||
: ENVIRONMENT === 'staging'
|
: 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_HOME = process.env.PM2_HOME ?? join(homedir(), '.pm2');
|
||||||
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', '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 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 =
|
export const PATHS_CONFIG_MODULES =
|
||||||
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';
|
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';
|
||||||
|
|||||||
@@ -1,35 +1,65 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { InquirerService } from 'nest-commander';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
import { ILogService, LogService } from '@app/unraid-api/cli/log.service.js';
|
||||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
|
||||||
import {
|
import {
|
||||||
InstallPluginCommand,
|
InstallPluginCommand,
|
||||||
ListPluginCommand,
|
ListPluginCommand,
|
||||||
RemovePluginCommand,
|
RemovePluginCommand,
|
||||||
} from '@app/unraid-api/cli/plugins/plugin.command.js';
|
} from '@app/unraid-api/cli/plugins/plugin.command.js';
|
||||||
import { RestartCommand } from '@app/unraid-api/cli/restart.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
|
// Mock services
|
||||||
const mockInternalClient = {
|
const mockLogger: ILogService = {
|
||||||
getClient: vi.fn(),
|
clear: vi.fn(),
|
||||||
};
|
shouldLog: vi.fn(),
|
||||||
|
|
||||||
const mockLogger = {
|
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
table: vi.fn(),
|
||||||
|
always: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
trace: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRestartCommand = {
|
const mockRestartCommand = {
|
||||||
run: vi.fn(),
|
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', () => {
|
describe('Plugin Commands', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear mocks before each test
|
// Clear mocks before each test
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset process.exitCode
|
||||||
|
process.exitCode = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('InstallPluginCommand', () => {
|
describe('InstallPluginCommand', () => {
|
||||||
@@ -39,9 +69,10 @@ describe('Plugin Commands', () => {
|
|||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
InstallPluginCommand,
|
InstallPluginCommand,
|
||||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
|
||||||
{ provide: LogService, useValue: mockLogger },
|
{ provide: LogService, useValue: mockLogger },
|
||||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||||
|
{ provide: PluginManagementService, useValue: mockPluginManagementService },
|
||||||
|
{ provide: ApiConfigPersistence, useValue: mockApiConfigPersistence },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -49,88 +80,39 @@ describe('Plugin Commands', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should install a plugin successfully', async () => {
|
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 });
|
await command.run(['@unraid/plugin-example'], { bundled: false, restart: true });
|
||||||
|
|
||||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
expect(mockPluginManagementService.addPlugin).toHaveBeenCalledWith('@unraid/plugin-example');
|
||||||
mutation: expect.anything(),
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
names: ['@unraid/plugin-example'],
|
|
||||||
bundled: false,
|
|
||||||
restart: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith('Added plugin @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 () => {
|
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 });
|
await command.run(['@unraid/bundled-plugin'], { bundled: true, restart: true });
|
||||||
|
|
||||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
expect(mockPluginManagementService.addBundledPlugin).toHaveBeenCalledWith(
|
||||||
mutation: expect.anything(),
|
'@unraid/bundled-plugin'
|
||||||
variables: {
|
);
|
||||||
input: {
|
|
||||||
names: ['@unraid/bundled-plugin'],
|
|
||||||
bundled: true,
|
|
||||||
restart: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith('Added bundled plugin @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 () => {
|
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 });
|
await command.run(['@unraid/plugin'], { bundled: false, restart: false });
|
||||||
|
|
||||||
|
expect(mockPluginManagementService.addPlugin).toHaveBeenCalledWith('@unraid/plugin');
|
||||||
|
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||||
expect(mockRestartCommand.run).not.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 () => {
|
it('should error when no package name provided', async () => {
|
||||||
await command.run([], { bundled: false, restart: true });
|
await command.run([], { bundled: false, restart: true });
|
||||||
|
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith('Package name is required.');
|
expect(mockLogger.error).toHaveBeenCalledWith('Package name is required.');
|
||||||
|
expect(mockApiConfigPersistence.persist).not.toHaveBeenCalled();
|
||||||
|
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||||
expect(process.exitCode).toBe(1);
|
expect(process.exitCode).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -142,57 +124,62 @@ describe('Plugin Commands', () => {
|
|||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
RemovePluginCommand,
|
RemovePluginCommand,
|
||||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
|
||||||
{ provide: LogService, useValue: mockLogger },
|
{ provide: LogService, useValue: mockLogger },
|
||||||
|
{ provide: PluginManagementService, useValue: mockPluginManagementService },
|
||||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||||
|
{ provide: InquirerService, useValue: mockInquirerService },
|
||||||
|
{ provide: ApiConfigPersistence, useValue: mockApiConfigPersistence },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
command = module.get<RemovePluginCommand>(RemovePluginCommand);
|
command = module.get<RemovePluginCommand>(RemovePluginCommand);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove a plugin successfully', async () => {
|
it('should remove plugins successfully', async () => {
|
||||||
const mockClient = {
|
mockInquirerService.prompt.mockResolvedValue({
|
||||||
mutate: vi.fn().mockResolvedValue({
|
plugins: ['@unraid/plugin-example', '@unraid/plugin-test'],
|
||||||
data: {
|
restart: true,
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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-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();
|
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle removing bundled plugins', async () => {
|
it('should skip restart when --no-restart is specified', async () => {
|
||||||
const mockClient = {
|
mockInquirerService.prompt.mockResolvedValue({
|
||||||
mutate: vi.fn().mockResolvedValue({
|
plugins: ['@unraid/plugin-example'],
|
||||||
data: {
|
restart: false,
|
||||||
removePlugin: true, // Manual restart required
|
});
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
await command.run([], { restart: false });
|
||||||
|
|
||||||
await command.run(['@unraid/bundled-plugin'], { bundled: true, restart: true });
|
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
|
||||||
|
'@unraid/plugin-example'
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith('Removed bundled plugin @unraid/bundled-plugin');
|
);
|
||||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||||
|
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,8 +190,8 @@ describe('Plugin Commands', () => {
|
|||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
ListPluginCommand,
|
ListPluginCommand,
|
||||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
|
||||||
{ provide: LogService, useValue: mockLogger },
|
{ provide: LogService, useValue: mockLogger },
|
||||||
|
{ provide: PluginManagementService, useValue: mockPluginManagementService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -212,63 +199,37 @@ describe('Plugin Commands', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should list installed plugins', async () => {
|
it('should list installed plugins', async () => {
|
||||||
const mockClient = {
|
vi.mocked(PluginService.listPlugins).mockResolvedValue([
|
||||||
query: vi.fn().mockResolvedValue({
|
['@unraid/plugin-1', '1.0.0'],
|
||||||
data: {
|
['@unraid/plugin-2', '2.0.0'],
|
||||||
plugins: [
|
]);
|
||||||
{
|
mockPluginManagementService.plugins = ['@unraid/plugin-1', '@unraid/plugin-2'];
|
||||||
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);
|
|
||||||
|
|
||||||
await command.run();
|
await command.run();
|
||||||
|
|
||||||
expect(mockClient.query).toHaveBeenCalledWith({
|
|
||||||
query: expect.anything(),
|
|
||||||
});
|
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith('Installed plugins:\n');
|
expect(mockLogger.log).toHaveBeenCalledWith('Installed plugins:\n');
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-1@1.0.0 [API]');
|
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-1@1.0.0');
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-2@2.0.0 [API, CLI]');
|
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-2@2.0.0');
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith();
|
expect(mockLogger.log).toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle no plugins installed', async () => {
|
it('should handle no plugins installed', async () => {
|
||||||
const mockClient = {
|
vi.mocked(PluginService.listPlugins).mockResolvedValue([]);
|
||||||
query: vi.fn().mockResolvedValue({
|
mockPluginManagementService.plugins = [];
|
||||||
data: {
|
|
||||||
plugins: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
|
||||||
|
|
||||||
await command.run();
|
await command.run();
|
||||||
|
|
||||||
expect(mockLogger.log).toHaveBeenCalledWith('No plugins installed.');
|
expect(mockLogger.log).toHaveBeenCalledWith('No plugins installed.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle errors', async () => {
|
it('should warn about plugins not installed', async () => {
|
||||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
vi.mocked(PluginService.listPlugins).mockResolvedValue([['@unraid/plugin-1', '1.0.0']]);
|
||||||
|
mockPluginManagementService.plugins = ['@unraid/plugin-1', '@unraid/plugin-2'];
|
||||||
|
|
||||||
await command.run();
|
await command.run();
|
||||||
|
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith('Failed to list plugins:', expect.any(Error));
|
expect(mockLogger.warn).toHaveBeenCalledWith('1 plugins are not installed:');
|
||||||
expect(process.exitCode).toBe(1);
|
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 { 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 { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||||
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
||||||
|
|
||||||
// Mock log service
|
// Mock log service
|
||||||
const mockLogService = {
|
const mockLogService: ILogService = {
|
||||||
debug: vi.fn(),
|
shouldLog: vi.fn(),
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
clear: 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
|
// Mock ApiReportService
|
||||||
@@ -101,7 +107,7 @@ describe('ReportCommand', () => {
|
|||||||
|
|
||||||
// Verify report was logged
|
// Verify report was logged
|
||||||
expect(mockLogService.clear).toHaveBeenCalled();
|
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 () => {
|
it('should handle API not running gracefully', async () => {
|
||||||
@@ -113,7 +119,7 @@ describe('ReportCommand', () => {
|
|||||||
expect(mockApiReportService.generateReport).not.toHaveBeenCalled();
|
expect(mockApiReportService.generateReport).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Verify warning was logged
|
// Verify warning was logged
|
||||||
expect(mockLogService.warn).toHaveBeenCalledWith(
|
expect(mockLogService.always).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('API is not running')
|
expect.stringContaining('API is not running')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -128,7 +134,7 @@ describe('ReportCommand', () => {
|
|||||||
expect(mockLogService.debug).toHaveBeenCalledWith(
|
expect(mockLogService.debug).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('Error generating report via GraphQL')
|
expect.stringContaining('Error generating report via GraphQL')
|
||||||
);
|
);
|
||||||
expect(mockLogService.warn).toHaveBeenCalledWith(
|
expect(mockLogService.always).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('Failed to generate system report')
|
expect.stringContaining('Failed to generate system report')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
PluginCommand,
|
PluginCommand,
|
||||||
RemovePluginCommand,
|
RemovePluginCommand,
|
||||||
} from '@app/unraid-api/cli/plugins/plugin.command.js';
|
} 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 { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||||
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
||||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||||
@@ -72,6 +74,7 @@ const DEFAULT_PROVIDERS = [
|
|||||||
DeleteApiKeyQuestionSet,
|
DeleteApiKeyQuestionSet,
|
||||||
AddSSOUserQuestionSet,
|
AddSSOUserQuestionSet,
|
||||||
RemoveSSOUserQuestionSet,
|
RemoveSSOUserQuestionSet,
|
||||||
|
RemovePluginQuestionSet,
|
||||||
DeveloperQuestions,
|
DeveloperQuestions,
|
||||||
DeveloperToolsService,
|
DeveloperToolsService,
|
||||||
LogService,
|
LogService,
|
||||||
@@ -85,7 +88,12 @@ const DEFAULT_PROVIDERS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LegacyConfigModule, ApiConfigModule, GlobalDepsModule, PluginCliModule.register()],
|
imports: [
|
||||||
|
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
|
||||||
|
ApiConfigModule,
|
||||||
|
GlobalDepsModule,
|
||||||
|
PluginCliModule.register(),
|
||||||
|
],
|
||||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||||
exports: [ApiReportService],
|
exports: [ApiReportService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { levels, LogLevel } from '@app/core/log.js';
|
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()
|
@Injectable()
|
||||||
export class LogService {
|
export class LogService implements ILogService {
|
||||||
private logger = console;
|
private logger = console;
|
||||||
|
private suppressLogs = SUPPRESS_LOGS;
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.logger.clear();
|
if (!this.suppressLogs) {
|
||||||
|
this.logger.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldLog(level: LogLevel): boolean {
|
shouldLog(level: LogLevel): boolean {
|
||||||
|
if (this.suppressLogs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const logLevelsLowToHigh = levels;
|
const logLevelsLowToHigh = levels;
|
||||||
const shouldLog =
|
const shouldLog =
|
||||||
logLevelsLowToHigh.indexOf(level) >=
|
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 {
|
debug(...messages: unknown[]): void {
|
||||||
if (this.shouldLog('debug')) {
|
if (this.shouldLog('debug')) {
|
||||||
this.logger.debug(...messages);
|
this.logger.debug(...messages);
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||||
import { ADD_PLUGIN_MUTATION } from '@app/unraid-api/cli/mutations/add-plugin.mutation.js';
|
import {
|
||||||
import { REMOVE_PLUGIN_MUTATION } from '@app/unraid-api/cli/mutations/remove-plugin.mutation.js';
|
NoPluginsFoundError,
|
||||||
import { PLUGINS_QUERY } from '@app/unraid-api/cli/queries/plugins.query.js';
|
RemovePluginQuestionSet,
|
||||||
|
} from '@app/unraid-api/cli/plugins/remove-plugin.questions.js';
|
||||||
import { RestartCommand } from '@app/unraid-api/cli/restart.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';
|
||||||
|
import { parsePackageArg } from '@app/utils.js';
|
||||||
|
|
||||||
interface InstallPluginCommandOptions {
|
interface InstallPluginCommandOptions {
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
@@ -24,7 +28,8 @@ export class InstallPluginCommand extends CommandRunner {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly logService: LogService,
|
private readonly logService: LogService,
|
||||||
private readonly restartCommand: RestartCommand,
|
private readonly restartCommand: RestartCommand,
|
||||||
private readonly internalClient: CliInternalClientService
|
private readonly pluginManagementService: PluginManagementService,
|
||||||
|
private readonly apiConfigPersistence: ApiConfigPersistence
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -35,35 +40,16 @@ export class InstallPluginCommand extends CommandRunner {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (options.bundled) {
|
||||||
try {
|
await this.pluginManagementService.addBundledPlugin(...passedParams);
|
||||||
const client = await this.internalClient.getClient();
|
this.logService.log(`Added bundled plugin ${passedParams.join(', ')}`);
|
||||||
|
} else {
|
||||||
const result = await client.mutate({
|
await this.pluginManagementService.addPlugin(...passedParams);
|
||||||
mutation: ADD_PLUGIN_MUTATION,
|
this.logService.log(`Added plugin ${passedParams.join(', ')}`);
|
||||||
variables: {
|
}
|
||||||
input: {
|
await this.apiConfigPersistence.persist();
|
||||||
names: passedParams,
|
if (options.restart) {
|
||||||
bundled: options.bundled,
|
await this.restartCommand.run();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,66 +72,57 @@ export class InstallPluginCommand extends CommandRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RemovePluginCommandOptions {
|
||||||
|
plugins?: string[];
|
||||||
|
restart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@SubCommand({
|
@SubCommand({
|
||||||
name: 'remove',
|
name: 'remove',
|
||||||
aliases: ['rm'],
|
aliases: ['rm'],
|
||||||
description: 'Remove a plugin peer dependency.',
|
description: 'Remove plugin peer dependencies.',
|
||||||
arguments: '<package>',
|
|
||||||
})
|
})
|
||||||
export class RemovePluginCommand extends CommandRunner {
|
export class RemovePluginCommand extends CommandRunner {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logService: LogService,
|
private readonly logService: LogService,
|
||||||
private readonly internalClient: CliInternalClientService,
|
private readonly pluginManagementService: PluginManagementService,
|
||||||
private readonly restartCommand: RestartCommand
|
private readonly restartCommand: RestartCommand,
|
||||||
|
private readonly inquirerService: InquirerService,
|
||||||
|
private readonly apiConfigPersistence: ApiConfigPersistence
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
|
async run(_passedParams: string[], options?: RemovePluginCommandOptions): Promise<void> {
|
||||||
if (passedParams.length === 0) {
|
try {
|
||||||
this.logService.error('Package name is required.');
|
options = await this.inquirerService.prompt(RemovePluginQuestionSet.name, options);
|
||||||
process.exitCode = 1;
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await this.pluginManagementService.removePlugin(...options.plugins);
|
||||||
const client = await this.internalClient.getClient();
|
for (const plugin of options.plugins) {
|
||||||
|
this.logService.log(`Removed plugin ${plugin}`);
|
||||||
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.apiConfigPersistence.persist();
|
||||||
|
|
||||||
@Option({
|
if (options.restart) {
|
||||||
flags: '-b, --bundled',
|
await this.restartCommand.run();
|
||||||
description: 'Uninstall a bundled plugin',
|
}
|
||||||
defaultValue: false,
|
|
||||||
})
|
|
||||||
parseBundled(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Option({
|
@Option({
|
||||||
@@ -166,39 +143,34 @@ export class RemovePluginCommand extends CommandRunner {
|
|||||||
export class ListPluginCommand extends CommandRunner {
|
export class ListPluginCommand extends CommandRunner {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logService: LogService,
|
private readonly logService: LogService,
|
||||||
private readonly internalClient: CliInternalClientService
|
private readonly pluginManagementService: PluginManagementService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
try {
|
const configPlugins = this.pluginManagementService.plugins;
|
||||||
const client = await this.internalClient.getClient();
|
const installedPlugins = await PluginService.listPlugins();
|
||||||
|
|
||||||
const result = await client.query({
|
// this can happen if configPlugins is a super set of installedPlugins
|
||||||
query: PLUGINS_QUERY,
|
if (installedPlugins.length !== configPlugins.length) {
|
||||||
});
|
const configSet = new Set(configPlugins.map((plugin) => parsePackageArg(plugin).name));
|
||||||
|
const installedSet = new Set(installedPlugins.map(([name]) => name));
|
||||||
const plugins = result.data?.plugins || [];
|
const notInstalled = Array.from(configSet.difference(installedSet));
|
||||||
|
this.logService.warn(`${notInstalled.length} plugins are not installed:`);
|
||||||
if (plugins.length === 0) {
|
this.logService.table('warn', notInstalled);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { execa, ExecaError } from 'execa';
|
||||||
|
|
||||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
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';
|
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||||
|
|
||||||
type CmdContext = Options & {
|
type CmdContext = Options & {
|
||||||
@@ -102,6 +102,6 @@ export class PM2Service {
|
|||||||
* Ensures that the dependencies necessary for PM2 to start and operate are present.
|
* Ensures that the dependencies necessary for PM2 to start and operate are present.
|
||||||
*/
|
*/
|
||||||
async ensurePm2Dependencies() {
|
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) {
|
if (!apiRunning) {
|
||||||
this.logger.warn(
|
this.logger.always(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
error: 'API is not running. Please start the API server before running a report.',
|
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);
|
const report = await this.apiReportService.generateReport(apiRunning);
|
||||||
|
|
||||||
this.logger.clear();
|
this.logger.clear();
|
||||||
this.logger.info(JSON.stringify(report, null, 2));
|
this.logger.always(JSON.stringify(report, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.debug('Error generating report via GraphQL: ' + error);
|
this.logger.debug('Error generating report via GraphQL: ' + error);
|
||||||
this.logger.warn(
|
this.logger.always(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
error: 'Failed to generate system report. Please ensure the API is running and properly configured.',
|
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) => {
|
private createErrorAndExit = (errorMessage: string) => {
|
||||||
this.logger.error(
|
this.logger.always(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
valid: false,
|
valid: false,
|
||||||
@@ -104,7 +104,7 @@ export class ValidateTokenCommand extends CommandRunner {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (ssoUsers.includes(username)) {
|
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);
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
this.createErrorAndExit('Username on token does not match');
|
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 { ConfigService, registerAs } from '@nestjs/config';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -10,8 +10,6 @@ import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';
|
|||||||
|
|
||||||
export { type ApiConfig };
|
export { type ApiConfig };
|
||||||
|
|
||||||
const logger = new Logger('ApiConfig');
|
|
||||||
|
|
||||||
const createDefaultConfig = (): ApiConfig => ({
|
const createDefaultConfig = (): ApiConfig => ({
|
||||||
version: API_VERSION,
|
version: API_VERSION,
|
||||||
extraOrigins: [],
|
extraOrigins: [],
|
||||||
@@ -23,17 +21,14 @@ const createDefaultConfig = (): ApiConfig => ({
|
|||||||
/**
|
/**
|
||||||
* Simple file-based config loading for plugin discovery (outside of nestjs DI container).
|
* Simple file-based config loading for plugin discovery (outside of nestjs DI container).
|
||||||
* This avoids complex DI container instantiation during module loading.
|
* 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 () => {
|
export const loadApiConfig = async () => {
|
||||||
const defaultConfig = createDefaultConfig();
|
const defaultConfig = createDefaultConfig();
|
||||||
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();
|
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();
|
||||||
|
|
||||||
let diskConfig: Partial<ApiConfig> = {};
|
const diskConfig: Partial<ApiConfig> = await apiHandler.loadConfig();
|
||||||
try {
|
|
||||||
diskConfig = await apiHandler.loadConfig();
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Failed to load API config from disk:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultConfig,
|
...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 { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||||
|
|
||||||
console.log('ENVIRONMENT', ENVIRONMENT);
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
GlobalDepsModule,
|
GlobalDepsModule,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PluginManagementService {
|
export class PluginManagementService {
|
||||||
|
static WORKSPACE_PACKAGES_TO_VENDOR = ['@unraid/shared', 'unraid-api-plugin-connect'];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService<{ api: ApiConfig }, true>,
|
private readonly configService: ConfigService<{ api: ApiConfig }, true>,
|
||||||
private readonly dependencyService: DependencyService
|
private readonly dependencyService: DependencyService
|
||||||
@@ -22,6 +24,15 @@ export class PluginManagementService {
|
|||||||
await this.dependencyService.rebuildVendorArchive();
|
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[]) {
|
async removePlugin(...plugins: string[]) {
|
||||||
const removed = this.removePluginFromConfig(...plugins);
|
const removed = this.removePluginFromConfig(...plugins);
|
||||||
await this.uninstallPlugins(...removed);
|
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.
|
* @param plugins - The plugins to install.
|
||||||
* @returns The execa result of the npm command.
|
* @returns The execa result of the npm command.
|
||||||
*/
|
*/
|
||||||
private installPlugins(...plugins: string[]) {
|
private async installPlugins(...plugins: string[]) {
|
||||||
return this.dependencyService.npm('i', '--save-peer', '--save-exact', ...plugins);
|
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.
|
* @param plugins - The plugins to uninstall.
|
||||||
* @returns The execa result of the npm command.
|
* @returns The execa result of the npm command.
|
||||||
*/
|
*/
|
||||||
private uninstallPlugins(...plugins: string[]) {
|
private async uninstallPlugins(...plugins: string[]) {
|
||||||
return this.dependencyService.npm('uninstall', ...plugins);
|
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 { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.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({})
|
@Module({})
|
||||||
export class PluginModule {
|
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> {
|
static async register(): Promise<DynamicModule> {
|
||||||
const plugins = await PluginService.getPlugins();
|
const plugins = await PluginService.getPlugins();
|
||||||
@@ -18,9 +26,7 @@ export class PluginModule {
|
|||||||
.filter((plugin) => plugin.ApiModule)
|
.filter((plugin) => plugin.ApiModule)
|
||||||
.map((plugin) => plugin.ApiModule!);
|
.map((plugin) => plugin.ApiModule!);
|
||||||
|
|
||||||
const pluginList = apiModules.map((plugin) => plugin.name).join(', ');
|
PluginModule.apiList = apiModules.map((plugin) => plugin.name);
|
||||||
PluginModule.logger.log(`Found ${apiModules.length} API plugins: ${pluginList}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
module: PluginModule,
|
module: PluginModule,
|
||||||
imports: [GlobalDepsModule, ResolversModule, ApiConfigModule, ...apiModules],
|
imports: [GlobalDepsModule, ResolversModule, ApiConfigModule, ...apiModules],
|
||||||
@@ -32,7 +38,15 @@ export class PluginModule {
|
|||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class PluginCliModule {
|
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> {
|
static async register(): Promise<DynamicModule> {
|
||||||
const plugins = await PluginService.getPlugins();
|
const plugins = await PluginService.getPlugins();
|
||||||
@@ -40,9 +54,7 @@ export class PluginCliModule {
|
|||||||
.filter((plugin) => plugin.CliModule)
|
.filter((plugin) => plugin.CliModule)
|
||||||
.map((plugin) => plugin.CliModule!);
|
.map((plugin) => plugin.CliModule!);
|
||||||
|
|
||||||
const cliList = cliModules.map((plugin) => plugin.name).join(', ');
|
PluginCliModule.cliList = cliModules.map((plugin) => plugin.name);
|
||||||
PluginCliModule.logger.debug(`Found ${cliModules.length} CLI plugins: ${cliList}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
module: PluginCliModule,
|
module: PluginCliModule,
|
||||||
imports: [GlobalDepsModule, ApiConfigModule, ...cliModules],
|
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 type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||||
|
import { pluginLogger } from '@app/core/log.js';
|
||||||
import { getPackageJson } from '@app/environment.js';
|
import { getPackageJson } from '@app/environment.js';
|
||||||
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||||
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
|
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
|
||||||
@@ -15,7 +16,7 @@ type Plugin = ApiNestPluginDefinition & {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PluginService {
|
export class PluginService {
|
||||||
private static readonly logger = new Logger(PluginService.name);
|
private static readonly logger = pluginLogger;
|
||||||
private static plugins: Promise<Plugin[]> | undefined;
|
private static plugins: Promise<Plugin[]> | undefined;
|
||||||
|
|
||||||
static async getPlugins() {
|
static async getPlugins() {
|
||||||
@@ -55,7 +56,6 @@ export class PluginService {
|
|||||||
if (plugins.errorOccurred) {
|
if (plugins.errorOccurred) {
|
||||||
PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`);
|
PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`);
|
||||||
}
|
}
|
||||||
PluginService.logger.log(`Loaded ${plugins.data.length} plugins.`);
|
|
||||||
return plugins.data;
|
return plugins.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ vi.mock('@app/core/log.js', () => ({
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
debug: 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', () => ({
|
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. */
|
/** Modifications that simply add a new file & remove it on rollback. */
|
||||||
const simpleTestCases: ModificationTestCase[] = [];
|
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) {
|
async function testModification(testCase: ModificationTestCase) {
|
||||||
const fileName = basename(testCase.fileUrl);
|
const fileName = basename(testCase.fileUrl);
|
||||||
const filePath = getPathToFixture(fileName);
|
const filePath = getPathToFixture(fileName);
|
||||||
const originalContent = await downloadOrRetrieveOriginalFile(filePath, testCase.fileUrl);
|
const originalContent = await readFile(filePath, 'utf-8').catch(() => '');
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const patcher = await new testCase.ModificationClass(logger);
|
const patcher = await new testCase.ModificationClass(logger);
|
||||||
const originalPath = patcher.filePath;
|
const originalPath = patcher.filePath;
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export default defineConfig(({ mode }): ViteUserConfig => {
|
|||||||
interop: 'auto',
|
interop: 'auto',
|
||||||
banner: (chunk) => {
|
banner: (chunk) => {
|
||||||
if (chunk.fileName === 'main.js' || chunk.fileName === 'cli.js') {
|
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 '#!/usr/local/bin/node\n';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ import { fileExists } from "./file.js";
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class ConfigFileHandler<T extends object> {
|
export class ConfigFileHandler<T extends object> {
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param definition The configuration definition that provides behavior
|
* @param definition The configuration definition that provides behavior
|
||||||
*/
|
*/
|
||||||
constructor(private readonly definition: ConfigDefinition<T>) {
|
constructor(private readonly definition: ConfigDefinition<T>, logger?: Logger) {
|
||||||
this.logger = new Logger(`ConfigFileHandler:${definition.fileName()}`);
|
this.logger = logger ?? new Logger(`ConfigFileHandler:${definition.fileName()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user