mirror of
https://github.com/unraid/api.git
synced 2026-02-17 13:38:29 -06:00
feat(cli): make unraid-api plugins remove scriptable (#1774)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added --bypass-npm and --npm flags, support for passing plugin names as command args, and a restart option; CLI params now merge with interactive prompts. * **Bug Fixes** * Vendor archive rebuild is performed only when actual uninstalls occur. * Restart behavior uses resolved options for consistent restarts. * Removal can run as "config-only" without running package operations. * **Tests** * Expanded tests for bypass scenarios, prompt flows, config-only removals, and removal control flow. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -36,6 +36,7 @@ const mockPluginManagementService = {
|
|||||||
addPlugin: vi.fn(),
|
addPlugin: vi.fn(),
|
||||||
addBundledPlugin: vi.fn(),
|
addBundledPlugin: vi.fn(),
|
||||||
removePlugin: vi.fn(),
|
removePlugin: vi.fn(),
|
||||||
|
removePluginConfigOnly: vi.fn(),
|
||||||
removeBundledPlugin: vi.fn(),
|
removeBundledPlugin: vi.fn(),
|
||||||
plugins: [] as string[],
|
plugins: [] as string[],
|
||||||
};
|
};
|
||||||
@@ -147,6 +148,7 @@ describe('Plugin Commands', () => {
|
|||||||
'@unraid/plugin-example',
|
'@unraid/plugin-example',
|
||||||
'@unraid/plugin-test'
|
'@unraid/plugin-test'
|
||||||
);
|
);
|
||||||
|
expect(mockPluginManagementService.removePluginConfigOnly).not.toHaveBeenCalled();
|
||||||
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(mockLogger.log).toHaveBeenCalledWith('Removed plugin @unraid/plugin-test');
|
||||||
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||||
@@ -178,9 +180,72 @@ describe('Plugin Commands', () => {
|
|||||||
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
|
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
|
||||||
'@unraid/plugin-example'
|
'@unraid/plugin-example'
|
||||||
);
|
);
|
||||||
|
expect(mockPluginManagementService.removePluginConfigOnly).not.toHaveBeenCalled();
|
||||||
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
|
||||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should bypass npm uninstall when bypass flag is provided', async () => {
|
||||||
|
mockInquirerService.prompt.mockResolvedValue({
|
||||||
|
plugins: ['@unraid/plugin-example'],
|
||||||
|
restart: true,
|
||||||
|
bypassNpm: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await command.run([], { restart: true, bypassNpm: true });
|
||||||
|
|
||||||
|
expect(mockPluginManagementService.removePluginConfigOnly).toHaveBeenCalledWith(
|
||||||
|
'@unraid/plugin-example'
|
||||||
|
);
|
||||||
|
expect(mockPluginManagementService.removePlugin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve cli flags when prompt supplies plugins', async () => {
|
||||||
|
mockInquirerService.prompt.mockResolvedValue({
|
||||||
|
plugins: ['@unraid/plugin-example'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await command.run([], { restart: false, bypassNpm: true });
|
||||||
|
|
||||||
|
expect(mockPluginManagementService.removePluginConfigOnly).toHaveBeenCalledWith(
|
||||||
|
'@unraid/plugin-example'
|
||||||
|
);
|
||||||
|
expect(mockPluginManagementService.removePlugin).not.toHaveBeenCalled();
|
||||||
|
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should honor prompt restart value when cli flag not provided', async () => {
|
||||||
|
mockInquirerService.prompt.mockResolvedValue({
|
||||||
|
plugins: ['@unraid/plugin-example'],
|
||||||
|
restart: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await command.run([], {});
|
||||||
|
|
||||||
|
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
|
||||||
|
'@unraid/plugin-example'
|
||||||
|
);
|
||||||
|
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect passed params and skip inquirer', async () => {
|
||||||
|
await command.run(['@unraid/plugin-example'], { restart: true, bypassNpm: false });
|
||||||
|
|
||||||
|
expect(mockInquirerService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
|
||||||
|
'@unraid/plugin-example'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should bypass npm when flag provided with passed params', async () => {
|
||||||
|
await command.run(['@unraid/plugin-example'], { restart: true, bypassNpm: true });
|
||||||
|
|
||||||
|
expect(mockInquirerService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect(mockPluginManagementService.removePluginConfigOnly).toHaveBeenCalledWith(
|
||||||
|
'@unraid/plugin-example'
|
||||||
|
);
|
||||||
|
expect(mockPluginManagementService.removePlugin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ListPluginCommand', () => {
|
describe('ListPluginCommand', () => {
|
||||||
|
|||||||
@@ -74,13 +74,15 @@ export class InstallPluginCommand extends CommandRunner {
|
|||||||
|
|
||||||
interface RemovePluginCommandOptions {
|
interface RemovePluginCommandOptions {
|
||||||
plugins?: string[];
|
plugins?: string[];
|
||||||
restart: boolean;
|
restart?: boolean;
|
||||||
|
bypassNpm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubCommand({
|
@SubCommand({
|
||||||
name: 'remove',
|
name: 'remove',
|
||||||
aliases: ['rm'],
|
aliases: ['rm'],
|
||||||
description: 'Remove plugin peer dependencies.',
|
description: 'Remove plugin peer dependencies.',
|
||||||
|
arguments: '[plugins...]',
|
||||||
})
|
})
|
||||||
export class RemovePluginCommand extends CommandRunner {
|
export class RemovePluginCommand extends CommandRunner {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -93,9 +95,83 @@ export class RemovePluginCommand extends CommandRunner {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(_passedParams: string[], options?: RemovePluginCommandOptions): Promise<void> {
|
async run(passedParams: string[], options?: RemovePluginCommandOptions): Promise<void> {
|
||||||
|
const cliBypass = options?.bypassNpm;
|
||||||
|
const cliRestart = options?.restart;
|
||||||
|
const mergedOptions: RemovePluginCommandOptions = {
|
||||||
|
bypassNpm: cliBypass ?? false,
|
||||||
|
restart: cliRestart ?? true,
|
||||||
|
plugins: passedParams.length > 0 ? passedParams : options?.plugins,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolvedOptions = mergedOptions;
|
||||||
|
if (!mergedOptions.plugins?.length) {
|
||||||
|
const promptOptions = await this.promptForPlugins(mergedOptions);
|
||||||
|
if (!promptOptions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvedOptions = {
|
||||||
|
// precedence: cli > prompt > default (fallback)
|
||||||
|
bypassNpm: cliBypass ?? promptOptions.bypassNpm ?? mergedOptions.bypassNpm,
|
||||||
|
restart: cliRestart ?? promptOptions.restart ?? mergedOptions.restart,
|
||||||
|
// precedence: prompt > default (fallback)
|
||||||
|
plugins: promptOptions.plugins ?? mergedOptions.plugins,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedOptions.plugins?.length) {
|
||||||
|
this.logService.warn('No plugins selected for removal.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedOptions.bypassNpm) {
|
||||||
|
await this.pluginManagementService.removePluginConfigOnly(...resolvedOptions.plugins);
|
||||||
|
} else {
|
||||||
|
await this.pluginManagementService.removePlugin(...resolvedOptions.plugins);
|
||||||
|
}
|
||||||
|
for (const plugin of resolvedOptions.plugins) {
|
||||||
|
this.logService.log(`Removed plugin ${plugin}`);
|
||||||
|
}
|
||||||
|
await this.apiConfigPersistence.persist();
|
||||||
|
|
||||||
|
if (resolvedOptions.restart) {
|
||||||
|
await this.restartCommand.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option({
|
||||||
|
flags: '--no-restart',
|
||||||
|
description: 'do NOT restart the service after deploy',
|
||||||
|
defaultValue: true,
|
||||||
|
})
|
||||||
|
parseRestart(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option({
|
||||||
|
flags: '-b, --bypass-npm',
|
||||||
|
description: 'Bypass npm uninstall and only update the config',
|
||||||
|
defaultValue: false,
|
||||||
|
name: 'bypassNpm',
|
||||||
|
})
|
||||||
|
parseBypass(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option({
|
||||||
|
flags: '--npm',
|
||||||
|
description: 'Run npm uninstall for unbundled plugins (default behavior)',
|
||||||
|
name: 'bypassNpm',
|
||||||
|
})
|
||||||
|
parseRunNpm(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async promptForPlugins(
|
||||||
|
initialOptions: RemovePluginCommandOptions
|
||||||
|
): Promise<RemovePluginCommandOptions | undefined> {
|
||||||
try {
|
try {
|
||||||
options = await this.inquirerService.prompt(RemovePluginQuestionSet.name, options);
|
return await this.inquirerService.prompt(RemovePluginQuestionSet.name, initialOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NoPluginsFoundError) {
|
if (error instanceof NoPluginsFoundError) {
|
||||||
this.logService.error(error.message);
|
this.logService.error(error.message);
|
||||||
@@ -108,30 +184,6 @@ export class RemovePluginCommand extends CommandRunner {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.plugins || options.plugins.length === 0) {
|
|
||||||
this.logService.warn('No plugins selected for removal.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.pluginManagementService.removePlugin(...options.plugins);
|
|
||||||
for (const plugin of options.plugins) {
|
|
||||||
this.logService.log(`Removed plugin ${plugin}`);
|
|
||||||
}
|
|
||||||
await this.apiConfigPersistence.persist();
|
|
||||||
|
|
||||||
if (options.restart) {
|
|
||||||
await this.restartCommand.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Option({
|
|
||||||
flags: '--no-restart',
|
|
||||||
description: 'do NOT restart the service after deploy',
|
|
||||||
defaultValue: true,
|
|
||||||
})
|
|
||||||
parseRestart(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
|
||||||
|
|
||||||
|
describe('PluginManagementService', () => {
|
||||||
|
let service: PluginManagementService;
|
||||||
|
let configStore: string[];
|
||||||
|
let configService: {
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
set: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let dependencyService: {
|
||||||
|
npm: ReturnType<typeof vi.fn>;
|
||||||
|
rebuildVendorArchive: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configStore = ['unraid-api-plugin-connect', '@unraid/test-plugin'];
|
||||||
|
configService = {
|
||||||
|
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||||
|
if (key === 'api.plugins') {
|
||||||
|
return configStore ?? defaultValue ?? [];
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}),
|
||||||
|
set: vi.fn((key: string, value: unknown) => {
|
||||||
|
if (key === 'api.plugins' && Array.isArray(value)) {
|
||||||
|
configStore = [...value];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
dependencyService = {
|
||||||
|
npm: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rebuildVendorArchive: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new PluginManagementService(configService as never, dependencyService as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds vendor archive when removing unbundled plugins', async () => {
|
||||||
|
await service.removePlugin('@unraid/test-plugin');
|
||||||
|
|
||||||
|
expect(dependencyService.npm).toHaveBeenCalledWith('uninstall', '@unraid/test-plugin');
|
||||||
|
expect(dependencyService.rebuildVendorArchive).toHaveBeenCalledTimes(1);
|
||||||
|
expect(configStore).not.toContain('@unraid/test-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips vendor archive when only bundled plugins are removed', async () => {
|
||||||
|
await service.removePlugin('unraid-api-plugin-connect');
|
||||||
|
|
||||||
|
expect(dependencyService.npm).not.toHaveBeenCalled();
|
||||||
|
expect(dependencyService.rebuildVendorArchive).not.toHaveBeenCalled();
|
||||||
|
expect(configStore).not.toContain('unraid-api-plugin-connect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rebuild vendor archive when bypassing npm uninstall', async () => {
|
||||||
|
await service.removePluginConfigOnly('@unraid/test-plugin');
|
||||||
|
|
||||||
|
expect(dependencyService.npm).not.toHaveBeenCalled();
|
||||||
|
expect(dependencyService.rebuildVendorArchive).not.toHaveBeenCalled();
|
||||||
|
expect(configStore).not.toContain('@unraid/test-plugin');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,8 +35,10 @@ export class PluginManagementService {
|
|||||||
*/
|
*/
|
||||||
async removePlugin(...plugins: string[]) {
|
async removePlugin(...plugins: string[]) {
|
||||||
const removed = this.removePluginFromConfig(...plugins);
|
const removed = this.removePluginFromConfig(...plugins);
|
||||||
await this.uninstallPlugins(...removed);
|
const { unbundledRemoved } = await this.uninstallPlugins(...removed);
|
||||||
await this.dependencyService.rebuildVendorArchive();
|
if (unbundledRemoved.length > 0) {
|
||||||
|
await this.dependencyService.rebuildVendorArchive();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,12 +102,15 @@ export class PluginManagementService {
|
|||||||
private async uninstallPlugins(...plugins: string[]) {
|
private async uninstallPlugins(...plugins: string[]) {
|
||||||
const bundled = plugins.filter((plugin) => this.isBundled(plugin));
|
const bundled = plugins.filter((plugin) => this.isBundled(plugin));
|
||||||
const unbundled = plugins.filter((plugin) => !this.isBundled(plugin));
|
const unbundled = plugins.filter((plugin) => !this.isBundled(plugin));
|
||||||
|
|
||||||
if (unbundled.length > 0) {
|
if (unbundled.length > 0) {
|
||||||
await this.dependencyService.npm('uninstall', ...unbundled);
|
await this.dependencyService.npm('uninstall', ...unbundled);
|
||||||
}
|
}
|
||||||
if (bundled.length > 0) {
|
if (bundled.length > 0) {
|
||||||
await this.removeBundledPlugin(...bundled);
|
await this.removePluginConfigOnly(...bundled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { bundledRemoved: bundled, unbundledRemoved: unbundled };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**------------------------------------------------------------------------
|
/**------------------------------------------------------------------------
|
||||||
@@ -125,7 +130,13 @@ export class PluginManagementService {
|
|||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeBundledPlugin(...plugins: string[]) {
|
/**
|
||||||
|
* Removes plugins from the config without touching npm (used for bundled/default bypass flow).
|
||||||
|
*
|
||||||
|
* @param plugins - The plugins to remove.
|
||||||
|
* @returns The list of plugins removed from the config.
|
||||||
|
*/
|
||||||
|
async removePluginConfigOnly(...plugins: string[]) {
|
||||||
const removed = this.removePluginFromConfig(...plugins);
|
const removed = this.removePluginFromConfig(...plugins);
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export class PluginResolver {
|
|||||||
})
|
})
|
||||||
async removePlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
|
async removePlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
|
||||||
if (input.bundled) {
|
if (input.bundled) {
|
||||||
await this.pluginManagementService.removeBundledPlugin(...input.names);
|
await this.pluginManagementService.removePluginConfigOnly(...input.names);
|
||||||
} else {
|
} else {
|
||||||
await this.pluginManagementService.removePlugin(...input.names);
|
await this.pluginManagementService.removePlugin(...input.names);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user