chore: cli commands for api plugin install & generation (#1352)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a CLI tool to scaffold new Unraid API plugins, including
templates for configuration, persistence, and GraphQL resolvers.
- Added plugin management commands to the CLI for installing, removing,
and listing plugins as peer dependencies.
- Implemented robust configuration state management with validation,
persistence, and error handling.
  - Added scheduled and debounced persistence for configuration changes.
  - Provided utilities for file existence checks and CSV string parsing.
- Enhanced GraphQL schema with new queries and mutations for health
checks and demo configuration.

- **Improvements**
- Updated configuration and environment handling to support modular,
persistent plugin configs.
- Improved logging and error handling throughout CLI and service layers.
- Refined dependency management for plugins, including support for
bundled dependencies.

- **Bug Fixes**
- Improved error handling during Docker service initialization to
prevent unhandled exceptions.

- **Chores**
- Added and updated development dependencies and TypeScript
configurations for better compatibility and type safety.

- **Refactor**
- Restructured internal modules to support dynamic plugin loading and
configuration injection.
  - Removed deprecated plugin schema extension logic and related code.

- **Documentation**
- Updated and added configuration files and templates for easier plugin
development and management.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pujit Mehrotra
2025-04-22 13:30:11 -04:00
committed by GitHub
parent 72860e71fe
commit 026b0b344c
51 changed files with 2575 additions and 190 deletions

View File

@@ -10,6 +10,7 @@ PATHS_MY_SERVERS_FB=./dev/Unraid.net/fb_keepalive # My servers flashbackup timek
PATHS_KEYFILE_BASE=./dev/Unraid.net # Keyfile location
PATHS_MACHINE_ID=./dev/data/machine-id
PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
PATHS_CONFIG_MODULES=./dev/configs
ENVIRONMENT="development"
NODE_ENV="development"
PORT="3001"
@@ -21,4 +22,4 @@ BYPASS_PERMISSION_CHECKS=false
BYPASS_CORS_CHECKS=true
CHOKIDAR_USEPOLLING=true
LOG_TRANSPORT=console
LOG_LEVEL=trace
LOG_LEVEL=trace

View File

@@ -2,3 +2,4 @@ ENVIRONMENT="production"
NODE_ENV="production"
PORT="/var/run/unraid-api.sock"
MOTHERSHIP_GRAPHQL_LINK="https://mothership.unraid.net/ws"
PATHS_CONFIG_MODULES="/boot/config/plugins/dynamix.my.servers/configs"

View File

@@ -2,3 +2,4 @@ ENVIRONMENT="staging"
NODE_ENV="production"
PORT="/var/run/unraid-api.sock"
MOTHERSHIP_GRAPHQL_LINK="https://staging.mothership.unraid.net/ws"
PATHS_CONFIG_MODULES="/boot/config/plugins/dynamix.my.servers/configs"

View File

@@ -10,5 +10,6 @@ PATHS_MY_SERVERS_FB=./dev/Unraid.net/fb_keepalive # My servers flashbackup timek
PATHS_KEYFILE_BASE=./dev/Unraid.net # Keyfile location
PATHS_MACHINE_ID=./dev/data/machine-id
PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
PATHS_CONFIG_MODULES=./dev/configs
PORT=5000
NODE_ENV="test"
NODE_ENV="test"

View File

@@ -1,5 +1,5 @@
[api]
version="4.4.1"
version="4.6.6"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"

View File

@@ -0,0 +1,23 @@
{
"demo": "2025-04-21T14:27:27.631Z",
"wanaccess": "yes",
"wanport": "8443",
"upnpEnabled": "no",
"apikey": "_______________________BIG_API_KEY_HERE_________________________",
"localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________",
"email": "test@example.com",
"username": "zspearmint",
"avatar": "https://via.placeholder.com/200",
"regWizTime": "1611175408732_0951-1653-3509-FBA155FA23C0",
"accesstoken": "",
"idtoken": "",
"refreshtoken": "",
"dynamicRemoteAccessType": "DISABLED",
"ssoSubIds": "",
"version": "4.6.6",
"extraOrigins": [
"https://google.com",
"https://test.com"
],
"sandbox": "yes"
}

View File

@@ -1232,8 +1232,8 @@ type DockerNetwork {
type Docker implements Node {
id: ID!
containers(useCache: Boolean! = true): [DockerContainer!]!
networks: [DockerNetwork!]!
containers(skipCache: Boolean! = false): [DockerContainer!]!
networks(skipCache: Boolean! = false): [DockerNetwork!]!
}
type Flash implements Node {
@@ -1423,6 +1423,7 @@ type Query {
disks: [Disk!]!
disk(id: String!): Disk!
health: String!
getDemo: String!
}
type Mutation {
@@ -1459,6 +1460,7 @@ type Mutation {
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]!
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
setDemo: String!
}
input CreateApiKeyInput {

View File

@@ -14,3 +14,7 @@ default:
alias b := build
alias d := deploy
sync-env server:
rsync -avz --progress --stats -e ssh .env* root@{{server}}:/usr/local/unraid-api
ssh root@{{server}} 'cp /usr/local/unraid-api/.env.staging /usr/local/unraid-api/.env'

View File

@@ -177,6 +177,7 @@
"@types/ini": "^4.1.1",
"@types/ip": "^1.1.3",
"@types/lodash": "^4.17.13",
"@types/lodash-es": "^4.17.12",
"@types/mustache": "^4.2.5",
"@types/node": "^22.13.4",
"@types/pify": "^6.0.0",

View File

@@ -80,7 +80,6 @@ test('it creates a FLASH config with OPTIONAL values', () => {
// 2fa & t2fa should be ignored
basicConfig.remote['2Fa'] = 'yes';
basicConfig.local['2Fa'] = 'yes';
basicConfig.local.showT2Fa = 'yes';
basicConfig.api.extraOrigins = 'myextra.origins';
basicConfig.remote.upnpEnabled = 'yes';
@@ -120,7 +119,6 @@ test('it creates a MEMORY config with OPTIONAL values', () => {
// 2fa & t2fa should be ignored
basicConfig.remote['2Fa'] = 'yes';
basicConfig.local['2Fa'] = 'yes';
basicConfig.local.showT2Fa = 'yes';
basicConfig.api.extraOrigins = 'myextra.origins';
basicConfig.remote.upnpEnabled = 'yes';
basicConfig.connectionStatus.upnpStatus = 'Turned On';

View File

@@ -5,40 +5,49 @@ import { fileURLToPath } from 'node:url';
import type { PackageJson, SetRequired } from 'type-fest';
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
/**
* Tries to get the package.json at the given location.
* @param location - The location of the package.json file, relative to the current file
* @returns The package.json object or undefined if unable to read
* Returns the absolute path to the given file.
* @param location - The location of the file, relative to the current file
* @returns The absolute path to the file
*/
function readPackageJson(location: string): PackageJson | undefined {
function getAbsolutePath(location: string): string {
try {
let packageJsonPath: string;
try {
const packageJsonUrl = import.meta.resolve(location);
packageJsonPath = fileURLToPath(packageJsonUrl);
} catch {
// Fallback (e.g. for local development): resolve the path relative to this module
packageJsonPath = fileURLToPath(new URL(location, import.meta.url));
}
const packageJsonRaw = readFileSync(packageJsonPath, 'utf-8');
return JSON.parse(packageJsonRaw) as PackageJson;
const fileUrl = import.meta.resolve(location);
return fileURLToPath(fileUrl);
} catch {
return undefined;
return fileURLToPath(new URL(location, import.meta.url));
}
}
/**
* Returns the path to the api's package.json file. Throws if unable to find.
* @param possiblePaths - The possible locations of the package.json file, relative to the current file
* @returns The absolute path to the package.json file
*/
export function getPackageJsonPath(possiblePaths = ['../package.json', '../../package.json']): string {
for (const location of possiblePaths) {
const packageJsonPath = getAbsolutePath(location);
if (fileExistsSync(packageJsonPath)) {
return packageJsonPath;
}
}
throw new Error(
`Could not find package.json in any of the expected locations: ${possiblePaths.join(', ')}`
);
}
/**
* Retrieves the Unraid API package.json. Throws if unable to find.
* Retrieves the Unraid API package.json. Throws if unable to find or parse.
* This should be considered a fatal error.
*
* @param pathOverride - The path to the package.json file. If not provided, the default path will be found & used.
* @returns The package.json object
*/
export const getPackageJson = () => {
const packageJson = readPackageJson('../package.json') || readPackageJson('../../package.json');
if (!packageJson) {
throw new Error('Could not find package.json in any of the expected locations');
}
return packageJson as SetRequired<PackageJson, 'version' | 'dependencies'>;
export const getPackageJson = (pathOverride?: string) => {
const packageJsonPath = pathOverride ?? getPackageJsonPath();
const packageJsonRaw = readFileSync(packageJsonPath, 'utf-8');
return JSON.parse(packageJsonRaw) as SetRequired<PackageJson, 'version' | 'dependencies'>;
};
/**
@@ -86,3 +95,4 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
: 'https://mothership.unraid.net/ws';
export const PM2_HOME = process.env.PM2_HOME ?? join(homedir(), '.pm2');
export const PATHS_CONFIG_MODULES = process.env.PATHS_CONFIG_MODULES!;

View File

@@ -4,6 +4,7 @@ import '@app/dotenv.js';
import { type NestFastifyApplication } from '@nestjs/platform-fastify';
import { unlinkSync } from 'fs';
import { mkdir } from 'fs/promises';
import http from 'http';
import https from 'https';
@@ -14,7 +15,7 @@ import { WebSocket } from 'ws';
import { logger } from '@app/core/log.js';
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { environment, PORT } from '@app/environment.js';
import { environment, PATHS_CONFIG_MODULES, PORT } from '@app/environment.js';
import * as envVars from '@app/environment.js';
import { setupNewMothershipSubscription } from '@app/mothership/subscribe-to-mothership.js';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
@@ -44,6 +45,8 @@ export const viteNodeApp = async () => {
logger.info('ENV %o', envVars);
logger.info('PATHS %o', store.getState().paths);
await mkdir(PATHS_CONFIG_MODULES, { recursive: true });
const cacheable = new CacheableLookup();
Object.assign(global, { WebSocket });

View File

@@ -1,6 +1,6 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { AuthZGuard } from 'nest-authz';
@@ -12,7 +12,6 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
import { GraphModule } from '@app/unraid-api/graph/graph.module.js';
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.module.js';
@@ -49,7 +48,6 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
},
]),
UnraidFileModifierModule,
PluginModule.register(),
],
controllers: [],
providers: [

View File

@@ -9,6 +9,7 @@ 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 { 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';
@@ -26,6 +27,9 @@ import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command.js';
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
// cli - plugin add/remove
// plugin generator
const DEFAULT_COMMANDS = [
ApiKeyCommand,
ConfigCommand,
@@ -57,7 +61,7 @@ const DEFAULT_PROVIDERS = [
] as const;
@Module({
imports: [PluginCliModule.register()],
imports: [PluginCliModule.register(), PluginCommandModule],
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
})
export class CliModule {}

View File

@@ -19,39 +19,39 @@ export class LogService {
return shouldLog;
}
log(message: string): void {
log(...messages: unknown[]): void {
if (this.shouldLog('info')) {
this.logger.log(message);
this.logger.log(...messages);
}
}
info(message: string): void {
info(...messages: unknown[]): void {
if (this.shouldLog('info')) {
this.logger.info(message);
this.logger.info(...messages);
}
}
warn(message: string): void {
warn(...messages: unknown[]): void {
if (this.shouldLog('warn')) {
this.logger.warn(message);
this.logger.warn(...messages);
}
}
error(message: string, trace: string = ''): void {
error(...messages: unknown[]): void {
if (this.shouldLog('error')) {
this.logger.error(message, trace);
this.logger.error(...messages);
}
}
debug(message: any, ...optionalParams: any[]): void {
debug(...messages: unknown[]): void {
if (this.shouldLog('debug')) {
this.logger.debug(message, ...optionalParams);
this.logger.debug(...messages);
}
}
trace(message: any, ...optionalParams: any[]): void {
trace(...messages: unknown[]): void {
if (this.shouldLog('trace')) {
this.logger.log(message, ...optionalParams);
this.logger.log(...messages);
}
}
}

View File

@@ -0,0 +1,131 @@
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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,118 @@
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 { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
interface InstallPluginCommandOptions {
bundled: boolean;
}
@SubCommand({
name: 'install',
aliases: ['i', 'add'],
description: 'Install a plugin as a peer dependency.',
arguments: '<package>',
})
export class InstallPluginCommand extends CommandRunner {
constructor(
private readonly dependencyService: DependencyService,
private readonly logService: LogService,
private readonly restartCommand: RestartCommand
) {
super();
}
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
const [packageName] = passedParams;
if (!packageName) {
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();
}
await this.restartCommand.run();
} catch (error) {
this.logService.error(error);
process.exitCode = 1;
}
}
@Option({
flags: '-b, --bundled',
description: 'Install as a bundled plugin (peer dependency version "workspace:*" and optional)',
defaultValue: false,
})
parseBundled(): boolean {
return true;
}
}
@SubCommand({
name: 'remove',
aliases: ['rm'],
description: 'Remove a plugin peer dependency.',
arguments: '<package>',
})
export class RemovePluginCommand extends CommandRunner {
constructor(
private readonly pluginService: DependencyService,
private readonly logService: LogService,
private readonly restartCommand: RestartCommand
) {
super();
}
async run(passedParams: string[]): Promise<void> {
const [packageName] = passedParams;
if (!packageName) {
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;
}
}
}
@SubCommand({
name: 'list',
description: 'List installed plugins (peer dependencies)',
options: { isDefault: true },
})
export class ListPluginCommand extends CommandRunner {
constructor(private readonly logService: LogService) {
super();
}
async run(): Promise<void> {
const plugins = await PluginService.listPlugins();
this.logService.log('Installed plugins:');
plugins.forEach(([name, version]) => {
this.logService.log(`☑️ ${name}@${version}`);
});
}
}
@Command({
name: 'plugins',
description: 'Manage Unraid API plugins (peer dependencies)',
subCommands: [InstallPluginCommand, RemovePluginCommand, ListPluginCommand],
})
export class PluginCommand extends CommandRunner {
constructor() {
super();
}
async run(): Promise<void> {}
}

View File

@@ -13,7 +13,7 @@ export class RestartCommand extends CommandRunner {
super();
}
async run(_): Promise<void> {
async run(): Promise<void> {
try {
this.logger.info('Restarting the Unraid API...');
const { stderr, stdout } = await this.pm2.run(

View File

@@ -0,0 +1,105 @@
import { Logger } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
import { join } from 'path';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { PATHS_CONFIG_MODULES } from '@app/environment.js';
import { makeConfigToken } from '@app/unraid-api/config/config.injection.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
export interface ApiStateConfigOptions<T> {
/**
* The name of the config.
*
* - Must be unique.
* - Should be the key representing this config in the `ConfigFeatures` interface.
* - Used for logging and dependency injection.
*/
name: string;
defaultConfig: T;
parse: (data: unknown) => T;
}
export class ApiStateConfig<T> {
private config: T;
private logger: Logger;
constructor(
readonly options: ApiStateConfigOptions<T>,
readonly persistenceHelper: ConfigPersistenceHelper
) {
// avoid sharing a reference with the given default config. This allows us to re-use it.
this.config = structuredClone(options.defaultConfig);
this.logger = new Logger(this.token);
}
/** Unique token for this config. Used for Dependency Injection & logging. */
get token() {
return makeConfigToken(this.options.name);
}
get fileName() {
return `${this.options.name}.json`;
}
get filePath() {
return join(PATHS_CONFIG_MODULES, this.fileName);
}
/**
* Persists the config to the file system. Will never throw.
* @param config - The config to persist.
* @returns True if the config was written successfully, false otherwise.
*/
async persist(config = this.config) {
try {
await this.persistenceHelper.persistIfChanged(this.filePath, config);
return true;
} catch (error) {
this.logger.error(error, `Could not write config to ${this.filePath}.`);
return false;
}
}
/**
* Reads the config from a path (defaults to the default file path of the config).
* @param opts - The options for the read operation.
* @param opts.filePath - The path to the config file.
* @returns The parsed config or undefined if the file does not exist.
* @throws If the file exists but is invalid.
*/
async parseConfig(opts: { filePath?: string } = {}): Promise<T | undefined> {
const { filePath = this.filePath } = opts;
if (!(await fileExists(filePath))) return undefined;
const rawConfig = JSON.parse(await readFile(filePath, 'utf8'));
return this.options.parse(rawConfig);
}
/**
* Loads config from the file system. If the file does not exist, it will be created with the default config.
* If the config is invalid or corrupt, no action will be taken. The error will be logged.
*
* Will never throw.
*/
async load() {
try {
const config = await this.parseConfig();
if (config) {
this.config = config;
} else {
this.logger.log(`Config file does not exist. Writing default config.`);
this.config = this.options.defaultConfig;
await this.persist();
}
} catch (error) {
this.logger.warn(error, `Config file '${this.filePath}' is invalid. Not modifying config.`);
}
}
update(config: Partial<T>) {
const proposedConfig = this.options.parse({ ...this.config, ...config });
this.config = proposedConfig;
return this;
}
}

View File

@@ -0,0 +1,54 @@
import type { DynamicModule, Provider } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import type { ApiStateConfigOptions } from '@app/unraid-api/config/api-state.model.js';
import type { ApiStateConfigPersistenceOptions } from '@app/unraid-api/config/api-state.service.js';
import { ApiStateConfig } from '@app/unraid-api/config/api-state.model.js';
import { ScheduledConfigPersistence } from '@app/unraid-api/config/api-state.service.js';
import { makeConfigToken } from '@app/unraid-api/config/config.injection.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
type ApiStateRegisterOptions<ConfigType> = ApiStateConfigOptions<ConfigType> & {
persistence?: ApiStateConfigPersistenceOptions;
};
export class ApiStateConfigModule {
static async register<ConfigType>(
options: ApiStateRegisterOptions<ConfigType>
): Promise<DynamicModule> {
const { persistence, ...configOptions } = options;
const configToken = makeConfigToken(options.name);
const persistenceToken = makeConfigToken(options.name, ScheduledConfigPersistence.name);
const ConfigProvider = {
provide: configToken,
useFactory: async (helper: ConfigPersistenceHelper) => {
const config = new ApiStateConfig(configOptions, helper);
await config.load();
return config;
},
inject: [ConfigPersistenceHelper],
};
const providers: Provider[] = [ConfigProvider, ConfigPersistenceHelper];
const exports = [configToken];
if (persistence) {
providers.push({
provide: persistenceToken,
useFactory: (
schedulerRegistry: SchedulerRegistry,
config: ApiStateConfig<ConfigType>
) => {
return new ScheduledConfigPersistence(schedulerRegistry, config, persistence);
},
inject: [SchedulerRegistry, configToken],
});
exports.push(persistenceToken);
}
return {
module: ApiStateConfigModule,
providers,
exports,
};
}
}

View File

@@ -0,0 +1,82 @@
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import type { ApiStateConfig } from '@app/unraid-api/config/api-state.model.js';
import { makeConfigToken } from '@app/unraid-api/config/config.injection.js';
export interface ApiStateConfigPersistenceOptions {
/** How often to persist the config to the file system, in milliseconds. Defaults to 10 seconds. */
intervalMs?: number;
/** How many consecutive failed persistence attempts to tolerate before stopping. Defaults to 5. */
maxConsecutiveFailures?: number;
/** By default, the config will be persisted to the file system when the module is initialized and destroyed.
* Set this to true to disable this behavior.
*/
disableLifecycleHooks?: boolean;
}
export class ScheduledConfigPersistence<T> implements OnModuleInit, OnModuleDestroy {
private consecutiveFailures = 0;
private logger: Logger;
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly config: ApiStateConfig<T>,
private readonly options: ApiStateConfigPersistenceOptions
) {
this.logger = new Logger(this.token);
}
get token() {
return makeConfigToken(this.configName, ScheduledConfigPersistence.name);
}
get configName() {
return this.config.options.name;
}
onModuleInit() {
if (this.options.disableLifecycleHooks) return;
this.setup();
}
async onModuleDestroy() {
if (this.options.disableLifecycleHooks) return;
this.stop();
await this.config.persist();
}
stop() {
if (this.schedulerRegistry.getInterval(this.token)) {
this.schedulerRegistry.deleteInterval(this.token);
}
}
setup() {
const interval = this.schedulerRegistry.getInterval(this.token);
if (interval) {
this.logger.warn(`Persistence interval for '${this.token}' already exists. Aborting setup.`);
return;
}
const ONE_MINUTE = 60_000;
const { intervalMs = ONE_MINUTE, maxConsecutiveFailures = 3 } = this.options;
const callback = async () => {
const success = await this.config.persist();
if (success) {
this.consecutiveFailures = 0;
return;
}
this.consecutiveFailures++;
if (this.consecutiveFailures > maxConsecutiveFailures) {
this.logger.warn(
`Failed to persist '${this.configName}' too many times in a row (${this.consecutiveFailures} attempts). Disabling persistence.`
);
this.schedulerRegistry.deleteInterval(this.token);
}
};
this.schedulerRegistry.addInterval(this.token, setInterval(callback, intervalMs));
}
}

View File

@@ -0,0 +1,22 @@
import { Inject } from '@nestjs/common';
import type { ConfigFeatures } from '@app/unraid-api/config/config.interface.js';
/**
* Creates a string token representation of the arguements. Pure function.
*
* @param configName - The name of the config.
* @returns A colon-separated string
*/
export function makeConfigToken(configName: string, ...details: string[]) {
return ['ApiConfig', configName, ...details].join('.');
}
/**
* Custom decorator to inject a config by name.
* @param feature - The name of the config to inject.
* @returns Dependency injector for the config.
*/
export function InjectConfig<K extends keyof ConfigFeatures>(feature: K) {
return Inject(makeConfigToken(feature));
}

View File

@@ -0,0 +1,15 @@
/**
* Container record of config names to their types. Used for type completion on registered configs.
* Config authors should redeclare/merge this interface with their config names as the keys
* and implementation models as the types.
*/
export interface ConfigFeatures {}
export interface ConfigMetadata<T = unknown> {
/** Unique token for this config. Used for Dependency Injection, logging, etc. */
token: string;
/** The path to the config file. */
filePath?: string;
/** Validates a config of type `T`. */
validate: (config: unknown) => Promise<T>;
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { readFile, writeFile } from 'fs/promises';
import { isEqual } from 'lodash-es';
@Injectable()
export class ConfigPersistenceHelper {
/**
* Persist the config to disk if the given data is different from the data on-disk.
* This helps preserve the boot flash drive's life by avoiding unnecessary writes.
*
* @param filePath - The path to the config file.
* @param data - The data to persist.
* @returns `true` if the config was persisted, `false` otherwise.
*
* @throws {Error} if the config file does not exist or is unreadable.
* @throws {Error} if the config file is not valid JSON.
* @throws {Error} if given data is not JSON (de)serializable.
* @throws {Error} if the config file is not writable.
*/
async persistIfChanged(filePath: string, data: unknown): Promise<boolean> {
const currentData = JSON.parse(await readFile(filePath, 'utf8'));
const stagedData = JSON.parse(JSON.stringify(data));
if (isEqual(currentData, stagedData)) {
return false;
}
await writeFile(filePath, JSON.stringify(stagedData, null, 2));
return true;
}
}

View File

@@ -16,13 +16,14 @@ import {
import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin.js';
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
@Module({
imports: [
ResolversModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [],
imports: [PluginModule.register()],
inject: [],
useFactory: async () => {
return {

View File

@@ -58,12 +58,17 @@ export class DockerService implements OnModuleInit {
}
public async onModuleInit() {
this.logger.debug('Warming Docker cache on startup...');
await this.getContainers({ skipCache: true });
await this.getNetworks({ skipCache: true });
this.logger.debug('Docker cache warming complete.');
const appInfo = await this.getAppInfo();
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
try {
this.logger.debug('Warming Docker cache on startup...');
await this.getContainers({ skipCache: true });
await this.getNetworks({ skipCache: true });
this.logger.debug('Docker cache warming complete.');
const appInfo = await this.getAppInfo();
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
} catch (error) {
this.logger.warn('Error initializing Docker module:', error);
this.logger.warn('Docker may be disabled under Settings -> Docker.');
}
}
/**

View File

@@ -42,7 +42,6 @@ export const apiNestPluginSchema = z
message: 'Invalid NestJS module: expected a class constructor',
})
.optional(),
graphqlSchemaExtension: asyncString().optional(),
})
.superRefine((data, ctx) => {
// Ensure that at least one of ApiModule or CliModule is defined.
@@ -53,14 +52,6 @@ export const apiNestPluginSchema = z
path: ['ApiModule', 'CliModule'],
});
}
// If graphqlSchemaExtension is provided, ensure that ApiModule is defined.
if (data.graphqlSchemaExtension && !data.ApiModule) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If graphqlSchemaExtension is provided, ApiModule must be defined',
path: ['graphqlSchemaExtension'],
});
}
});
export type ApiNestPluginDefinition = z.infer<typeof apiNestPluginSchema>;

View File

@@ -5,7 +5,6 @@ import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
@Module({})
export class PluginModule {
private static readonly logger = new Logger(PluginModule.name);
constructor(private readonly pluginService: PluginService) {}
static async register(): Promise<DynamicModule> {
const plugins = await PluginService.getPlugins();

View File

@@ -1,8 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import type { SetRequired } from 'type-fest';
import { parse } from 'graphql';
import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
import { getPackageJson } from '@app/environment.js';
import { apiNestPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
@@ -18,29 +15,6 @@ export class PluginService {
return PluginService.plugins;
}
static async getGraphQLSchemas() {
const plugins = (await PluginService.getPlugins()).filter(
(plugin): plugin is SetRequired<ApiNestPluginDefinition, 'graphqlSchemaExtension'> =>
plugin.graphqlSchemaExtension !== undefined
);
const { data: schemas } = await batchProcess(plugins, async (plugin) => {
try {
const schema = await plugin.graphqlSchemaExtension();
// Validate schema by parsing it - this will throw if invalid
parse(schema);
return schema;
} catch (error) {
// we can safely assert ApiModule's presence since we validate the plugin schema upon importing it.
// ApiModule must be defined when graphqlSchemaExtension is defined.
PluginService.logger.error(
`Error parsing GraphQL schema from ${plugin.ApiModule!.name}: ${JSON.stringify(error, null, 2)}`
);
throw error;
}
});
return schemas;
}
private static async importPlugins() {
if (PluginService.plugins) {
return PluginService.plugins;
@@ -77,7 +51,7 @@ export class PluginService {
return plugins.data;
}
private 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 pluginPrefix = 'unraid-api-plugin-';
// All api plugins must be installed as dependencies of the unraid-api package

View File

@@ -166,11 +166,7 @@ export default defineConfig(({ mode }): ViteUserConfig => {
include: ['src/**/*'],
reporter: ['text', 'json', 'html'],
},
setupFiles: [
'dotenv/config',
'reflect-metadata',
'src/__test__/setup.ts',
],
setupFiles: ['dotenv/config', 'reflect-metadata', 'src/__test__/setup.ts'],
exclude: ['**/deploy/**', '**/node_modules/**'],
},
};

View File

@@ -1,47 +0,0 @@
import { Module, Logger, Inject } from "@nestjs/common";
import { ConfigModule, ConfigService, registerAs } from "@nestjs/config";
import { Resolver, Query } from "@nestjs/graphql";
export const adapter = 'nestjs';
export const graphqlSchemaExtension = async () => `
type Query {
health: String
}
`;
@Resolver()
export class HealthResolver {
@Query(() => String)
health() {
// You can replace the return value with your actual health check logic
return 'I am healthy!';
}
}
const config = registerAs("connect", () => ({
demo: true,
}));
@Module({
imports: [ConfigModule.forFeature(config)],
providers: [HealthResolver],
})
class ConnectPluginModule {
logger = new Logger(ConnectPluginModule.name);
private readonly configService: ConfigService;
/**
* @param {ConfigService} configService
*/
constructor(@Inject(ConfigService) configService: ConfigService) {
this.configService = configService;
}
onModuleInit() {
this.logger.log("Connect plugin initialized");
console.log("Connect plugin initialized", this.configService.get('connect'));
}
}
export const ApiModule = ConnectPluginModule;

View File

@@ -20,7 +20,16 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.11",
"@nestjs/graphql": "^13.0.3",
"@types/ini": "^4.1.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.0",
"camelcase-keys": "^9.1.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ini": "^5.0.0",
"lodash-es": "^4.17.21",
"nest-authz": "^2.14.0",
"rxjs": "^7.8.2",
"typescript": "^5.8.2"
},
"peerDependencies": {
@@ -28,6 +37,12 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.11",
"@nestjs/graphql": "^13.0.3",
"nest-authz": "^2.14.0"
"camelcase-keys": "^9.1.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ini": "^5.0.0",
"lodash-es": "^4.17.21",
"nest-authz": "^2.14.0",
"rxjs": "^7.8.2"
}
}

View File

@@ -0,0 +1,6 @@
import { Field } from "@nestjs/graphql";
export class ConnectConfig {
@Field(() => String)
demo!: string;
}

View File

@@ -0,0 +1,109 @@
import { registerAs } from "@nestjs/config";
import { Field, ObjectType, InputType } from "@nestjs/graphql";
import {
IsString,
IsEnum,
IsOptional,
IsEmail,
Matches,
IsBoolean,
IsNumber,
IsArray,
} from "class-validator";
import { ConnectConfig } from "./config.demo.js";
import { UsePipes, ValidationPipe } from "@nestjs/common";
export enum MinigraphStatus {
ONLINE = "online",
OFFLINE = "offline",
UNKNOWN = "unknown",
}
export enum DynamicRemoteAccessType {
NONE = "none",
UPNP = "upnp",
MANUAL = "manual",
}
@ObjectType()
@UsePipes(new ValidationPipe({ transform: true }))
@InputType("MyServersConfigInput")
export class MyServersConfig {
// Remote Access Configurationx
@Field(() => String)
@IsString()
wanaccess!: string;
@Field(() => Number)
@IsNumber()
wanport!: number;
@Field(() => Boolean)
@IsBoolean()
upnpEnabled!: boolean;
@Field(() => String)
@IsString()
apikey!: string;
@Field(() => String)
@IsString()
localApiKey!: string;
// User Information
@Field(() => String)
@IsEmail()
email!: string;
@Field(() => String)
@IsString()
username!: string;
@Field(() => String)
@IsString()
avatar!: string;
@Field(() => String)
@IsString()
regWizTime!: string;
// Authentication Tokens
@Field(() => String)
@IsString()
accesstoken!: string;
@Field(() => String)
@IsString()
idtoken!: string;
@Field(() => String)
@IsString()
refreshtoken!: string;
// Remote Access Settings
@Field(() => DynamicRemoteAccessType)
@IsEnum(DynamicRemoteAccessType)
dynamicRemoteAccessType!: DynamicRemoteAccessType;
@Field(() => [String])
@IsArray()
@Matches(/^[a-zA-Z0-9-]+$/, {
each: true,
message: "Each SSO ID must be alphanumeric with dashes",
})
ssoSubIds!: string[];
// Connection Status
// @Field(() => MinigraphStatus)
// @IsEnum(MinigraphStatus)
// minigraph!: MinigraphStatus;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
upnpStatus?: string | null;
}
export const configFeature = registerAs<ConnectConfig>("connect", () => ({
demo: "hello.unraider",
}));

View File

@@ -0,0 +1,185 @@
import {
Logger,
Injectable,
OnModuleInit,
OnModuleDestroy,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { existsSync, readFileSync } from "fs";
import { writeFile } from "fs/promises";
import path from "path";
import { debounceTime } from "rxjs/operators";
import type { MyServersConfig as LegacyConfig } from "./helpers/my-servers-config.js";
import { MyServersConfig } from "./config.entity.js";
import { plainToInstance } from "class-transformer";
import { csvStringToArray } from "./helpers/utils.js";
import { parse as parseIni } from 'ini';
import { isEqual } from "lodash-es";
import { validateOrReject } from "class-validator";
@Injectable()
export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
constructor(private readonly configService: ConfigService) {}
private logger = new Logger(ConnectConfigPersister.name);
get configPath() {
// PATHS_CONFIG_MODULES is a required environment variable.
// It is the directory where custom config files are stored.
return path.join(
this.configService.get("PATHS_CONFIG_MODULES")!,
"connect.json"
);
}
async onModuleDestroy() {
await this.persist();
}
async onModuleInit() {
this.logger.debug(`Config path: ${this.configPath}`);
await this.loadOrMigrateConfig();
// Persist changes to the config.
const HALF_SECOND = 500;
this.configService.changes$.pipe(debounceTime(HALF_SECOND)).subscribe({
next: async ({ newValue, oldValue, path }) => {
if (path.startsWith("connect.")) {
this.logger.debug(
`Config changed: ${path} from ${oldValue} to ${newValue}`
);
await this.persist();
}
},
error: (err) => {
this.logger.error("Error receiving config changes:", err);
},
});
}
/**
* Persist the config to disk if the given data is different from the data on-disk.
* This helps preserve the boot flash drive's life by avoiding unnecessary writes.
*
* @param config - The config object to persist.
* @returns `true` if the config was persisted, `false` otherwise.
*/
async persist(config = this.configService.get<MyServersConfig>("connect")) {
try {
if (isEqual(config, await this.loadConfig())) {
this.logger.verbose(`Config is unchanged, skipping persistence`);
return false;
}
} catch (error) {
this.logger.error(`Error loading config (will overwrite file):`, error);
}
const data = JSON.stringify(config, null, 2);
this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`);
try {
await writeFile(this.configPath, data);
this.logger.verbose(`Config persisted to ${this.configPath}`);
return true;
} catch (error) {
this.logger.error(
`Error persisting config to '${this.configPath}':`,
error
);
return false;
}
}
/**
* Validate the config object.
* @param config - The config object to validate.
* @returns The validated config instance.
*/
private async validate(config: object) {
let instance: MyServersConfig;
if (config instanceof MyServersConfig) {
instance = config;
} else {
instance = plainToInstance(MyServersConfig, config, { enableImplicitConversion: true });
}
await validateOrReject(instance);
return instance;
}
/**
* Load the config from the filesystem, or migrate the legacy config file to the new config format.
* When unable to load or migrate the config, messages are logged at WARN level, but no other action is taken.
* @returns true if the config was loaded successfully, false otherwise.
*/
private async loadOrMigrateConfig() {
try {
const config = await this.loadConfig();
this.configService.set("connect", config);
this.logger.verbose(`Config loaded from ${this.configPath}`);
return true;
} catch (error) {
this.logger.warn("Error loading config:", error);
}
try {
await this.migrateLegacyConfig();
return this.persist();
} catch (error) {
this.logger.warn("Error migrating legacy config:", error);
}
this.logger.error(
"Failed to load or migrate config from filesystem. Config is not persisted. Using defaults in-memory."
);
return false;
}
/**
* Load the JSON config from the filesystem
* @throws {Error} - If the config file does not exist.
* @throws {Error} - If the config file is not parse-able.
* @throws {Error} - If the config file is not valid.
*/
private async loadConfig(configFilePath = this.configPath) {
if (!existsSync(configFilePath)) throw new Error(`Config file does not exist at '${configFilePath}'`);
return this.validate(JSON.parse(readFileSync(configFilePath, "utf8")));
}
/**
* Migrate the legacy config file to the new config format.
* Loads into memory, but does not persist.
*
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async migrateLegacyConfig() {
const legacyConfig = await this.parseLegacyConfig();
this.configService.set("connect", {
demo: new Date().toISOString(),
...legacyConfig,
});
}
/**
* Parse the legacy config file and return a new config object.
* @param filePath - The path to the legacy config file.
* @returns A new config object.
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async parseLegacyConfig(filePath?: string): Promise<MyServersConfig> {
filePath ??= this.configService.get(
"PATHS_MY_SERVERS_CONFIG",
"/boot/config/plugins/dynamix.my.servers/myservers.cfg"
);
if (!filePath) {
throw new Error("No legacy config file path provided");
}
if (!existsSync(filePath)) {
throw new Error(`Legacy config file does not exist: ${filePath}`);
}
const config = parseIni(readFileSync(filePath, "utf8")) as LegacyConfig;
return this.validate({
...config.api,
...config.local,
...config.remote,
extraOrigins: csvStringToArray(config.api.extraOrigins),
});
}
}

View File

@@ -0,0 +1,25 @@
import { ConfigService } from "@nestjs/config";
import { Resolver, Query, Mutation } from "@nestjs/graphql";
@Resolver()
export class HealthResolver {
constructor(private readonly configService: ConfigService) {}
@Query(() => String)
health() {
// You can replace the return value with your actual health check logic
return "I am healthy!";
}
@Query(() => String)
getDemo() {
return this.configService.get("connect.demo");
}
@Mutation(() => String)
async setDemo() {
const newValue = new Date().toISOString();
this.configService.set("connect.demo", newValue);
return newValue;
}
}

View File

@@ -0,0 +1,61 @@
// Schema for the legacy myservers.cfg configuration file.
enum MinigraphStatus {
PRE_INIT = "PRE_INIT",
CONNECTING = "CONNECTING",
CONNECTED = "CONNECTED",
PING_FAILURE = "PING_FAILURE",
ERROR_RETRYING = "ERROR_RETRYING",
}
enum DynamicRemoteAccessType {
STATIC = "STATIC",
UPNP = "UPNP",
DISABLED = "DISABLED",
}
// TODO Currently registered in the main api, but this will eventually be the source of truth.
//
// registerEnumType(MinigraphStatus, {
// name: "MinigraphStatus",
// description: "The status of the minigraph",
// });
//
// registerEnumType(DynamicRemoteAccessType, {
// name: "DynamicRemoteAccessType",
// description: "The type of dynamic remote access",
// });
export type MyServersConfig = {
api: {
version: string;
extraOrigins: string;
};
local: {
sandbox: "yes" | "no";
};
remote: {
wanaccess: string;
wanport: string;
upnpEnabled: string;
apikey: string;
localApiKey: string;
email: string;
username: string;
avatar: string;
regWizTime: string;
accesstoken: string;
idtoken: string;
refreshtoken: string;
dynamicRemoteAccessType: DynamicRemoteAccessType;
ssoSubIds: string;
};
};
/** In-Memory representation of the legacy myservers.cfg configuration file */
export type MyServersConfigMemory = MyServersConfig & {
connectionStatus: {
minigraph: MinigraphStatus;
upnpStatus?: string | null;
};
};

View File

@@ -0,0 +1,43 @@
import { accessSync } from 'fs';
import { access } from 'fs/promises';
import { F_OK } from 'node:constants';
export const fileExists = async (path: string) =>
access(path, F_OK)
.then(() => true)
.catch(() => false);
export const fileExistsSync = (path: string) => {
try {
accessSync(path, F_OK);
return true;
} catch (error: unknown) {
return false;
}
};
/**
* Converts a Comma Separated (CSV) string to an array of strings.
*
* @example
* csvStringToArray('one,two,three') // ['one', 'two', 'three']
* csvStringToArray('one, two, three') // ['one', 'two', 'three']
* csvStringToArray(null) // []
* csvStringToArray(undefined) // []
* csvStringToArray('') // []
*
* @param csvString - The Comma Separated string to convert
* @param opts - Options
* @param opts.noEmpty - Whether to omit empty strings. Default is true.
* @returns An array of strings
*/
export function csvStringToArray(
csvString?: string | null,
opts: { noEmpty?: boolean } = { noEmpty: true }
): string[] {
if (!csvString) return [];
const result = csvString.split(',').map((item) => item.trim());
if (opts.noEmpty) {
return result.filter((item) => item !== '');
}
return result;
}

View File

@@ -0,0 +1,28 @@
import { Module, Logger, Inject } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ConnectConfigPersister } from "./config.persistence.js";
import { configFeature } from "./config.entity.js";
import { HealthResolver } from "./connect.resolver.js";
export const adapter = "nestjs";
@Module({
imports: [ConfigModule.forFeature(configFeature)],
providers: [HealthResolver, ConnectConfigPersister],
})
class ConnectPluginModule {
logger = new Logger(ConnectPluginModule.name);
constructor(
@Inject(ConfigService) private readonly configService: ConfigService
) {}
onModuleInit() {
this.logger.log(
"Connect plugin initialized with %o",
this.configService.get("connect")
);
}
}
export const ApiModule = ConnectPluginModule;

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "nodenext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": true,
@@ -13,6 +13,6 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["index.ts"],
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,34 @@
{
"name": "@unraid/create-api-plugin",
"version": "1.0.0",
"type": "module",
"bin": {
"create-api-plugin": "./dist/index.js"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"prepare": "npm run build"
},
"dependencies": {
"chalk": "^5.4.1",
"change-case": "^5.4.4",
"commander": "^13.1.0",
"create-create-app": "^7.3.0",
"fs-extra": "^11.3.0",
"inquirer": "^12.5.2",
"validate-npm-package-name": "^6.0.0"
},
"devDependencies": {
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.11",
"@nestjs/graphql": "^13.0.3",
"@types/fs-extra": "^11.0.4",
"@types/inquirer": "^9.0.7",
"@types/node": "^22.14.1",
"@types/validate-npm-package-name": "^4.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,128 @@
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import { pascalCase, kebabCase } from 'change-case';
import validateNpmPackageName from 'validate-npm-package-name';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const isValidName = (name: string) => {
const { validForNewPackages } = validateNpmPackageName(name);
return validForNewPackages;
};
export async function createPlugin(pluginName: string, targetDir: string = process.cwd()) {
if (!isValidName(pluginName)) {
throw new Error("Invalid plugin name");
}
const pascalName = pascalCase(pluginName);
const kebabName = kebabCase(pluginName);
const packageName = `unraid-api-plugin-${kebabName}`;
const pluginDir = path.join(targetDir, packageName);
// Check if directory already exists
if (await fs.pathExists(pluginDir)) {
throw new Error(`Directory ${pluginDir} already exists`);
}
// Create directory structure
await fs.ensureDir(path.join(pluginDir, 'src'));
// Create package.json
const packageJson = {
name: packageName,
version: "1.0.0",
"unraidVersion": {
"min": "^6.12.15",
"max": "~7.1.0"
},
main: "dist/index.js",
type: "module",
files: ["dist"],
scripts: {
test: "echo \"Error: no test specified\" && exit 1",
build: "tsc",
prepare: "npm run build"
},
keywords: [],
license: "GPL-2.0-or-later",
description: `Plugin for Unraid API: ${pascalName}`,
devDependencies: {
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.11",
"@nestjs/graphql": "^13.0.3",
"@types/ini": "^4.1.1",
"@types/node": "^22.14.0",
"camelcase-keys": "^9.1.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ini": "^5.0.0",
"nest-authz": "^2.14.0",
"rxjs": "^7.8.2",
"typescript": "^5.8.2",
"zod": "^3.23.8"
},
peerDependencies: {
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.11",
"@nestjs/graphql": "^13.0.3",
"camelcase-keys": "^9.1.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ini": "^5.0.0",
"nest-authz": "^2.14.0",
"rxjs": "^7.8.2",
"zod": "^3.23.8"
}
};
await fs.writeJson(path.join(pluginDir, 'package.json'), packageJson, { spaces: 2 });
// Create tsconfig.json
const tsconfig = {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
sourceMap: true,
forceConsistentCasingInFileNames: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
esModuleInterop: true,
strict: true,
outDir: "dist",
rootDir: "src"
},
include: ["src/**/*"],
exclude: ["node_modules", "dist"]
};
await fs.writeJson(path.join(pluginDir, 'tsconfig.json'), tsconfig, { spaces: 2 });
// Read template files and replace variables
const templatesDir = path.join(__dirname, '../src/templates');
const replaceNames = (template: string) => {
return template
.replace(/PluginName/g, pascalName)
.replace(/plugin-name/g, kebabName);
};
// Process all template files
const templateFiles = await fs.readdir(templatesDir);
for (const templateFile of templateFiles) {
// Read template content
const templateContent = await fs.readFile(path.join(templatesDir, templateFile), 'utf8');
const processedContent = replaceNames(templateContent);
const outputFileName = replaceNames(templateFile);
// Write to target directory
await fs.writeFile(path.join(pluginDir, 'src', outputFileName), processedContent);
}
return pluginDir;
}

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env node
import { Command } from "commander";
import { createPlugin, isValidName } from "./create-plugin.js";
import chalk from "chalk";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const program = new Command();
async function getPluginName(name: string | undefined) {
if (name && isValidName(name)) return name;
const { pluginName } = await import("inquirer").then(
({ default: inquirer }) =>
inquirer.prompt([
{
type: "input",
name: "pluginName",
message: "What would you like to name your plugin?",
validate: (input: string) => {
if (!input) return "Plugin name is required";
if (!isValidName(input))
return "Plugin name should only contain lowercase letters, numbers, and hyphens, and may not start with a hyphen";
return true;
},
},
])
);
return pluginName;
}
program
.name("create-api-plugin")
.description("Create a new Unraid API plugin")
.argument("[name]", "Name of the plugin (e.g., my-plugin)")
.option(
"-d, --dir <directory>",
"Directory to create the plugin in",
process.cwd()
)
.option(
"-p, --package-manager <manager>",
"Package manager to use (npm, yarn, pnpm)",
"npm"
)
.option(
"-i, --install",
"Install dependencies after creating the plugin",
false
)
.action(
async (
name: string | undefined,
options: { dir: string; packageManager: string; install: boolean }
) => {
try {
const pluginName = await getPluginName(name);
const pluginDir = await createPlugin(pluginName, options.dir);
console.log(chalk.green(`Successfully created plugin: ${pluginName}`));
if (options.install) {
console.log(
chalk.blue(
`\nInstalling dependencies using ${options.packageManager}...`
)
);
try {
await execAsync(`${options.packageManager} install`, {
cwd: pluginDir,
});
console.log(chalk.green("Dependencies installed successfully!"));
} catch (error) {
console.error(chalk.red("Error installing dependencies:"), error);
process.exit(1);
}
}
const nextSteps = [`cd ${pluginDir}`];
if (!options.install) {
nextSteps.push(`${options.packageManager} install`);
}
nextSteps.push(`Start developing your plugin!`);
console.log(chalk.blue("\nNext steps:"));
nextSteps.forEach((step, index) => {
console.log(chalk.blue(`${index + 1}. ${step}`));
});
} catch (error) {
console.error(chalk.red("Error creating plugin:"), error);
process.exit(1);
}
}
);
program.parse();

View File

@@ -0,0 +1,20 @@
import { registerAs } from "@nestjs/config";
import { Field, ObjectType } from "@nestjs/graphql";
import { Exclude, Expose } from "class-transformer";
import { IsBoolean } from "class-validator";
@Exclude() // Exclude properties by default
@ObjectType()
export class PluginNameConfig {
@Expose() // Expose this property for transformation
@Field(() => Boolean, { description: "Whether the plugin is enabled" })
@IsBoolean()
enabled!: boolean;
}
// This function provides the default config and registers it under the 'plugin-name' key.
export const configFeature = registerAs<PluginNameConfig>("plugin-name", () => {
return {
enabled: true,
};
});

View File

@@ -0,0 +1,71 @@
import { Logger, Injectable, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { existsSync, readFileSync } from "fs";
import { writeFile } from "fs/promises";
import path from "path";
import { debounceTime } from "rxjs/operators";
import { PluginNameConfig } from "./config.entity.js";
@Injectable()
export class PluginNameConfigPersister implements OnModuleInit {
constructor(private readonly configService: ConfigService) {}
private logger = new Logger(PluginNameConfigPersister.name);
/** the file path to the config file for this plugin */
get configPath() {
return path.join(
this.configService.get("PATHS_CONFIG_MODULES")!,
"plugin-name.json" // Use kebab-case for the filename
);
}
onModuleInit() {
this.logger.debug(`Config path: ${this.configPath}`);
// Load the config from the file if it exists, otherwise initialize it with defaults.
if (existsSync(this.configPath)) {
try {
const configFromFile = JSON.parse(
readFileSync(this.configPath, "utf8")
);
this.configService.set("plugin-name", configFromFile);
this.logger.verbose(`Config loaded from ${this.configPath}`);
} catch (error) {
this.logger.error(`Error reading or parsing config file at ${this.configPath}. Using defaults.`, error);
// If loading fails, ensure default config is set and persisted
this.persist();
}
} else {
this.logger.log(`Config file ${this.configPath} does not exist. Writing default config...`);
// Persist the default configuration provided by configFeature
this.persist();
}
// Automatically persist changes to the config file after a short delay.
const HALF_SECOND = 500;
this.configService.changes$.pipe(debounceTime(HALF_SECOND)).subscribe({
next: ({ newValue, oldValue, path: changedPath }) => {
// Only persist if the change is within this plugin's config namespace
if (changedPath.startsWith("plugin-name.") && newValue !== oldValue) {
this.logger.debug(`Config changed: ${changedPath} from ${oldValue} to ${newValue}`);
// Persist the entire config object for this plugin
this.persist();
}
},
error: (err) => {
this.logger.error("Error subscribing to config changes:", err);
},
});
}
async persist(config = this.configService.get<PluginNameConfig>("plugin-name")) {
const data = JSON.stringify(config, null, 2);
this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`);
try {
await writeFile(this.configPath, data);
this.logger.verbose(`Config change persisted to ${this.configPath}`);
} catch (error) {
this.logger.error(`Error persisting config to '${this.configPath}':`, error);
}
}
}

View File

@@ -0,0 +1,28 @@
import { Module, Logger, Inject } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { PluginNameConfigPersister } from "./config.persistence.js";
import { configFeature } from "./config.entity.js";
import { PluginNameResolver } from "./plugin-name.resolver.js";
export const adapter = "nestjs";
@Module({
imports: [ConfigModule.forFeature(configFeature)],
providers: [PluginNameResolver, PluginNameConfigPersister],
})
class PluginNamePluginModule {
logger = new Logger(PluginNamePluginModule.name);
constructor(
@Inject(ConfigService) private readonly configService: ConfigService
) {}
onModuleInit() {
this.logger.log(
"PluginName plugin initialized with %o",
this.configService.get("plugin-name")
);
}
}
export const ApiModule = PluginNamePluginModule;

View File

@@ -0,0 +1,23 @@
import { Resolver, Query, Mutation } from "@nestjs/graphql";
import { ConfigService } from "@nestjs/config";
@Resolver()
export class PluginNameResolver {
constructor(private readonly configService: ConfigService) {}
@Query(() => String)
async PluginNameStatus() {
// Example query: Fetch a value from the config
return this.configService.get("plugin-name.enabled", true) ? "Enabled" : "Disabled";
}
@Mutation(() => Boolean)
async togglePluginNameStatus() {
// Example mutation: Update a value in the config
const currentStatus = this.configService.get("plugin-name.enabled", true);
const newStatus = !currentStatus;
this.configService.set("plugin-name.enabled", newStatus);
// The config persister will automatically save the changes.
return newStatus;
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/templates/**/*"]
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

1021
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff