Skip to content
Merged
12 changes: 12 additions & 0 deletions api/src/connect-plugin-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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');
};
2 changes: 1 addition & 1 deletion api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { type NestFastifyApplication } from '@nestjs/platform-fastify';
import { unlinkSync } from 'fs';
import { mkdir } from 'fs/promises';
import { mkdir, readFile } from 'fs/promises';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import readFile.

Copilot Autofix

AI about 10 hours ago

The best way to fix this problem is to remove the unused readFile import from line 7 in api/src/index.ts. This should be done by editing the import statement so that only the actually used mkdir named import remains. No other changes need to be made elsewhere in the code since readFile is never referenced.


Suggested changeset 1
api/src/index.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/api/src/index.ts b/api/src/index.ts
--- a/api/src/index.ts
+++ b/api/src/index.ts
@@ -4,7 +4,7 @@
 
 import { type NestFastifyApplication } from '@nestjs/platform-fastify';
 import { unlinkSync } from 'fs';
-import { mkdir, readFile } from 'fs/promises';
+import { mkdir } from 'fs/promises';
 import http from 'http';
 import https from 'https';
 
EOF
@@ -4,7 +4,7 @@

import { type NestFastifyApplication } from '@nestjs/platform-fastify';
import { unlinkSync } from 'fs';
import { mkdir, readFile } from 'fs/promises';
import { mkdir } from 'fs/promises';
import http from 'http';
import https from 'https';

Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
import http from 'http';
import https from 'https';

Expand Down
8 changes: 8 additions & 0 deletions api/src/unraid-api/config/api-config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -29,6 +30,13 @@ export const loadApiConfig = async () => {
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();

const diskConfig: Partial<ApiConfig> = 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);
}
Comment on lines +33 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for the config write operation.

The writeConfigFile operation can fail (e.g., due to disk I/O errors or permission issues), but there's no error handling in place. Since this runs during application startup outside the NestJS DI container, an unhandled error could crash the application before proper error handling is initialized.

Consider adding error handling:

 // 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);
+    const originalPlugins = diskConfig.plugins ?? [];
+    const cleanedPlugins = originalPlugins.filter(
+        (plugin) => plugin !== 'unraid-api-plugin-connect'
+    );
+    if (cleanedPlugins.length !== originalPlugins.length) {
+        diskConfig.plugins = cleanedPlugins;
+        try {
+            await apiHandler.writeConfigFile(diskConfig as ApiConfig);
+        } catch (error) {
+            console.error('Failed to cleanup stale connect plugin entry:', error);
+            // Continue with startup even if cleanup fails
+        }
+    }
 }
🤖 Prompt for AI Agents
In api/src/unraid-api/config/api-config.module.ts around lines 33 to 39, the
call to await apiHandler.writeConfigFile(diskConfig as ApiConfig) is unprotected
and can throw during startup; wrap this call in a try/catch, catch any error,
log the error details (e.g., console.error with a clear message and the error
object) and avoid rethrowing so startup doesn't crash; if you need to treat this
as fatal, log the error then call process.exit(1) explicitly—otherwise just log
and continue.

⚠️ Potential issue | 🟠 Major

Optimize: Only write config if the plugin was actually removed.

The current implementation writes to disk even if the 'unraid-api-plugin-connect' entry was not present in the plugins array. This causes unnecessary I/O operations on every startup when the plugin is not installed but also not in the config.

Apply this diff to optimize the write operation:

 // 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);
+    const originalPlugins = diskConfig.plugins ?? [];
+    const cleanedPlugins = originalPlugins.filter(
+        (plugin) => plugin !== 'unraid-api-plugin-connect'
+    );
+    if (cleanedPlugins.length !== originalPlugins.length) {
+        diskConfig.plugins = cleanedPlugins;
+        await apiHandler.writeConfigFile(diskConfig as ApiConfig);
+    }
 }
🤖 Prompt for AI Agents
In api/src/unraid-api/config/api-config.module.ts around lines 33 to 39, the
code always writes diskConfig back to disk even when 'unraid-api-plugin-connect'
wasn't present; change the logic to check whether the plugin was actually
removed before calling writeConfigFile: compute the filtered plugins array,
compare its length (or use includes to detect presence) against the original
plugins, and only assign diskConfig.plugins and await
apiHandler.writeConfigFile(diskConfig as ApiConfig) when the filtered array
differs from the original (i.e., the plugin existed and was removed).


return {
...defaultConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
11 changes: 7 additions & 4 deletions api/src/unraid-api/plugin/plugin-management.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ export class PluginManagementService {
}
pluginSet.add(plugin);
});
// @ts-expect-error - This is a valid config key
this.configService.set<string[]>('api.plugins', Array.from(pluginSet));
this.updatePluginsConfig(Array.from(pluginSet));
return added;
}

Expand All @@ -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<ApiConfig>('api');
this.configService.set('api', { ...apiConfig, plugins });
}

/**
* Install bundle / unbundled plugins using npm or direct with the config.
*
Expand Down
65 changes: 1 addition & 64 deletions packages/unraid-api-plugin-connect/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Loading