feat: api plugin management via CLI (#1416)

implements unraid-api `plugins list`, `plugins install`, `plugins remove` commands via a new `DependencyService` that invokes npm.

## Summary by CodeRabbit

- **New Features**
- Enhanced plugin management with install, remove, and list commands
supporting bundled plugins and restart control.
- Added plugin persistence and configuration synchronization across API
settings and interfaces.
- Introduced dependency management service for streamlined npm
operations and vendor archive rebuilding.
- **Bug Fixes**
- Improved plugin listing accuracy with warnings for configured but
missing plugins.
- **Chores**
- Refactored CLI modules and services for unified plugin management and
dependency handling.
- Updated API configuration loading and persistence for better
separation of concerns.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pujit Mehrotra
2025-06-12 09:37:36 -04:00
committed by GitHub
parent 184b76de1c
commit 3dcbfbe489
16 changed files with 435 additions and 218 deletions

View File

@@ -5,5 +5,6 @@
"https://test.com" "https://test.com"
], ],
"sandbox": true, "sandbox": true,
"ssoSubIds": [] "ssoSubIds": [],
"plugins": []
} }

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { csvStringToArray, formatDatetime } from '@app/utils.js'; import { csvStringToArray, formatDatetime, parsePackageArg } from '@app/utils.js';
describe('formatDatetime', () => { describe('formatDatetime', () => {
const testDate = new Date('2024-02-14T12:34:56'); const testDate = new Date('2024-02-14T12:34:56');
@@ -103,3 +103,78 @@ describe('csvStringToArray', () => {
expect(csvStringToArray(',one,')).toEqual(['one']); expect(csvStringToArray(',one,')).toEqual(['one']);
}); });
}); });
describe('parsePackageArg', () => {
it('parses simple package names without version', () => {
expect(parsePackageArg('lodash')).toEqual({ name: 'lodash' });
expect(parsePackageArg('express')).toEqual({ name: 'express' });
expect(parsePackageArg('react')).toEqual({ name: 'react' });
});
it('parses simple package names with version', () => {
expect(parsePackageArg('lodash@4.17.21')).toEqual({ name: 'lodash', version: '4.17.21' });
expect(parsePackageArg('express@4.18.2')).toEqual({ name: 'express', version: '4.18.2' });
expect(parsePackageArg('react@18.2.0')).toEqual({ name: 'react', version: '18.2.0' });
});
it('parses scoped package names without version', () => {
expect(parsePackageArg('@types/node')).toEqual({ name: '@types/node' });
expect(parsePackageArg('@angular/core')).toEqual({ name: '@angular/core' });
expect(parsePackageArg('@nestjs/common')).toEqual({ name: '@nestjs/common' });
});
it('parses scoped package names with version', () => {
expect(parsePackageArg('@types/node@18.15.0')).toEqual({
name: '@types/node',
version: '18.15.0',
});
expect(parsePackageArg('@angular/core@15.2.0')).toEqual({
name: '@angular/core',
version: '15.2.0',
});
expect(parsePackageArg('@nestjs/common@9.3.12')).toEqual({
name: '@nestjs/common',
version: '9.3.12',
});
});
it('handles version ranges and tags', () => {
expect(parsePackageArg('lodash@^4.17.0')).toEqual({ name: 'lodash', version: '^4.17.0' });
expect(parsePackageArg('react@~18.2.0')).toEqual({ name: 'react', version: '~18.2.0' });
expect(parsePackageArg('express@latest')).toEqual({ name: 'express', version: 'latest' });
expect(parsePackageArg('vue@beta')).toEqual({ name: 'vue', version: 'beta' });
expect(parsePackageArg('@types/node@next')).toEqual({ name: '@types/node', version: 'next' });
});
it('handles multiple @ symbols correctly', () => {
expect(parsePackageArg('package@1.0.0@extra')).toEqual({
name: 'package@1.0.0',
version: 'extra',
});
expect(parsePackageArg('@scope/pkg@1.0.0@extra')).toEqual({
name: '@scope/pkg@1.0.0',
version: 'extra',
});
});
it('ignores versions that contain forward slashes', () => {
expect(parsePackageArg('package@github:user/repo')).toEqual({
name: 'package@github:user/repo',
});
expect(parsePackageArg('@scope/pkg@git+https://github.com/user/repo.git')).toEqual({
name: '@scope/pkg@git+https://github.com/user/repo.git',
});
});
it('handles edge cases', () => {
expect(parsePackageArg('@')).toEqual({ name: '@' });
expect(parsePackageArg('@scope')).toEqual({ name: '@scope' });
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
expect(parsePackageArg('@scope/pkg@')).toEqual({ name: '@scope/pkg@' });
});
it('handles empty version strings', () => {
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
expect(parsePackageArg('@scope/package@')).toEqual({ name: '@scope/package@' });
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import * as path from 'path';
import { execa } from 'execa';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { getPackageJsonPath } from '@app/environment.js';
@Injectable()
export class DependencyService {
constructor() {}
/**
* Executes an npm command.
*
* @param npmArgs - The arguments to pass to npm.
* @returns The execa result of the npm command.
*/
async npm(...npmArgs: string[]) {
return await execa('npm', [...npmArgs], {
stdio: 'inherit',
cwd: path.dirname(getPackageJsonPath()),
});
}
/**
* Installs dependencies for the api using npm.
*
* @throws {Error} from execa if the npm install command fails.
*/
async npmInstall(): Promise<void> {
await this.npm('install');
}
/**
* Rebuilds the vendored dependency archive for the api and stores it on the boot drive.
* If the rc.unraid-api script is not found, an error is thrown.
*
* @throws {Error} from execa if the rc.unraid-api command fails.
*/
async rebuildVendorArchive(): Promise<void> {
const rcUnraidApi = '/etc/rc.d/rc.unraid-api';
if (!(await fileExists(rcUnraidApi))) {
throw new Error('[rebuild-vendor-archive] rc.unraid-api not found; no action taken!');
}
await execa(rcUnraidApi, ['archive-dependencies'], { stdio: 'inherit' });
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
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';
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js'; import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js'; import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
@@ -10,7 +11,12 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js'; import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
import { LogService } from '@app/unraid-api/cli/log.service.js'; import { LogService } from '@app/unraid-api/cli/log.service.js';
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js'; import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
import { PluginCommandModule } from '@app/unraid-api/cli/plugins/plugin.cli.module.js'; import {
InstallPluginCommand,
ListPluginCommand,
PluginCommand,
RemovePluginCommand,
} from '@app/unraid-api/cli/plugins/plugin.command.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';
@@ -30,26 +36,30 @@ import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js'; import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js'; import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
// cli - plugin add/remove
// plugin generator
const DEFAULT_COMMANDS = [ const DEFAULT_COMMANDS = [
ApiKeyCommand, ApiKeyCommand,
ConfigCommand, ConfigCommand,
DeveloperCommand, DeveloperCommand,
LogsCommand, LogsCommand,
ReportCommand, ReportCommand,
VersionCommand,
// Lifecycle commands
SwitchEnvCommand,
RestartCommand, RestartCommand,
StartCommand, StartCommand,
StatusCommand, StatusCommand,
StopCommand, StopCommand,
SwitchEnvCommand, // SSO commands
VersionCommand,
SSOCommand, SSOCommand,
ValidateTokenCommand, ValidateTokenCommand,
AddSSOUserCommand, AddSSOUserCommand,
RemoveSSOUserCommand, RemoveSSOUserCommand,
ListSSOUserCommand, ListSSOUserCommand,
// Plugin commands
PluginCommand,
ListPluginCommand,
InstallPluginCommand,
RemovePluginCommand,
] as const; ] as const;
const DEFAULT_PROVIDERS = [ const DEFAULT_PROVIDERS = [
@@ -62,10 +72,11 @@ const DEFAULT_PROVIDERS = [
PM2Service, PM2Service,
ApiKeyService, ApiKeyService,
SsoUserService, SsoUserService,
DependencyService,
] as const; ] as const;
@Module({ @Module({
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register(), PluginCommandModule], imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register()],
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS], providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
}) })
export class CliModule {} export class CliModule {}

View File

@@ -19,6 +19,12 @@ export class LogService {
return shouldLog; return shouldLog;
} }
table(level: LogLevel, data: unknown, columns?: string[]) {
if (this.shouldLog(level)) {
console.table(data, columns);
}
}
log(...messages: unknown[]): void { log(...messages: unknown[]): void {
if (this.shouldLog('info')) { if (this.shouldLog('info')) {
this.logger.log(...messages); this.logger.log(...messages);

View File

@@ -1,131 +0,0 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs/promises';
import * as path from 'path';
import type { PackageJson } from 'type-fest';
import { execa } from 'execa';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { getPackageJson, getPackageJsonPath } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
@Injectable()
export class DependencyService {
constructor(private readonly logger: LogService) {}
/**
* Writes the package.json file for the api.
*
* @param data - The data to write to the package.json file.
* @throws {Error} from fs.writeFile if the file cannot be written.
*/
private async writePackageJson(data: PackageJson): Promise<void> {
const packageJsonPath = getPackageJsonPath();
await fs.writeFile(packageJsonPath, JSON.stringify(data, null, 2) + '\n');
}
// Basic parser, assumes format 'name' or 'name@version'
private parsePackageArg(packageArg: string): { name: string; version?: string } {
const atIndex = packageArg.lastIndexOf('@');
// Handles scoped packages @scope/pkg or @scope/pkg@version and simple pkg@version
if (atIndex > 0) {
// Ensure '@' is not the first character
const name = packageArg.substring(0, atIndex);
const version = packageArg.substring(atIndex + 1);
// Basic check if version looks like a version (simplistic)
if (version && !version.includes('/')) {
// Avoid treating part of scope as version
return { name, version };
}
}
return { name: packageArg }; // No version or scoped package without version
}
/**
* Adds a peer dependency to the api. If bundled is true, the vendored package will be used.
* Note that this function does not check whether the package is, in fact, bundled.
*
* @param packageArg - The package name and version to add.
* @param bundled - Whether the package is bundled with the api.
* @returns The name, version, and bundled status of the added dependency.
*/
async addPeerDependency(
packageArg: string,
bundled: boolean
): Promise<{ name: string; version: string; bundled: boolean }> {
const { name } = this.parsePackageArg(packageArg);
if (!name) {
throw new Error('Invalid package name provided.');
}
const packageJson = getPackageJson();
packageJson.peerDependencies = packageJson.peerDependencies ?? {};
let finalVersion = '';
if (bundled) {
finalVersion = 'workspace:*';
packageJson.peerDependencies[name] = finalVersion;
packageJson.peerDependenciesMeta = packageJson.peerDependenciesMeta ?? {};
packageJson.peerDependenciesMeta[name] = { optional: true };
await this.writePackageJson(packageJson);
return { name, version: finalVersion, bundled };
}
await execa('npm', ['install', '--save-peer', '--save-exact', packageArg], {
cwd: path.dirname(getPackageJsonPath()),
});
const updatedPackageJson = getPackageJson();
return { name, version: updatedPackageJson.peerDependencies?.[name] ?? 'unknown', bundled };
}
/**
* Removes a peer dependency from the api.
*
* @param packageName - The name of the package to remove.
* @throws {Error} if the package name is invalid.
*/
async removePeerDependency(packageName: string): Promise<void> {
const packageJson = getPackageJson();
const { name } = this.parsePackageArg(packageName);
if (!name) {
throw new Error('Invalid package name provided.');
}
if (packageJson.peerDependencies?.[name]) {
delete packageJson.peerDependencies[name];
this.logger.log(`Removed peer dependency ${name}`);
} else {
this.logger.warn(`Peer dependency ${name} not found.`);
}
if (packageJson.peerDependenciesMeta?.[name]) {
delete packageJson.peerDependenciesMeta[name];
}
await this.writePackageJson(packageJson);
}
/**
* Installs dependencies for the api using npm.
*
* @throws {Error} from execa if the npm install command fails.
*/
async npmInstall(): Promise<void> {
const packageJsonPath = getPackageJsonPath();
await execa('npm', ['install'], { cwd: path.dirname(packageJsonPath) });
}
/**
* Rebuilds the vendored dependency archive for the api and stores it on the boot drive.
* If the rc.unraid-api script is not found, no action is taken, but a warning is logged.
*
* @throws {Error} from execa if the rc.unraid-api command fails.
*/
async rebuildVendorArchive(): Promise<void> {
const rcUnraidApi = '/etc/rc.d/rc.unraid-api';
if (!(await fileExists(rcUnraidApi))) {
this.logger.error('[rebuild-vendor-archive] rc.unraid-api not found; no action taken!');
return;
}
await execa(rcUnraidApi, ['archive-dependencies']);
}
}

View File

@@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { DependencyService } from '@app/unraid-api/cli/plugins/dependency.service.js';
import {
InstallPluginCommand,
ListPluginCommand,
PluginCommand,
RemovePluginCommand,
} from '@app/unraid-api/cli/plugins/plugin.command.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
const services = [DependencyService, LogService, PM2Service];
const commands = [
PluginCommand,
ListPluginCommand,
InstallPluginCommand,
RemovePluginCommand,
RestartCommand,
];
const moduleResources = [...services, ...commands];
@Module({
providers: moduleResources,
exports: moduleResources,
})
export class PluginCommandModule {}

View File

@@ -1,12 +1,16 @@
import { Injectable } from '@nestjs/common';
import { Command, CommandRunner, Option, SubCommand } from 'nest-commander'; import { Command, CommandRunner, Option, SubCommand } from 'nest-commander';
import { LogService } from '@app/unraid-api/cli/log.service.js'; import { LogService } from '@app/unraid-api/cli/log.service.js';
import { DependencyService } from '@app/unraid-api/cli/plugins/dependency.service.js';
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js'; import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.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;
restart: boolean;
} }
@SubCommand({ @SubCommand({
@@ -17,30 +21,28 @@ interface InstallPluginCommandOptions {
}) })
export class InstallPluginCommand extends CommandRunner { export class InstallPluginCommand extends CommandRunner {
constructor( constructor(
private readonly dependencyService: DependencyService,
private readonly logService: LogService, private readonly logService: LogService,
private readonly restartCommand: RestartCommand private readonly restartCommand: RestartCommand,
private readonly pluginManagementService: PluginManagementService
) { ) {
super(); super();
} }
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> { async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
const [packageName] = passedParams; if (passedParams.length === 0) {
if (!packageName) {
this.logService.error('Package name is required.'); this.logService.error('Package name is required.');
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
try { if (options.bundled) {
await this.dependencyService.addPeerDependency(packageName, options.bundled); await this.pluginManagementService.addBundledPlugin(...passedParams);
this.logService.log(`Added ${packageName} as a peer dependency.`); this.logService.log(`Added bundled plugin ${passedParams.join(', ')}`);
if (!options.bundled) { } else {
await this.dependencyService.rebuildVendorArchive(); await this.pluginManagementService.addPlugin(...passedParams);
} this.logService.log(`Added plugin ${passedParams.join(', ')}`);
}
if (options.restart) {
await this.restartCommand.run(); await this.restartCommand.run();
} catch (error) {
this.logService.error(error);
process.exitCode = 1;
} }
} }
@@ -52,6 +54,15 @@ export class InstallPluginCommand extends CommandRunner {
parseBundled(): boolean { parseBundled(): boolean {
return true; return true;
} }
@Option({
flags: '--no-restart',
description: 'do NOT restart the service after deploy',
defaultValue: true,
})
parseRestart(value: boolean): boolean {
return false;
}
} }
@SubCommand({ @SubCommand({
@@ -62,27 +73,47 @@ export class InstallPluginCommand extends CommandRunner {
}) })
export class RemovePluginCommand extends CommandRunner { export class RemovePluginCommand extends CommandRunner {
constructor( constructor(
private readonly pluginService: DependencyService,
private readonly logService: LogService, private readonly logService: LogService,
private readonly pluginManagementService: PluginManagementService,
private readonly restartCommand: RestartCommand private readonly restartCommand: RestartCommand
) { ) {
super(); super();
} }
async run(passedParams: string[]): Promise<void> { async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
const [packageName] = passedParams; if (passedParams.length === 0) {
if (!packageName) {
this.logService.error('Package name is required.'); this.logService.error('Package name is required.');
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
try { if (options.bundled) {
await this.pluginService.removePeerDependency(packageName); await this.pluginManagementService.removeBundledPlugin(...passedParams);
await this.restartCommand.run(); this.logService.log(`Removed bundled plugin ${passedParams.join(', ')}`);
} catch (error) { } else {
this.logService.error(`Failed to remove plugin '${packageName}':`, error); await this.pluginManagementService.removePlugin(...passedParams);
process.exitCode = 1; this.logService.log(`Removed plugin ${passedParams.join(', ')}`);
} }
if (options.restart) {
await this.restartCommand.run();
}
}
@Option({
flags: '-b, --bundled',
description: 'Uninstall a bundled plugin',
defaultValue: false,
})
parseBundled(): boolean {
return true;
}
@Option({
flags: '--no-restart',
description: 'do NOT restart the service after deploy',
defaultValue: true,
})
parseRestart(value: boolean): boolean {
return false;
} }
} }
@@ -92,27 +123,52 @@ export class RemovePluginCommand extends CommandRunner {
options: { isDefault: true }, options: { isDefault: true },
}) })
export class ListPluginCommand extends CommandRunner { export class ListPluginCommand extends CommandRunner {
constructor(private readonly logService: LogService) { constructor(
private readonly logService: LogService,
private readonly pluginManagementService: PluginManagementService
) {
super(); super();
} }
async run(): Promise<void> { async run(): Promise<void> {
const plugins = await PluginService.listPlugins(); const configPlugins = this.pluginManagementService.plugins;
this.logService.log('Installed plugins:'); const installedPlugins = await PluginService.listPlugins();
plugins.forEach(([name, version]) => {
// this can happen if configPlugins is a super set of installedPlugins
if (installedPlugins.length !== configPlugins.length) {
const configSet = new Set(configPlugins.map((plugin) => parsePackageArg(plugin).name));
const installedSet = new Set(installedPlugins.map(([name]) => name));
const notInstalled = Array.from(configSet.difference(installedSet));
this.logService.warn(`${notInstalled.length} plugins are not installed:`);
this.logService.table('warn', notInstalled);
}
if (installedPlugins.length === 0) {
this.logService.log('No plugins installed.');
return;
}
this.logService.log('Installed plugins:\n');
installedPlugins.forEach(([name, version]) => {
this.logService.log(`☑️ ${name}@${version}`); this.logService.log(`☑️ ${name}@${version}`);
}); });
this.logService.log(); // for spacing
} }
} }
@Injectable()
@Command({ @Command({
name: 'plugins', name: 'plugins',
description: 'Manage Unraid API plugins (peer dependencies)', description: 'Manage Unraid API plugins (peer dependencies)',
subCommands: [InstallPluginCommand, RemovePluginCommand, ListPluginCommand], subCommands: [InstallPluginCommand, RemovePluginCommand, ListPluginCommand],
}) })
export class PluginCommand extends CommandRunner { export class PluginCommand extends CommandRunner {
constructor() { constructor(private readonly logger: LogService) {
super(); super();
} }
async run(): Promise<void> {}
async run(): Promise<void> {
this.logger.info('Please provide a subcommand or use --help for more information');
process.exit(0);
}
} }

View File

@@ -17,12 +17,22 @@ const createDefaultConfig = (): ApiConfig => ({
extraOrigins: [], extraOrigins: [],
sandbox: false, sandbox: false,
ssoSubIds: [], ssoSubIds: [],
plugins: [],
}); });
/** export const persistApiConfig = async (config: ApiConfig) => {
* Loads the API config from disk. If not found, returns the default config, but does not persist it. const apiConfig = new ApiStateConfig<ApiConfig>(
*/ {
export const apiConfig = registerAs<ApiConfig>('api', async () => { name: 'api',
defaultConfig: config,
parse: (data) => data as ApiConfig,
},
new ConfigPersistenceHelper()
);
return await apiConfig.persist(config);
};
export const loadApiConfig = async () => {
const defaultConfig = createDefaultConfig(); const defaultConfig = createDefaultConfig();
const apiConfig = new ApiStateConfig<ApiConfig>( const apiConfig = new ApiStateConfig<ApiConfig>(
{ {
@@ -38,7 +48,12 @@ export const apiConfig = registerAs<ApiConfig>('api', async () => {
...diskConfig, ...diskConfig,
version: API_VERSION, version: API_VERSION,
}; };
}); };
/**
* Loads the API config from disk. If not found, returns the default config, but does not persist it.
*/
export const apiConfig = registerAs<ApiConfig>('api', loadApiConfig);
@Injectable() @Injectable()
class ApiConfigPersistence { class ApiConfigPersistence {

View File

@@ -32,6 +32,7 @@ export class ApiSettings {
sandbox: this.configService.get('api.sandbox', { infer: true }), sandbox: this.configService.get('api.sandbox', { infer: true }),
extraOrigins: this.configService.get('api.extraOrigins', { infer: true }), extraOrigins: this.configService.get('api.extraOrigins', { infer: true }),
ssoSubIds: this.configService.get('api.ssoSubIds', { infer: true }), ssoSubIds: this.configService.get('api.ssoSubIds', { infer: true }),
plugins: this.configService.get('api.plugins', { infer: true }),
}; };
} }

View File

@@ -0,0 +1,116 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiConfig } from '@unraid/shared/services/api-config.js';
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
import { persistApiConfig } from '@app/unraid-api/config/api-config.module.js';
@Injectable()
export class PluginManagementService {
constructor(
private readonly configService: ConfigService<{ api: ApiConfig }, true>,
private readonly dependencyService: DependencyService
) {}
get plugins() {
return this.configService.get('api.plugins', [], { infer: true });
}
async addPlugin(...plugins: string[]) {
const added = this.addPluginToConfig(...plugins);
await this.persistConfig();
await this.installPlugins(...added);
await this.dependencyService.rebuildVendorArchive();
}
async removePlugin(...plugins: string[]) {
const removed = this.removePluginFromConfig(...plugins);
await this.persistConfig();
await this.uninstallPlugins(...removed);
await this.dependencyService.rebuildVendorArchive();
}
/**
* Adds plugins to the config.
*
* @param plugins - The plugins to add.
* @returns The list of plugins added to the config
*/
private addPluginToConfig(...plugins: string[]) {
const pluginSet = new Set(this.plugins);
const added: string[] = [];
plugins.forEach((plugin) => {
if (!pluginSet.has(plugin)) {
added.push(plugin);
}
pluginSet.add(plugin);
});
// @ts-expect-error - This is a valid config key
this.configService.set<string[]>('api.plugins', Array.from(pluginSet));
return added;
}
/**
* Removes plugins from the config.
*
* @param plugins - The plugins to remove.
* @returns The list of plugins removed from the config
*/
private removePluginFromConfig(...plugins: string[]) {
const pluginSet = new Set(this.plugins);
const removed = plugins.filter((plugin) => pluginSet.delete(plugin));
const pluginsArray = Array.from(pluginSet);
// @ts-expect-error - This is a valid config key
this.configService.set('api.plugins', pluginsArray);
return removed;
}
/**
* Installs plugins using npm.
*
* @param plugins - The plugins to install.
* @returns The execa result of the npm command.
*/
private installPlugins(...plugins: string[]) {
return this.dependencyService.npm('i', '--save-peer', '--save-exact', ...plugins);
}
/**
* Uninstalls plugins using npm.
*
* @param plugins - The plugins to uninstall.
* @returns The execa result of the npm command.
*/
private uninstallPlugins(...plugins: string[]) {
return this.dependencyService.npm('uninstall', ...plugins);
}
/**------------------------------------------------------------------------
* Bundled Plugins
* Plugins that are not published to npm, but vendored as tarballs in the
* `/usr/local/unraid-api/packages` directory.
*
* We don't know their versions ahead of time, so for simplicity, they
* are installed to node_modules at build time and are never un/installed.
*
* We use the `api.plugins` config setting to control whether these plugins
* are loaded/enabled at runtime.
*------------------------------------------------------------------------**/
async addBundledPlugin(...plugins: string[]) {
const added = this.addPluginToConfig(...plugins);
await this.persistConfig();
return added;
}
async removeBundledPlugin(...plugins: string[]) {
const removed = this.removePluginFromConfig(...plugins);
await this.persistConfig();
return removed;
}
private async persistConfig() {
return await persistApiConfig(this.configService.get('api', { infer: true }));
}
}

View File

@@ -1,7 +1,9 @@
import { DynamicModule, Logger, Module } from '@nestjs/common'; import { DynamicModule, Logger, Module } from '@nestjs/common';
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js'; import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js'; import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
@Module({}) @Module({})
@@ -20,8 +22,8 @@ export class PluginModule {
return { return {
module: PluginModule, module: PluginModule,
imports: [GlobalDepsModule, ResolversModule, ...apiModules], imports: [GlobalDepsModule, ResolversModule, ...apiModules],
providers: [PluginService], providers: [PluginService, PluginManagementService, DependencyService],
exports: [PluginService, GlobalDepsModule], exports: [PluginService, PluginManagementService, DependencyService, GlobalDepsModule],
}; };
} }
} }
@@ -42,7 +44,8 @@ export class PluginCliModule {
return { return {
module: PluginCliModule, module: PluginCliModule,
imports: [GlobalDepsModule, ...cliModules], imports: [GlobalDepsModule, ...cliModules],
exports: [GlobalDepsModule], providers: [PluginManagementService, DependencyService],
exports: [PluginManagementService, DependencyService, GlobalDepsModule],
}; };
} }
} }

View File

@@ -2,13 +2,14 @@ import { Injectable, Logger } 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 { getPackageJson } from '@app/environment.js'; import { getPackageJson } from '@app/environment.js';
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
import { import {
NotificationImportance, NotificationImportance,
NotificationType, NotificationType,
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js'; } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
import { apiNestPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js'; import { apiNestPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
import { batchProcess } from '@app/utils.js'; import { batchProcess, parsePackageArg } from '@app/utils.js';
@Injectable() @Injectable()
export class PluginService { export class PluginService {
@@ -51,19 +52,31 @@ export class PluginService {
return plugins.data; return plugins.data;
} }
/**
* Lists all plugins that are installed as peer dependencies of the unraid-api package.
*
* @returns A tuple of the plugin name and version.
*/
static async listPlugins(): Promise<[string, string][]> { static async listPlugins(): Promise<[string, string][]> {
/** All api plugins must be npm packages whose name starts with this prefix */ const { plugins = [] } = await loadApiConfig();
const pluginPrefix = 'unraid-api-plugin-'; const pluginNames = new Set(
// All api plugins must be installed as dependencies of the unraid-api package plugins.map((plugin) => {
const { name } = parsePackageArg(plugin);
return name;
})
);
const { peerDependencies } = getPackageJson(); const { peerDependencies } = getPackageJson();
// All api plugins must be installed as peer dependencies of the unraid-api package
if (!peerDependencies) { if (!peerDependencies) {
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.'); PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
return []; return [];
} }
const plugins = Object.entries(peerDependencies).filter((entry): entry is [string, string] => { const pluginTuples = Object.entries(peerDependencies).filter(
const [pkgName, version] = entry; (entry): entry is [string, string] => {
return pkgName.startsWith(pluginPrefix) && typeof version === 'string'; const [pkgName, version] = entry;
}); return pluginNames.has(pkgName) && typeof version === 'string';
return plugins; }
);
return pluginTuples;
} }
} }

View File

@@ -286,3 +286,25 @@ export const convertWebGuiPathToAssetPath = (webGuiPath: string): string => {
const assetPath = webGuiPath.replace('/usr/local/emhttp/', '/'); const assetPath = webGuiPath.replace('/usr/local/emhttp/', '/');
return assetPath; return assetPath;
}; };
/**
* Parses an npm package argument into a name and version.
*
* @param packageArg - The package argument to parse.
* @returns The name and version of the package.
*/
export function parsePackageArg(packageArg: string): { name: string; version?: string } {
const atIndex = packageArg.lastIndexOf('@');
// Handles scoped packages @scope/pkg or @scope/pkg@version and simple pkg@version
if (atIndex > 0) {
// Ensure '@' is not the first character
const name = packageArg.substring(0, atIndex);
const version = packageArg.substring(atIndex + 1);
// Basic check if version looks like a version (simplistic)
if (version && !version.includes('/')) {
// Avoid treating part of scope as version
return { name, version };
}
}
return { name: packageArg }; // No version or scoped package without version
}

View File

@@ -21,4 +21,9 @@ export class ApiConfig {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
ssoSubIds!: string[]; ssoSubIds!: string[];
@Field(() => [String])
@IsArray()
@IsString({ each: true })
plugins!: string[];
} }

View File

@@ -228,9 +228,6 @@ for txz_file in /boot/config/plugins/dynamix.my.servers/dynamix.unraid.net-*.txz
fi fi
done done
# Clean up any old node_modules archives (on the boot drive) that don't match our current version
/etc/rc.d/rc.unraid-api cleanup-dependencies
# Remove existing node_modules directory # Remove existing node_modules directory
echo "Cleaning up existing node_modules directory..." echo "Cleaning up existing node_modules directory..."
if [ -d "/usr/local/unraid-api/node_modules" ]; then if [ -d "/usr/local/unraid-api/node_modules" ]; then
@@ -245,12 +242,19 @@ if [ $? -ne 0 ]; then
exit 1 exit 1
fi fi
# Clean up any old node_modules archives (on the boot drive) that don't match our current version
#
# Must run after package installation because it provides an update api config file,
# which determines the current API version and vendor archive to keep.
/etc/rc.d/rc.unraid-api cleanup-dependencies
if [[ -n "$TAG" && "$TAG" != "" ]]; then if [[ -n "$TAG" && "$TAG" != "" ]]; then
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG" printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md" sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
fi fi
echo "Starting Unraid API service" echo "Starting Unraid API service"
/etc/rc.d/rc.unraid-api plugins add unraid-api-plugin-connect -b --no-restart
/etc/rc.d/rc.unraid-api start /etc/rc.d/rc.unraid-api start
echo "Unraid API service started" echo "Unraid API service started"