mirror of
https://github.com/unraid/api.git
synced 2026-02-05 23:48:59 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[api]
|
||||
version="4.4.1"
|
||||
version="4.6.6"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
[local]
|
||||
sandbox="yes"
|
||||
|
||||
23
api/dev/configs/connect.json
Normal file
23
api/dev/configs/connect.json
Normal 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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
api/src/unraid-api/cli/plugins/dependency.service.ts
Normal file
131
api/src/unraid-api/cli/plugins/dependency.service.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
28
api/src/unraid-api/cli/plugins/plugin.cli.module.ts
Normal file
28
api/src/unraid-api/cli/plugins/plugin.cli.module.ts
Normal 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 {}
|
||||
118
api/src/unraid-api/cli/plugins/plugin.command.ts
Normal file
118
api/src/unraid-api/cli/plugins/plugin.command.ts
Normal 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> {}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
105
api/src/unraid-api/config/api-state.model.ts
Normal file
105
api/src/unraid-api/config/api-state.model.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
api/src/unraid-api/config/api-state.register.ts
Normal file
54
api/src/unraid-api/config/api-state.register.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
82
api/src/unraid-api/config/api-state.service.ts
Normal file
82
api/src/unraid-api/config/api-state.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
22
api/src/unraid-api/config/config.injection.ts
Normal file
22
api/src/unraid-api/config/config.injection.ts
Normal 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));
|
||||
}
|
||||
15
api/src/unraid-api/config/config.interface.ts
Normal file
15
api/src/unraid-api/config/config.interface.ts
Normal 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>;
|
||||
}
|
||||
30
api/src/unraid-api/config/persistence.helper.ts
Normal file
30
api/src/unraid-api/config/persistence.helper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/unraid-api-plugin-connect/src/config.demo.ts
Normal file
6
packages/unraid-api-plugin-connect/src/config.demo.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Field } from "@nestjs/graphql";
|
||||
|
||||
export class ConnectConfig {
|
||||
@Field(() => String)
|
||||
demo!: string;
|
||||
}
|
||||
109
packages/unraid-api-plugin-connect/src/config.entity.ts
Normal file
109
packages/unraid-api-plugin-connect/src/config.entity.ts
Normal 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",
|
||||
}));
|
||||
185
packages/unraid-api-plugin-connect/src/config.persistence.ts
Normal file
185
packages/unraid-api-plugin-connect/src/config.persistence.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
25
packages/unraid-api-plugin-connect/src/connect.resolver.ts
Normal file
25
packages/unraid-api-plugin-connect/src/connect.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
43
packages/unraid-api-plugin-connect/src/helpers/utils.ts
Normal file
43
packages/unraid-api-plugin-connect/src/helpers/utils.ts
Normal 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;
|
||||
}
|
||||
28
packages/unraid-api-plugin-connect/src/index.ts
Normal file
28
packages/unraid-api-plugin-connect/src/index.ts
Normal 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;
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
34
packages/unraid-api-plugin-generator/package.json
Normal file
34
packages/unraid-api-plugin-generator/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
128
packages/unraid-api-plugin-generator/src/create-plugin.ts
Normal file
128
packages/unraid-api-plugin-generator/src/create-plugin.ts
Normal 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;
|
||||
}
|
||||
95
packages/unraid-api-plugin-generator/src/index.ts
Normal file
95
packages/unraid-api-plugin-generator/src/index.ts
Normal 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();
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/unraid-api-plugin-generator/src/templates/index.ts
Normal file
28
packages/unraid-api-plugin-generator/src/templates/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
13
packages/unraid-api-plugin-generator/tsconfig.build.json
Normal file
13
packages/unraid-api-plugin-generator/tsconfig.build.json
Normal 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/**/*"]
|
||||
}
|
||||
15
packages/unraid-api-plugin-generator/tsconfig.json
Normal file
15
packages/unraid-api-plugin-generator/tsconfig.json
Normal 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
1021
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user