diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 4463b903a..11b6dcf64 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -5,5 +5,6 @@ "https://test.com" ], "sandbox": true, - "ssoSubIds": [] + "ssoSubIds": [], + "plugins": [] } \ No newline at end of file diff --git a/api/src/__test__/utils.test.ts b/api/src/__test__/utils.test.ts index e09a8c879..f3a28fb8d 100644 --- a/api/src/__test__/utils.test.ts +++ b/api/src/__test__/utils.test.ts @@ -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@' }); + }); +}); diff --git a/api/src/unraid-api/app/dependency.service.ts b/api/src/unraid-api/app/dependency.service.ts new file mode 100644 index 000000000..af70ef800 --- /dev/null +++ b/api/src/unraid-api/app/dependency.service.ts @@ -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 { + 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 { + 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' }); + } +} diff --git a/api/src/unraid-api/cli/cli.module.ts b/api/src/unraid-api/cli/cli.module.ts index 2409e4a8f..28b9f0d39 100644 --- a/api/src/unraid-api/cli/cli.module.ts +++ b/api/src/unraid-api/cli/cli.module.ts @@ -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 {} diff --git a/api/src/unraid-api/cli/log.service.ts b/api/src/unraid-api/cli/log.service.ts index 60123a8cd..dd5c94dca 100644 --- a/api/src/unraid-api/cli/log.service.ts +++ b/api/src/unraid-api/cli/log.service.ts @@ -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); diff --git a/api/src/unraid-api/cli/plugins/dependency.service.ts b/api/src/unraid-api/cli/plugins/dependency.service.ts deleted file mode 100644 index b94b60407..000000000 --- a/api/src/unraid-api/cli/plugins/dependency.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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']); - } -} diff --git a/api/src/unraid-api/cli/plugins/plugin.cli.module.ts b/api/src/unraid-api/cli/plugins/plugin.cli.module.ts deleted file mode 100644 index d570603a0..000000000 --- a/api/src/unraid-api/cli/plugins/plugin.cli.module.ts +++ /dev/null @@ -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 {} diff --git a/api/src/unraid-api/cli/plugins/plugin.command.ts b/api/src/unraid-api/cli/plugins/plugin.command.ts index 2d86f95b6..8cb408316 100644 --- a/api/src/unraid-api/cli/plugins/plugin.command.ts +++ b/api/src/unraid-api/cli/plugins/plugin.command.ts @@ -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 { - 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 { - const [packageName] = passedParams; - if (!packageName) { + async run(passedParams: string[], options: InstallPluginCommandOptions): Promise { + 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 { - 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 {} + + async run(): Promise { + this.logger.info('Please provide a subcommand or use --help for more information'); + process.exit(0); + } } diff --git a/api/src/unraid-api/config/api-config.module.ts b/api/src/unraid-api/config/api-config.module.ts index 7f638041a..39dc547f7 100644 --- a/api/src/unraid-api/config/api-config.module.ts +++ b/api/src/unraid-api/config/api-config.module.ts @@ -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('api', async () => { +export const persistApiConfig = async (config: ApiConfig) => { + const apiConfig = new ApiStateConfig( + { + 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( { @@ -38,7 +48,12 @@ export const apiConfig = registerAs('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('api', loadApiConfig); @Injectable() class ApiConfigPersistence { diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.service.ts b/api/src/unraid-api/graph/resolvers/settings/settings.service.ts index 2a20bc01e..457803194 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.service.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.service.ts @@ -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 }), }; } diff --git a/api/src/unraid-api/plugin/plugin-management.service.ts b/api/src/unraid-api/plugin/plugin-management.service.ts new file mode 100644 index 000000000..78e4f81ac --- /dev/null +++ b/api/src/unraid-api/plugin/plugin-management.service.ts @@ -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('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 })); + } +} diff --git a/api/src/unraid-api/plugin/plugin.module.ts b/api/src/unraid-api/plugin/plugin.module.ts index 1a2db35a0..f965b4580 100644 --- a/api/src/unraid-api/plugin/plugin.module.ts +++ b/api/src/unraid-api/plugin/plugin.module.ts @@ -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], }; } } diff --git a/api/src/unraid-api/plugin/plugin.service.ts b/api/src/unraid-api/plugin/plugin.service.ts index b689825ad..39cc7071d 100644 --- a/api/src/unraid-api/plugin/plugin.service.ts +++ b/api/src/unraid-api/plugin/plugin.service.ts @@ -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; } } diff --git a/api/src/utils.ts b/api/src/utils.ts index 170a07f8a..dcb1ec428 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -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 +} diff --git a/packages/unraid-shared/src/services/api-config.ts b/packages/unraid-shared/src/services/api-config.ts index 2f3259861..d9179fcc3 100644 --- a/packages/unraid-shared/src/services/api-config.ts +++ b/packages/unraid-shared/src/services/api-config.ts @@ -21,4 +21,9 @@ export class ApiConfig { @IsArray() @IsString({ each: true }) ssoSubIds!: string[]; + + @Field(() => [String]) + @IsArray() + @IsString({ each: true }) + plugins!: string[]; } diff --git a/plugin/plugins/dynamix.unraid.net.plg b/plugin/plugins/dynamix.unraid.net.plg index d14091e2a..942099194 100755 --- a/plugin/plugins/dynamix.unraid.net.plg +++ b/plugin/plugins/dynamix.unraid.net.plg @@ -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"