mirror of
https://github.com/unraid/api.git
synced 2026-01-04 07:29:48 -06:00
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:
@@ -5,5 +5,6 @@
|
||||
"https://test.com"
|
||||
],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": []
|
||||
"ssoSubIds": [],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { csvStringToArray, formatDatetime } from '@app/utils.js';
|
||||
import { csvStringToArray, formatDatetime, parsePackageArg } from '@app/utils.js';
|
||||
|
||||
describe('formatDatetime', () => {
|
||||
const testDate = new Date('2024-02-14T12:34:56');
|
||||
@@ -103,3 +103,78 @@ describe('csvStringToArray', () => {
|
||||
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@' });
|
||||
});
|
||||
});
|
||||
|
||||
48
api/src/unraid-api/app/dependency.service.ts
Normal file
48
api/src/unraid-api/app/dependency.service.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { SsoUserService } from '@app/unraid-api/auth/sso-user.service.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 { LogService } from '@app/unraid-api/cli/log.service.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 { ReportCommand } from '@app/unraid-api/cli/report.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 { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
// cli - plugin add/remove
|
||||
// plugin generator
|
||||
|
||||
const DEFAULT_COMMANDS = [
|
||||
ApiKeyCommand,
|
||||
ConfigCommand,
|
||||
DeveloperCommand,
|
||||
LogsCommand,
|
||||
ReportCommand,
|
||||
VersionCommand,
|
||||
// Lifecycle commands
|
||||
SwitchEnvCommand,
|
||||
RestartCommand,
|
||||
StartCommand,
|
||||
StatusCommand,
|
||||
StopCommand,
|
||||
SwitchEnvCommand,
|
||||
VersionCommand,
|
||||
// SSO commands
|
||||
SSOCommand,
|
||||
ValidateTokenCommand,
|
||||
AddSSOUserCommand,
|
||||
RemoveSSOUserCommand,
|
||||
ListSSOUserCommand,
|
||||
// Plugin commands
|
||||
PluginCommand,
|
||||
ListPluginCommand,
|
||||
InstallPluginCommand,
|
||||
RemovePluginCommand,
|
||||
] as const;
|
||||
|
||||
const DEFAULT_PROVIDERS = [
|
||||
@@ -62,10 +72,11 @@ const DEFAULT_PROVIDERS = [
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
SsoUserService,
|
||||
DependencyService,
|
||||
] as const;
|
||||
|
||||
@Module({
|
||||
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register(), PluginCommandModule],
|
||||
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register()],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||
})
|
||||
export class CliModule {}
|
||||
|
||||
@@ -19,6 +19,12 @@ export class LogService {
|
||||
return shouldLog;
|
||||
}
|
||||
|
||||
table(level: LogLevel, data: unknown, columns?: string[]) {
|
||||
if (this.shouldLog(level)) {
|
||||
console.table(data, columns);
|
||||
}
|
||||
}
|
||||
|
||||
log(...messages: unknown[]): void {
|
||||
if (this.shouldLog('info')) {
|
||||
this.logger.log(...messages);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option, SubCommand } from 'nest-commander';
|
||||
|
||||
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 { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
import { parsePackageArg } from '@app/utils.js';
|
||||
|
||||
interface InstallPluginCommandOptions {
|
||||
bundled: boolean;
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
@SubCommand({
|
||||
@@ -17,30 +21,28 @@ interface InstallPluginCommandOptions {
|
||||
})
|
||||
export class InstallPluginCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly dependencyService: DependencyService,
|
||||
private readonly logService: LogService,
|
||||
private readonly restartCommand: RestartCommand
|
||||
private readonly restartCommand: RestartCommand,
|
||||
private readonly pluginManagementService: PluginManagementService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
|
||||
const [packageName] = passedParams;
|
||||
if (!packageName) {
|
||||
if (passedParams.length === 0) {
|
||||
this.logService.error('Package name is required.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.dependencyService.addPeerDependency(packageName, options.bundled);
|
||||
this.logService.log(`Added ${packageName} as a peer dependency.`);
|
||||
if (!options.bundled) {
|
||||
await this.dependencyService.rebuildVendorArchive();
|
||||
}
|
||||
if (options.bundled) {
|
||||
await this.pluginManagementService.addBundledPlugin(...passedParams);
|
||||
this.logService.log(`Added bundled plugin ${passedParams.join(', ')}`);
|
||||
} else {
|
||||
await this.pluginManagementService.addPlugin(...passedParams);
|
||||
this.logService.log(`Added plugin ${passedParams.join(', ')}`);
|
||||
}
|
||||
if (options.restart) {
|
||||
await this.restartCommand.run();
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +54,15 @@ export class InstallPluginCommand extends CommandRunner {
|
||||
parseBundled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--no-restart',
|
||||
description: 'do NOT restart the service after deploy',
|
||||
defaultValue: true,
|
||||
})
|
||||
parseRestart(value: boolean): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@SubCommand({
|
||||
@@ -62,27 +73,47 @@ export class InstallPluginCommand extends CommandRunner {
|
||||
})
|
||||
export class RemovePluginCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly pluginService: DependencyService,
|
||||
private readonly logService: LogService,
|
||||
private readonly pluginManagementService: PluginManagementService,
|
||||
private readonly restartCommand: RestartCommand
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[]): Promise<void> {
|
||||
const [packageName] = passedParams;
|
||||
if (!packageName) {
|
||||
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
|
||||
if (passedParams.length === 0) {
|
||||
this.logService.error('Package name is required.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.pluginService.removePeerDependency(packageName);
|
||||
await this.restartCommand.run();
|
||||
} catch (error) {
|
||||
this.logService.error(`Failed to remove plugin '${packageName}':`, error);
|
||||
process.exitCode = 1;
|
||||
if (options.bundled) {
|
||||
await this.pluginManagementService.removeBundledPlugin(...passedParams);
|
||||
this.logService.log(`Removed bundled plugin ${passedParams.join(', ')}`);
|
||||
} else {
|
||||
await this.pluginManagementService.removePlugin(...passedParams);
|
||||
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 },
|
||||
})
|
||||
export class ListPluginCommand extends CommandRunner {
|
||||
constructor(private readonly logService: LogService) {
|
||||
constructor(
|
||||
private readonly logService: LogService,
|
||||
private readonly pluginManagementService: PluginManagementService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const plugins = await PluginService.listPlugins();
|
||||
this.logService.log('Installed plugins:');
|
||||
plugins.forEach(([name, version]) => {
|
||||
const configPlugins = this.pluginManagementService.plugins;
|
||||
const installedPlugins = await PluginService.listPlugins();
|
||||
|
||||
// this can happen if configPlugins is a super set of installedPlugins
|
||||
if (installedPlugins.length !== configPlugins.length) {
|
||||
const configSet = new Set(configPlugins.map((plugin) => parsePackageArg(plugin).name));
|
||||
const installedSet = new Set(installedPlugins.map(([name]) => name));
|
||||
const notInstalled = Array.from(configSet.difference(installedSet));
|
||||
this.logService.warn(`${notInstalled.length} plugins are not installed:`);
|
||||
this.logService.table('warn', notInstalled);
|
||||
}
|
||||
|
||||
if (installedPlugins.length === 0) {
|
||||
this.logService.log('No plugins installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.log('Installed plugins:\n');
|
||||
installedPlugins.forEach(([name, version]) => {
|
||||
this.logService.log(`☑️ ${name}@${version}`);
|
||||
});
|
||||
this.logService.log(); // for spacing
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Command({
|
||||
name: 'plugins',
|
||||
description: 'Manage Unraid API plugins (peer dependencies)',
|
||||
subCommands: [InstallPluginCommand, RemovePluginCommand, ListPluginCommand],
|
||||
})
|
||||
export class PluginCommand extends CommandRunner {
|
||||
constructor() {
|
||||
constructor(private readonly logger: LogService) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,22 @@ const createDefaultConfig = (): ApiConfig => ({
|
||||
extraOrigins: [],
|
||||
sandbox: false,
|
||||
ssoSubIds: [],
|
||||
plugins: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads the API config from disk. If not found, returns the default config, but does not persist it.
|
||||
*/
|
||||
export const apiConfig = registerAs<ApiConfig>('api', async () => {
|
||||
export const persistApiConfig = async (config: ApiConfig) => {
|
||||
const apiConfig = new ApiStateConfig<ApiConfig>(
|
||||
{
|
||||
name: 'api',
|
||||
defaultConfig: config,
|
||||
parse: (data) => data as ApiConfig,
|
||||
},
|
||||
new ConfigPersistenceHelper()
|
||||
);
|
||||
return await apiConfig.persist(config);
|
||||
};
|
||||
|
||||
export const loadApiConfig = async () => {
|
||||
const defaultConfig = createDefaultConfig();
|
||||
const apiConfig = new ApiStateConfig<ApiConfig>(
|
||||
{
|
||||
@@ -38,7 +48,12 @@ export const apiConfig = registerAs<ApiConfig>('api', async () => {
|
||||
...diskConfig,
|
||||
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()
|
||||
class ApiConfigPersistence {
|
||||
|
||||
@@ -32,6 +32,7 @@ export class ApiSettings {
|
||||
sandbox: this.configService.get('api.sandbox', { infer: true }),
|
||||
extraOrigins: this.configService.get('api.extraOrigins', { infer: true }),
|
||||
ssoSubIds: this.configService.get('api.ssoSubIds', { infer: true }),
|
||||
plugins: this.configService.get('api.plugins', { infer: true }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
116
api/src/unraid-api/plugin/plugin-management.service.ts
Normal file
116
api/src/unraid-api/plugin/plugin-management.service.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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 { 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';
|
||||
|
||||
@Module({})
|
||||
@@ -20,8 +22,8 @@ export class PluginModule {
|
||||
return {
|
||||
module: PluginModule,
|
||||
imports: [GlobalDepsModule, ResolversModule, ...apiModules],
|
||||
providers: [PluginService],
|
||||
exports: [PluginService, GlobalDepsModule],
|
||||
providers: [PluginService, PluginManagementService, DependencyService],
|
||||
exports: [PluginService, PluginManagementService, DependencyService, GlobalDepsModule],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -42,7 +44,8 @@ export class PluginCliModule {
|
||||
return {
|
||||
module: PluginCliModule,
|
||||
imports: [GlobalDepsModule, ...cliModules],
|
||||
exports: [GlobalDepsModule],
|
||||
providers: [PluginManagementService, DependencyService],
|
||||
exports: [PluginManagementService, DependencyService, GlobalDepsModule],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { getPackageJson } from '@app/environment.js';
|
||||
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
import {
|
||||
NotificationImportance,
|
||||
NotificationType,
|
||||
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
|
||||
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.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()
|
||||
export class PluginService {
|
||||
@@ -51,19 +52,31 @@ export class PluginService {
|
||||
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][]> {
|
||||
/** All api plugins must be npm packages whose name starts with this prefix */
|
||||
const pluginPrefix = 'unraid-api-plugin-';
|
||||
// All api plugins must be installed as dependencies of the unraid-api package
|
||||
const { plugins = [] } = await loadApiConfig();
|
||||
const pluginNames = new Set(
|
||||
plugins.map((plugin) => {
|
||||
const { name } = parsePackageArg(plugin);
|
||||
return name;
|
||||
})
|
||||
);
|
||||
const { peerDependencies } = getPackageJson();
|
||||
// All api plugins must be installed as peer dependencies of the unraid-api package
|
||||
if (!peerDependencies) {
|
||||
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
|
||||
return [];
|
||||
}
|
||||
const plugins = Object.entries(peerDependencies).filter((entry): entry is [string, string] => {
|
||||
const [pkgName, version] = entry;
|
||||
return pkgName.startsWith(pluginPrefix) && typeof version === 'string';
|
||||
});
|
||||
return plugins;
|
||||
const pluginTuples = Object.entries(peerDependencies).filter(
|
||||
(entry): entry is [string, string] => {
|
||||
const [pkgName, version] = entry;
|
||||
return pluginNames.has(pkgName) && typeof version === 'string';
|
||||
}
|
||||
);
|
||||
return pluginTuples;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,3 +286,25 @@ export const convertWebGuiPathToAssetPath = (webGuiPath: string): string => {
|
||||
const assetPath = webGuiPath.replace('/usr/local/emhttp/', '/');
|
||||
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
|
||||
}
|
||||
|
||||
@@ -21,4 +21,9 @@ export class ApiConfig {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
ssoSubIds!: string[];
|
||||
|
||||
@Field(() => [String])
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
plugins!: string[];
|
||||
}
|
||||
|
||||
@@ -228,9 +228,6 @@ for txz_file in /boot/config/plugins/dynamix.my.servers/dynamix.unraid.net-*.txz
|
||||
fi
|
||||
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
|
||||
echo "Cleaning up existing node_modules directory..."
|
||||
if [ -d "/usr/local/unraid-api/node_modules" ]; then
|
||||
@@ -245,12 +242,19 @@ if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
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
|
||||
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
|
||||
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
echo "Unraid API service started"
|
||||
|
||||
Reference in New Issue
Block a user