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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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