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:
Pujit Mehrotra
2025-11-13 10:49:50 -05:00
committed by GitHub
parent d56797c59f
commit 64eb9ce9b5
5 changed files with 223 additions and 32 deletions

View File

@@ -36,6 +36,7 @@ const mockPluginManagementService = {
addPlugin: vi.fn(),
addBundledPlugin: vi.fn(),
removePlugin: vi.fn(),
removePluginConfigOnly: vi.fn(),
removeBundledPlugin: vi.fn(),
plugins: [] as string[],
};
@@ -147,6 +148,7 @@ describe('Plugin Commands', () => {
'@unraid/plugin-example',
'@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-test');
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
@@ -178,9 +180,72 @@ describe('Plugin Commands', () => {
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
'@unraid/plugin-example'
);
expect(mockPluginManagementService.removePluginConfigOnly).not.toHaveBeenCalled();
expect(mockApiConfigPersistence.persist).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', () => {

View File

@@ -74,13 +74,15 @@ export class InstallPluginCommand extends CommandRunner {
interface RemovePluginCommandOptions {
plugins?: string[];
restart: boolean;
restart?: boolean;
bypassNpm?: boolean;
}
@SubCommand({
name: 'remove',
aliases: ['rm'],
description: 'Remove plugin peer dependencies.',
arguments: '[plugins...]',
})
export class RemovePluginCommand extends CommandRunner {
constructor(
@@ -93,9 +95,83 @@ export class RemovePluginCommand extends CommandRunner {
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 {
options = await this.inquirerService.prompt(RemovePluginQuestionSet.name, options);
return await this.inquirerService.prompt(RemovePluginQuestionSet.name, initialOptions);
} catch (error) {
if (error instanceof NoPluginsFoundError) {
this.logService.error(error.message);
@@ -108,30 +184,6 @@ export class RemovePluginCommand extends CommandRunner {
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;
}
}

View File

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

View File

@@ -35,8 +35,10 @@ export class PluginManagementService {
*/
async removePlugin(...plugins: string[]) {
const removed = this.removePluginFromConfig(...plugins);
await this.uninstallPlugins(...removed);
await this.dependencyService.rebuildVendorArchive();
const { unbundledRemoved } = await this.uninstallPlugins(...removed);
if (unbundledRemoved.length > 0) {
await this.dependencyService.rebuildVendorArchive();
}
}
/**
@@ -100,12 +102,15 @@ export class PluginManagementService {
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);
await this.removePluginConfigOnly(...bundled);
}
return { bundledRemoved: bundled, unbundledRemoved: unbundled };
}
/**------------------------------------------------------------------------
@@ -125,7 +130,13 @@ export class PluginManagementService {
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);
return removed;
}

View File

@@ -75,7 +75,7 @@ export class PluginResolver {
})
async removePlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
if (input.bundled) {
await this.pluginManagementService.removeBundledPlugin(...input.names);
await this.pluginManagementService.removePluginConfigOnly(...input.names);
} else {
await this.pluginManagementService.removePlugin(...input.names);
}