diff --git a/api/src/connect-plugin-cleanup.ts b/api/src/connect-plugin-cleanup.ts new file mode 100644 index 0000000000..dbb29357bc --- /dev/null +++ b/api/src/connect-plugin-cleanup.ts @@ -0,0 +1,12 @@ +import { existsSync } from 'node:fs'; + +/** + * Local filesystem and env checks stay synchronous so we can branch at module load. + * @returns True if the Connect Unraid plugin is installed, false otherwise. + */ +export const isConnectPluginInstalled = () => { + if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') { + return true; + } + return existsSync('/boot/config/plugins/dynamix.unraid.net.plg'); +}; diff --git a/api/src/index.ts b/api/src/index.ts index deb3f5b4ce..e1d2c41d48 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -4,7 +4,7 @@ import '@app/dotenv.js'; import { type NestFastifyApplication } from '@nestjs/platform-fastify'; import { unlinkSync } from 'fs'; -import { mkdir } from 'fs/promises'; +import { mkdir, readFile } from 'fs/promises'; import http from 'http'; import https from 'https'; diff --git a/api/src/unraid-api/config/api-config.module.ts b/api/src/unraid-api/config/api-config.module.ts index a724d7123b..a3daf5f88a 100644 --- a/api/src/unraid-api/config/api-config.module.ts +++ b/api/src/unraid-api/config/api-config.module.ts @@ -6,6 +6,7 @@ import type { ApiConfig } from '@unraid/shared/services/api-config.js'; import { ConfigFilePersister } from '@unraid/shared/services/config-file.js'; import { csvStringToArray } from '@unraid/shared/util/data.js'; +import { isConnectPluginInstalled } from '@app/connect-plugin-cleanup.js'; import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js'; export { type ApiConfig }; @@ -29,6 +30,13 @@ export const loadApiConfig = async () => { const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler(); const diskConfig: Partial = await apiHandler.loadConfig(); + // Hack: cleanup stale connect plugin entry if necessary + if (!isConnectPluginInstalled()) { + diskConfig.plugins = diskConfig.plugins?.filter( + (plugin) => plugin !== 'unraid-api-plugin-connect' + ); + await apiHandler.writeConfigFile(diskConfig as ApiConfig); + } return { ...defaultConfig, diff --git a/api/src/unraid-api/plugin/__test__/plugin-management.service.spec.ts b/api/src/unraid-api/plugin/__test__/plugin-management.service.spec.ts index 1b6c51028c..093577eab8 100644 --- a/api/src/unraid-api/plugin/__test__/plugin-management.service.spec.ts +++ b/api/src/unraid-api/plugin/__test__/plugin-management.service.spec.ts @@ -21,9 +21,19 @@ describe('PluginManagementService', () => { if (key === 'api.plugins') { return configStore ?? defaultValue ?? []; } + if (key === 'api') { + return { plugins: configStore ?? defaultValue ?? [] }; + } return defaultValue; }), set: vi.fn((key: string, value: unknown) => { + if (key === 'api' && typeof value === 'object' && value !== null) { + // @ts-expect-error - value is an object + if (Array.isArray(value.plugins)) { + // @ts-expect-error - value is an object + configStore = [...value.plugins]; + } + } if (key === 'api.plugins' && Array.isArray(value)) { configStore = [...value]; } diff --git a/api/src/unraid-api/plugin/plugin-management.service.ts b/api/src/unraid-api/plugin/plugin-management.service.ts index de7fcf49cb..9e27182b63 100644 --- a/api/src/unraid-api/plugin/plugin-management.service.ts +++ b/api/src/unraid-api/plugin/plugin-management.service.ts @@ -56,8 +56,7 @@ export class PluginManagementService { } pluginSet.add(plugin); }); - // @ts-expect-error - This is a valid config key - this.configService.set('api.plugins', Array.from(pluginSet)); + this.updatePluginsConfig(Array.from(pluginSet)); return added; } @@ -71,11 +70,15 @@ export class PluginManagementService { 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); + this.updatePluginsConfig(pluginsArray); return removed; } + private updatePluginsConfig(plugins: string[]) { + const apiConfig = this.configService.get('api'); + this.configService.set('api', { ...apiConfig, plugins }); + } + /** * Install bundle / unbundled plugins using npm or direct with the config. * diff --git a/packages/unraid-api-plugin-connect/src/index.ts b/packages/unraid-api-plugin-connect/src/index.ts index d799da9765..0ef023984a 100644 --- a/packages/unraid-api-plugin-connect/src/index.ts +++ b/packages/unraid-api-plugin-connect/src/index.ts @@ -1,8 +1,5 @@ import { Inject, Logger, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { existsSync } from 'node:fs'; - -import { execa } from 'execa'; import { ConnectConfigPersister } from './config/config.persistence.js'; import { configFeature } from './config/connect.config.js'; @@ -30,64 +27,4 @@ class ConnectPluginModule { } } -/** - * Fallback module keeps the export shape intact but only warns operators. - * This makes `ApiModule` safe to import even when the plugin is absent. - */ -@Module({}) -export class DisabledConnectPluginModule { - logger = new Logger(DisabledConnectPluginModule.name); - async onModuleInit() { - const removalCommand = 'unraid-api plugins remove -b unraid-api-plugin-connect'; - - this.logger.warn( - 'Connect plugin is not installed, but is listed as an API plugin. Attempting `%s` automatically.', - removalCommand - ); - - try { - const { stdout, stderr } = await execa('unraid-api', [ - 'plugins', - 'remove', - '-b', - 'unraid-api-plugin-connect', - ]); - - if (stdout?.trim()) { - this.logger.debug(stdout.trim()); - } - - if (stderr?.trim()) { - this.logger.debug(stderr.trim()); - } - - this.logger.log( - 'Successfully completed `%s` to prune the stale connect plugin entry.', - removalCommand - ); - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Unknown error while removing stale connect plugin entry.'; - this.logger.error('Failed to run `%s`: %s', removalCommand, message); - } - } -} - -/** - * Local filesystem and env checks stay synchronous so we can branch at module load. - */ -const isConnectPluginInstalled = () => { - if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') { - return true; - } - return existsSync('/boot/config/plugins/dynamix.unraid.net.plg'); -}; - -/** - * Downstream code always imports `ApiModule`. We swap the implementation based on availability, - * avoiding dynamic module plumbing while keeping the DI graph predictable. - * Set `SKIP_CONNECT_PLUGIN_CHECK=true` in development to force the connected path. - */ -export const ApiModule = isConnectPluginInstalled() ? ConnectPluginModule : DisabledConnectPluginModule; +export const ApiModule = ConnectPluginModule;