mirror of
https://github.com/unraid/api.git
synced 2026-01-06 00:30:22 -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:
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user