mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
feat: api plugin system & offline versioned dependency vendoring (#1252)
- **New Features** - Created a dynamic plugin system for the API to enable community augmentation of GraphQL, CLI, and Cron functionalities capabilities. - Included an example plugin under `packages/unraid-api-plugin-health` that adds a new graphql query for API health checks. - Added `rc.unraid-api` commands for backing up, restoring, and installing production dependencies, streamlining maintenance and deployment. - Improved dependency vendoring by bundling a versioned pnpm store (instead of `node_modules`). Versioning will allow users to add plugins to a specific api release without requiring an internet connection on subsequent reboots. - **Chores** - Upgraded build workflows and versioning processes to ensure more reliable artifact handling and production packaging.
This commit is contained in:
22
.github/workflows/main.yml
vendored
22
.github/workflows/main.yml
vendored
@@ -152,6 +152,11 @@ jobs:
|
||||
with:
|
||||
name: unraid-api
|
||||
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
|
||||
- name: Upload PNPM Store to Github artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packed-pnpm-store
|
||||
path: ${{ github.workspace }}/api/deploy/packed-pnpm-store.txz
|
||||
|
||||
build-unraid-ui-webcomponents:
|
||||
name: Build Unraid UI Library (Webcomponent Version)
|
||||
@@ -316,6 +321,15 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get API Version
|
||||
id: vars
|
||||
run: |
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
IS_TAGGED=$(git describe --tags --abbrev=0 --exact-match || echo '')
|
||||
PACKAGE_LOCK_VERSION=$(jq -r '.version' package.json)
|
||||
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
|
||||
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
@@ -347,6 +361,11 @@ jobs:
|
||||
with:
|
||||
name: unraid-api
|
||||
path: ${{ github.workspace }}/plugin/api/
|
||||
- name: Download PNPM Store
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: packed-pnpm-store
|
||||
path: ${{ github.workspace }}/plugin/
|
||||
- name: Extract Unraid API
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/unraid-api
|
||||
@@ -355,6 +374,7 @@ jobs:
|
||||
id: build-plugin
|
||||
run: |
|
||||
cd ${{ github.workspace }}/plugin
|
||||
ls -al
|
||||
pnpm run build:txz
|
||||
|
||||
if [ -n "${{ github.event.pull_request.number }}" ]; then
|
||||
@@ -375,7 +395,6 @@ jobs:
|
||||
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
pnpm run build:plugin --tag="${TAG}" --base-url="${BASE_URL}"
|
||||
|
||||
- name: Ensure Plugin Files Exist
|
||||
run: |
|
||||
if [ ! -f ./deploy/*.plg ]; then
|
||||
@@ -387,6 +406,7 @@ jobs:
|
||||
echo "Error: .txz file not found in plugin/deploy/"
|
||||
exit 1
|
||||
fi
|
||||
ls -al ./deploy
|
||||
- name: Upload to GHA
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"cacheable-lookup": "^7.0.0",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"casbin": "^5.32.0",
|
||||
"change-case": "^5.4.4",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-table": "^0.3.11",
|
||||
"command-exists": "^1.2.9",
|
||||
@@ -186,6 +187,7 @@
|
||||
"rollup-plugin-node-externals": "^8.0.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"tsx": "^4.19.2",
|
||||
"type-fest": "^4.37.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.13.0",
|
||||
"unplugin-swc": "^1.5.1",
|
||||
|
||||
@@ -25,6 +25,8 @@ try {
|
||||
|
||||
// Update the package.json version to the deployment version
|
||||
parsedPackageJson.version = deploymentVersion;
|
||||
// omit dev dependencies from release build
|
||||
parsedPackageJson.devDependencies = {};
|
||||
|
||||
// Create a temporary directory for packaging
|
||||
await mkdir('./deploy/pack/', { recursive: true });
|
||||
@@ -36,9 +38,18 @@ try {
|
||||
// Change to the pack directory and install dependencies
|
||||
cd('./deploy/pack');
|
||||
|
||||
console.log('Installing production dependencies...');
|
||||
console.log('Building production pnpm store...');
|
||||
$.verbose = true;
|
||||
await $`pnpm install --prod --ignore-workspace --node-linker hoisted`;
|
||||
await $`pnpm install --prod --ignore-workspace --store-dir=../.pnpm-store`;
|
||||
|
||||
await $`rm -rf node_modules`; // Don't include node_modules in final package
|
||||
|
||||
const sudoCheck = await $`command -v sudo`.nothrow();
|
||||
const SUDO = sudoCheck.exitCode === 0 ? 'sudo' : '';
|
||||
await $`${SUDO} chown -R 0:0 ../.pnpm-store`;
|
||||
|
||||
await $`XZ_OPT=-5 tar -cJf ../packed-pnpm-store.txz ../.pnpm-store`;
|
||||
await $`${SUDO} rm -rf ../.pnpm-store`;
|
||||
|
||||
// chmod the cli
|
||||
await $`chmod +x ./dist/cli.js`;
|
||||
|
||||
@@ -20,7 +20,16 @@ const getUnraidApiLocation = async () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await CommandFactory.run(CliModule, {
|
||||
// Register plugins and create a dynamic module configuration
|
||||
const dynamicModule = await CliModule.registerWithPlugins();
|
||||
|
||||
// Create a new class that extends CliModule with the dynamic configuration
|
||||
const DynamicCliModule = class extends CliModule {
|
||||
static module = dynamicModule.module;
|
||||
static imports = dynamicModule.imports;
|
||||
static providers = dynamicModule.providers;
|
||||
};
|
||||
await CommandFactory.run(DynamicCliModule, {
|
||||
cliName: 'unraid-api',
|
||||
logger: LOG_LEVEL === 'TRACE' ? new LogService() : false, // - enable this to see nest initialization issues
|
||||
completion: {
|
||||
|
||||
@@ -3,36 +3,57 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const getPackageJsonVersion = () => {
|
||||
import type { PackageJson, SetRequired } from 'type-fest';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function readPackageJson(location: string): PackageJson | undefined {
|
||||
try {
|
||||
// Try different possible locations for package.json
|
||||
const possibleLocations = ['../package.json', '../../package.json'];
|
||||
|
||||
for (const location of possibleLocations) {
|
||||
try {
|
||||
const packageJsonUrl = import.meta.resolve(location);
|
||||
const packageJsonPath = fileURLToPath(packageJsonUrl);
|
||||
const packageJson = readFileSync(packageJsonPath, 'utf-8');
|
||||
const packageJsonObject = JSON.parse(packageJson);
|
||||
if (packageJsonObject.version) {
|
||||
return packageJsonObject.version;
|
||||
}
|
||||
} catch {
|
||||
// Continue to next location if this one fails
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
// If we get here, we couldn't find a valid package.json in any location
|
||||
console.error('Could not find package.json in any of the expected locations');
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('Failed to load package.json:', error);
|
||||
const packageJsonRaw = readFileSync(packageJsonPath, 'utf-8');
|
||||
return JSON.parse(packageJsonRaw) as PackageJson;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Unraid API package.json. Throws if unable to find.
|
||||
* This should be considered a fatal error.
|
||||
*
|
||||
* @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 API_VERSION =
|
||||
process.env.npm_package_version ?? getPackageJsonVersion() ?? new Error('API_VERSION not set');
|
||||
/**
|
||||
* Returns list of runtime dependencies from the Unraid-API package.json. Returns undefined if
|
||||
* the package.json or its dependency object cannot be found or read.
|
||||
*
|
||||
* Does not log or produce side effects.
|
||||
* @returns The names of all runtime dependencies. Undefined if failed.
|
||||
*/
|
||||
export const getPackageJsonDependencies = (): string[] | undefined => {
|
||||
const { dependencies } = getPackageJson();
|
||||
return Object.keys(dependencies);
|
||||
};
|
||||
|
||||
export const API_VERSION = process.env.npm_package_version ?? getPackageJson().version;
|
||||
|
||||
export const NODE_ENV =
|
||||
(process.env.NODE_ENV as 'development' | 'test' | 'staging' | 'production') ?? 'production';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mergeTypeDefs } from '@graphql-tools/merge';
|
||||
|
||||
import { logger } from '@app/core/log.js';
|
||||
|
||||
export const loadTypeDefs = async () => {
|
||||
export const loadTypeDefs = async (additionalTypeDefs: string[] = []) => {
|
||||
// TypeScript now knows this returns Record<string, () => Promise<string>>
|
||||
const typeModules = import.meta.glob('./types/**/*.graphql', { query: '?raw', import: 'default' });
|
||||
|
||||
@@ -19,6 +19,7 @@ export const loadTypeDefs = async () => {
|
||||
if (!files.length) {
|
||||
throw new Error('No GraphQL type definitions found');
|
||||
}
|
||||
files.push(...additionalTypeDefs);
|
||||
return mergeTypeDefs(files);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load GraphQL type definitions:', error);
|
||||
|
||||
@@ -35,6 +35,7 @@ export const store = configureStore({
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type ApiStore = typeof store;
|
||||
|
||||
export const getters = {
|
||||
cache: () => store.getState().cache,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard.js';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.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';
|
||||
|
||||
@@ -46,6 +47,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
},
|
||||
]),
|
||||
UnraidFileModifierModule,
|
||||
PluginModule.registerPlugins(),
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner } from 'nest-commander';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||
@@ -23,32 +25,76 @@ import { StatusCommand } from '@app/unraid-api/cli/status.command.js';
|
||||
import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
|
||||
import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command.js';
|
||||
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
|
||||
import { ApiPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
|
||||
const DEFAULT_COMMANDS = [
|
||||
ApiKeyCommand,
|
||||
ConfigCommand,
|
||||
DeveloperCommand,
|
||||
LogsCommand,
|
||||
ReportCommand,
|
||||
RestartCommand,
|
||||
StartCommand,
|
||||
StatusCommand,
|
||||
StopCommand,
|
||||
SwitchEnvCommand,
|
||||
VersionCommand,
|
||||
SSOCommand,
|
||||
ValidateTokenCommand,
|
||||
AddSSOUserCommand,
|
||||
RemoveSSOUserCommand,
|
||||
ListSSOUserCommand,
|
||||
] as const;
|
||||
|
||||
const DEFAULT_PROVIDERS = [
|
||||
AddApiKeyQuestionSet,
|
||||
AddSSOUserQuestionSet,
|
||||
RemoveSSOUserQuestionSet,
|
||||
DeveloperQuestions,
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
] as const;
|
||||
|
||||
type PluginProvider = Provider & {
|
||||
provide: string | symbol | Type<any>;
|
||||
useValue?: ApiPluginDefinition;
|
||||
};
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
AddSSOUserCommand,
|
||||
AddSSOUserQuestionSet,
|
||||
RemoveSSOUserCommand,
|
||||
RemoveSSOUserQuestionSet,
|
||||
ListSSOUserCommand,
|
||||
LogService,
|
||||
PM2Service,
|
||||
StartCommand,
|
||||
StopCommand,
|
||||
RestartCommand,
|
||||
ReportCommand,
|
||||
ApiKeyService,
|
||||
ApiKeyCommand,
|
||||
AddApiKeyQuestionSet,
|
||||
SwitchEnvCommand,
|
||||
VersionCommand,
|
||||
StatusCommand,
|
||||
SSOCommand,
|
||||
ValidateTokenCommand,
|
||||
LogsCommand,
|
||||
ConfigCommand,
|
||||
DeveloperCommand,
|
||||
DeveloperQuestions,
|
||||
],
|
||||
imports: [PluginModule],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||
})
|
||||
export class CliModule {}
|
||||
export class CliModule {
|
||||
/**
|
||||
* Get all registered commands
|
||||
* @returns Array of registered command classes
|
||||
*/
|
||||
static getCommands(): Type<CommandRunner>[] {
|
||||
return [...DEFAULT_COMMANDS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the module with plugin support
|
||||
* @returns DynamicModule configuration including plugin commands
|
||||
*/
|
||||
static async registerWithPlugins(): Promise<DynamicModule> {
|
||||
const pluginModule = await PluginModule.registerPlugins();
|
||||
|
||||
// Get commands from plugins
|
||||
const pluginCommands: Type<CommandRunner>[] = [];
|
||||
for (const provider of (pluginModule.providers || []) as PluginProvider[]) {
|
||||
if (provider.provide !== PluginService && provider.useValue?.commands) {
|
||||
pluginCommands.push(...provider.useValue.commands);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
module: CliModule,
|
||||
imports: [pluginModule],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS, ...pluginCommands],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,38 +24,46 @@ import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.modul
|
||||
import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js';
|
||||
import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js';
|
||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ResolversModule,
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
useFactory: async () => ({
|
||||
introspection: getters.config()?.local?.sandbox === 'yes',
|
||||
playground: false,
|
||||
context: ({ req, connectionParams, extra }: any) => ({
|
||||
req,
|
||||
connectionParams,
|
||||
extra,
|
||||
}),
|
||||
plugins: [sandboxPlugin, idPrefixPlugin] as any[],
|
||||
subscriptions: {
|
||||
'graphql-ws': {
|
||||
path: '/graphql',
|
||||
imports: [PluginModule],
|
||||
inject: [PluginService],
|
||||
useFactory: async (pluginService: PluginService) => {
|
||||
const plugins = await pluginService.getGraphQLConfiguration();
|
||||
return {
|
||||
introspection: getters.config()?.local?.sandbox === 'yes',
|
||||
playground: false,
|
||||
context: ({ req, connectionParams, extra }: any) => ({
|
||||
req,
|
||||
connectionParams,
|
||||
extra,
|
||||
}),
|
||||
plugins: [sandboxPlugin, idPrefixPlugin] as any[],
|
||||
subscriptions: {
|
||||
'graphql-ws': {
|
||||
path: '/graphql',
|
||||
},
|
||||
},
|
||||
},
|
||||
path: '/graphql',
|
||||
typeDefs: print(await loadTypeDefs()),
|
||||
resolvers: {
|
||||
JSON: JSONResolver,
|
||||
Long: GraphQLLong,
|
||||
UUID: UUIDResolver,
|
||||
DateTime: DateTimeResolver,
|
||||
Port: PortResolver,
|
||||
URL: URLResolver,
|
||||
},
|
||||
validationRules: [NoUnusedVariablesRule],
|
||||
}),
|
||||
path: '/graphql',
|
||||
typeDefs: [print(await loadTypeDefs([plugins.typeDefs]))],
|
||||
resolvers: {
|
||||
JSON: JSONResolver,
|
||||
Long: GraphQLLong,
|
||||
UUID: UUIDResolver,
|
||||
DateTime: DateTimeResolver,
|
||||
Port: PortResolver,
|
||||
URL: URLResolver,
|
||||
...plugins.resolvers,
|
||||
},
|
||||
validationRules: [NoUnusedVariablesRule],
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
|
||||
61
api/src/unraid-api/plugin/plugin.interface.ts
Normal file
61
api/src/unraid-api/plugin/plugin.interface.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Logger, Type } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner } from 'nest-commander';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApiStore } from '@app/store/index.js';
|
||||
|
||||
export interface PluginMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const asyncArray = () => z.function().returns(z.promise(z.array(z.any())));
|
||||
const asyncString = () => z.function().returns(z.promise(z.string()));
|
||||
const asyncVoid = () => z.function().returns(z.promise(z.void()));
|
||||
|
||||
// GraphQL resolver type definitions
|
||||
const resolverFunction = z
|
||||
.function()
|
||||
.args(
|
||||
z.any().optional(), // parent
|
||||
z.any().optional(), // args
|
||||
z.any().optional(), // context
|
||||
z.any().optional() // info
|
||||
)
|
||||
.returns(z.any());
|
||||
|
||||
const resolverFieldMap = z.record(z.string(), resolverFunction);
|
||||
const resolverTypeMap = z.record(
|
||||
z.enum(['Query', 'Mutation', 'Subscription']).or(z.string()),
|
||||
resolverFieldMap
|
||||
);
|
||||
const asyncResolver = () => z.function().returns(z.promise(resolverTypeMap));
|
||||
|
||||
/** Warning: unstable API. The config mechanism and API may soon change. */
|
||||
export const apiPluginSchema = z.object({
|
||||
_type: z.literal('UnraidApiPlugin'),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
commands: z.array(z.custom<Type<CommandRunner>>()),
|
||||
registerGraphQLResolvers: asyncResolver().optional(),
|
||||
registerGraphQLTypeDefs: asyncString().optional(),
|
||||
registerRESTControllers: asyncArray().optional(),
|
||||
registerRESTRoutes: asyncArray().optional(),
|
||||
registerServices: asyncArray().optional(),
|
||||
registerCronJobs: asyncArray().optional(),
|
||||
// These schema definitions are picked up as nest modules as well.
|
||||
onModuleInit: asyncVoid().optional(),
|
||||
onModuleDestroy: asyncVoid().optional(),
|
||||
});
|
||||
|
||||
/** Warning: unstable API. The config mechanism and API may soon change. */
|
||||
export type ApiPluginDefinition = z.infer<typeof apiPluginSchema>;
|
||||
|
||||
// todo: the blocker to publishing this type is the 'ApiStore' type.
|
||||
// It pulls in a lot of irrelevant types (e.g. graphql types) and triggers js transpilation of everything related to the store.
|
||||
// If we can isolate the type, we can publish it to npm and developers can use it as a dev dependency.
|
||||
/**
|
||||
* Represents a subclass of UnraidAPIPlugin that can be instantiated.
|
||||
*/
|
||||
export type ConstructablePlugin = (options: { store: ApiStore; logger: Logger }) => ApiPluginDefinition;
|
||||
20
api/src/unraid-api/plugin/plugin.module.ts
Normal file
20
api/src/unraid-api/plugin/plugin.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { DynamicModule, Logger, Module } from '@nestjs/common';
|
||||
|
||||
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 registerPlugins(): Promise<DynamicModule> {
|
||||
const plugins = await PluginService.getPlugins();
|
||||
const providers = plugins.map((result) => result.provider);
|
||||
return {
|
||||
module: PluginModule,
|
||||
providers: [PluginService, ...providers],
|
||||
exports: [PluginService, ...providers.map((p) => p.provide)],
|
||||
global: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
225
api/src/unraid-api/plugin/plugin.service.ts
Normal file
225
api/src/unraid-api/plugin/plugin.service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Injectable, Logger, Provider, Type } from '@nestjs/common';
|
||||
|
||||
import { pascalCase } from 'change-case';
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import type {
|
||||
ApiPluginDefinition,
|
||||
ConstructablePlugin,
|
||||
} from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { getPackageJsonDependencies as getPackageDependencies } from '@app/environment.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { apiPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { batchProcess } from '@app/utils.js';
|
||||
|
||||
type CustomProvider = Provider & {
|
||||
provide: string | symbol | Type<any>;
|
||||
};
|
||||
|
||||
type PluginProvider = {
|
||||
provider: CustomProvider;
|
||||
pluginInstance: ApiPluginDefinition;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PluginService {
|
||||
private pluginProviders: PluginProvider[] | undefined;
|
||||
private loadingPromise: Promise<PluginProvider[]> | undefined;
|
||||
private static readonly logger = new Logger(PluginService.name);
|
||||
constructor() {
|
||||
this.loadPlugins();
|
||||
}
|
||||
|
||||
private get plugins() {
|
||||
return this.pluginProviders?.map((plugin) => plugin.pluginInstance) ?? [];
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
// If plugins are already loaded, return them
|
||||
if (this.pluginProviders?.length) {
|
||||
return this.pluginProviders;
|
||||
}
|
||||
|
||||
// If getPlugins() is already loading, return its promise
|
||||
if (this.loadingPromise) {
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
this.loadingPromise = PluginService.getPlugins()
|
||||
.then((plugins) => {
|
||||
if (!this.pluginProviders?.length) {
|
||||
this.pluginProviders = plugins;
|
||||
const pluginNames = this.plugins.map((plugin) => plugin.name);
|
||||
PluginService.logger.debug(
|
||||
`Registered ${pluginNames.length} plugins: ${pluginNames.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
PluginService.logger.debug(
|
||||
`${plugins.length} plugins already registered. Skipping registration.`
|
||||
);
|
||||
}
|
||||
return this.pluginProviders;
|
||||
})
|
||||
.catch((error) => {
|
||||
PluginService.logger.error('Error registering plugins', error);
|
||||
return [];
|
||||
})
|
||||
.finally(() => {
|
||||
// clear loading state
|
||||
this.loadingPromise = undefined;
|
||||
});
|
||||
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
private static isPluginFactory(factory: unknown): factory is ConstructablePlugin {
|
||||
return typeof factory === 'function';
|
||||
}
|
||||
|
||||
private static async getPluginFromPackage(pluginPackage: string): Promise<{
|
||||
provider: CustomProvider;
|
||||
pluginInstance: ApiPluginDefinition;
|
||||
}> {
|
||||
const moduleImport = await import(/* @vite-ignore */ pluginPackage);
|
||||
const pluginName = pascalCase(pluginPackage);
|
||||
const PluginFactory = moduleImport.default || moduleImport[pluginName];
|
||||
|
||||
if (!PluginService.isPluginFactory(PluginFactory)) {
|
||||
throw new Error(`Invalid plugin from ${pluginPackage}. Must export a factory function.`);
|
||||
}
|
||||
|
||||
const logger = new Logger(PluginFactory.name);
|
||||
const validation = apiPluginSchema.safeParse(PluginFactory({ store, logger }));
|
||||
if (!validation.success) {
|
||||
throw new Error(`Invalid plugin from ${pluginPackage}: ${validation.error}`);
|
||||
}
|
||||
const pluginInstance = validation.data;
|
||||
|
||||
return {
|
||||
provider: {
|
||||
provide: PluginFactory.name,
|
||||
useValue: pluginInstance,
|
||||
},
|
||||
pluginInstance,
|
||||
};
|
||||
}
|
||||
|
||||
static async getPlugins() {
|
||||
/** 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
|
||||
/** list of npm packages that are unraid-api plugins */
|
||||
const plugins = getPackageDependencies()?.filter((pkgName) => pkgName.startsWith(pluginPrefix));
|
||||
if (!plugins) {
|
||||
PluginService.logger.warn('Could not load dependencies from the Unraid-API package.json');
|
||||
// Fail silently: Return the module without plugins
|
||||
return [];
|
||||
}
|
||||
|
||||
const failedPlugins: string[] = [];
|
||||
const { data: pluginProviders } = await batchProcess(plugins, async (pluginPackage) => {
|
||||
try {
|
||||
return await PluginService.getPluginFromPackage(pluginPackage);
|
||||
} catch (error) {
|
||||
failedPlugins.push(pluginPackage);
|
||||
PluginService.logger.warn(error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (failedPlugins.length > 0) {
|
||||
PluginService.logger.warn(
|
||||
`${failedPlugins.length} plugins failed to load. Ignoring them: ${failedPlugins.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return pluginProviders;
|
||||
}
|
||||
|
||||
async getGraphQLConfiguration() {
|
||||
await this.loadPlugins();
|
||||
const plugins = this.plugins;
|
||||
|
||||
let combinedResolvers = {};
|
||||
const typeDefs: string[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.registerGraphQLResolvers) {
|
||||
const pluginResolvers = await plugin.registerGraphQLResolvers();
|
||||
combinedResolvers = {
|
||||
...combinedResolvers,
|
||||
...pluginResolvers,
|
||||
};
|
||||
}
|
||||
|
||||
if (plugin.registerGraphQLTypeDefs) {
|
||||
const pluginTypeDefs = await plugin.registerGraphQLTypeDefs();
|
||||
try {
|
||||
// Validate schema by parsing it - this will throw if invalid
|
||||
parse(pluginTypeDefs);
|
||||
typeDefs.push(pluginTypeDefs);
|
||||
} catch (error) {
|
||||
const errorMessage = `Plugin ${plugin.name} returned an unusable GraphQL type definition: ${JSON.stringify(
|
||||
pluginTypeDefs
|
||||
)}`;
|
||||
PluginService.logger.warn(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resolvers: combinedResolvers,
|
||||
typeDefs: typeDefs.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
async getRESTConfiguration() {
|
||||
await this.loadPlugins();
|
||||
const controllers: Type<any>[] = [];
|
||||
const routes: Record<string, any>[] = [];
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.registerRESTControllers) {
|
||||
const pluginControllers = await plugin.registerRESTControllers();
|
||||
controllers.push(...pluginControllers);
|
||||
}
|
||||
|
||||
if (plugin.registerRESTRoutes) {
|
||||
const pluginRoutes = await plugin.registerRESTRoutes();
|
||||
routes.push(...pluginRoutes);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
controllers,
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
async getServices() {
|
||||
await this.loadPlugins();
|
||||
const services: Type<any>[] = [];
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.registerServices) {
|
||||
const pluginServices = await plugin.registerServices();
|
||||
services.push(...pluginServices);
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
async getCronJobs() {
|
||||
await this.loadPlugins();
|
||||
const cronJobs: Record<string, any>[] = [];
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.registerCronJobs) {
|
||||
const pluginCronJobs = await plugin.registerCronJobs();
|
||||
cronJobs.push(...pluginCronJobs);
|
||||
}
|
||||
}
|
||||
|
||||
return cronJobs;
|
||||
}
|
||||
}
|
||||
33
packages/unraid-api-plugin-health/index.js
Normal file
33
packages/unraid-api-plugin-health/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export default ({ store, logger }) => ({
|
||||
_type: "UnraidApiPlugin",
|
||||
name: "HealthPlugin",
|
||||
description: "Health plugin",
|
||||
|
||||
commands: [],
|
||||
|
||||
async registerGraphQLResolvers() {
|
||||
return {
|
||||
Query: {
|
||||
health: () => {
|
||||
logger.log("Pinged health");
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async registerGraphQLTypeDefs() {
|
||||
return `
|
||||
type Query {
|
||||
health: String
|
||||
}
|
||||
`;
|
||||
},
|
||||
async onModuleInit() {
|
||||
logger.log("Health plugin initialized");
|
||||
},
|
||||
|
||||
async onModuleDestroy() {
|
||||
logger.log("Health plugin destroyed");
|
||||
},
|
||||
});
|
||||
13
packages/unraid-api-plugin-health/package.json
Normal file
13
packages/unraid-api-plugin-health/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "unraid-api-plugin-health",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
"license": "GPL-2.0-only",
|
||||
"description": "Example Health plugin for Unraid API"
|
||||
}
|
||||
6
plugin/.gitignore
vendored
6
plugin/.gitignore
vendored
@@ -13,4 +13,8 @@ source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-com
|
||||
!source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/.gitkeep
|
||||
|
||||
source/dynamix.unraid.net/usr/local/unraid-api/*
|
||||
!source/dynamix.unraid.net/usr/local/unraid-api/.gitkeep
|
||||
!source/dynamix.unraid.net/usr/local/unraid-api/.gitkeep
|
||||
|
||||
source/dynamix.unraid.net/install/doinst.sh
|
||||
|
||||
packed-pnpm-store.txz
|
||||
|
||||
@@ -3,7 +3,7 @@ import { $ } from "zx";
|
||||
import { escape as escapeHtml } from "html-sloppy-escaper";
|
||||
import { dirname, join } from "node:path";
|
||||
import { getTxzName, pluginName, startingDir } from "./utils/consts";
|
||||
import { getPluginUrl } from "./utils/bucket-urls";
|
||||
import { getAssetUrl, getPluginUrl } from "./utils/bucket-urls";
|
||||
import { getMainTxzUrl } from "./utils/bucket-urls";
|
||||
import {
|
||||
deployDir,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./utils/paths";
|
||||
import { PluginEnv, setupPluginEnv } from "./cli/setup-plugin-environment";
|
||||
import { cleanupPluginFiles } from "./utils/cleanup";
|
||||
import { bundlePnpmStore, getPnpmBundleName } from "./build-pnpm-store";
|
||||
|
||||
/**
|
||||
* Check if git is available
|
||||
@@ -60,6 +61,8 @@ const buildPlugin = async ({
|
||||
pluginURL: getPluginUrl({ baseUrl, tag }),
|
||||
MAIN_TXZ: getMainTxzUrl({ baseUrl, pluginVersion, tag }),
|
||||
TXZ_SHA256: txzSha256,
|
||||
VENDOR_STORE_URL: getAssetUrl({ baseUrl, tag }, getPnpmBundleName()),
|
||||
VENDOR_STORE_FILENAME: getPnpmBundleName(),
|
||||
...(tag ? { TAG: tag } : {}),
|
||||
};
|
||||
|
||||
@@ -67,7 +70,9 @@ const buildPlugin = async ({
|
||||
// Iterate over entities and update them
|
||||
Object.entries(entities).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
throw new Error(`Entity ${key} not set in entities: ${JSON.stringify(entities)}`);
|
||||
throw new Error(
|
||||
`Entity ${key} not set in entities: ${JSON.stringify(entities)}`
|
||||
);
|
||||
}
|
||||
plgContent = updateEntityValue(plgContent, key, value);
|
||||
});
|
||||
@@ -94,11 +99,16 @@ const buildPlugin = async ({
|
||||
const main = async () => {
|
||||
try {
|
||||
const validatedEnv = await setupPluginEnv(process.argv);
|
||||
await checkGit();
|
||||
if (validatedEnv.tag === "LOCAL_PLUGIN_BUILD") {
|
||||
console.log("Skipping git check for LOCAL_PLUGIN_BUILD");
|
||||
} else {
|
||||
await checkGit();
|
||||
}
|
||||
await cleanupPluginFiles();
|
||||
|
||||
await buildPlugin(validatedEnv);
|
||||
await moveTxzFile(validatedEnv.txzPath, validatedEnv.pluginVersion);
|
||||
await bundlePnpmStore();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
|
||||
42
plugin/builder/build-pnpm-store.ts
Normal file
42
plugin/builder/build-pnpm-store.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { apiDir, deployDir } from "./utils/paths";
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { startingDir } from "./utils/consts";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
|
||||
/**
|
||||
* Get the version of the API from the package.json file
|
||||
*
|
||||
* Throws if package.json is not found or is invalid JSON.
|
||||
* @returns The version of the API
|
||||
*/
|
||||
function getVersion(): string {
|
||||
const packageJsonPath = join(apiDir, "package.json");
|
||||
const packageJsonString = readFileSync(packageJsonPath, "utf8");
|
||||
const packageJson = JSON.parse(packageJsonString);
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the pnpm store archive that will be vendored with the plugin.
|
||||
* @returns The name of the pnpm store bundle file
|
||||
*/
|
||||
export function getPnpmBundleName(): string {
|
||||
const version = getVersion();
|
||||
return `pnpm-store-for-v${version}.txz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a versioned bundle of the API's pnpm store to vendor dependencies.
|
||||
*
|
||||
* It expects a generic `packed-pnpm-store.txz` archive to be available in the `startingDir`.
|
||||
* It copies this archive to the `deployDir` directory and adds a version to the filename.
|
||||
* It does not actually create the packed pnpm store archive; that is done inside the API's build script.
|
||||
*
|
||||
* After this operation, the vendored store will be available inside the `deployDir`.
|
||||
*/
|
||||
export async function bundlePnpmStore(): Promise<void> {
|
||||
const storeArchive = join(startingDir, "packed-pnpm-store.txz");
|
||||
const pnpmStoreTarPath = join(deployDir, getPnpmBundleName());
|
||||
await copyFile(storeArchive, pnpmStoreTarPath);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { readdir } from "node:fs/promises";
|
||||
import { getTxzName, pluginName, startingDir } from "./utils/consts";
|
||||
import { setupTxzEnv, TxzEnv } from "./cli/setup-txz-environment";
|
||||
import { cleanupTxzFiles } from "./utils/cleanup";
|
||||
import { apiDir } from "./utils/paths";
|
||||
|
||||
// Recursively search for manifest files
|
||||
const findManifestFiles = async (dir: string): Promise<string[]> => {
|
||||
@@ -74,14 +75,6 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => {
|
||||
);
|
||||
}
|
||||
|
||||
const apiDir = join(
|
||||
startingDir,
|
||||
"source",
|
||||
pluginName,
|
||||
"usr",
|
||||
"local",
|
||||
"unraid-api"
|
||||
);
|
||||
if (!existsSync(apiDir)) {
|
||||
throw new Error(`API directory ${apiDir} does not exist`);
|
||||
}
|
||||
|
||||
@@ -26,24 +26,25 @@ const getRootBucketPath = ({ baseUrl, tag }: UrlParams): URL => {
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the URL for an asset from the root bucket
|
||||
* ex. returns = BASE_URL/TAG/dynamix.unraid.net.plg
|
||||
*/
|
||||
export const getAssetUrl = (params: UrlParams, assetName: string): string => {
|
||||
const rootUrl = getRootBucketPath(params);
|
||||
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + assetName;
|
||||
return rootUrl.toString();
|
||||
};
|
||||
/**
|
||||
* Get the URL for the plugin file
|
||||
* ex. returns = BASE_URL/TAG/dynamix.unraid.net.plg
|
||||
*/
|
||||
export const getPluginUrl = (params: UrlParams): string => {
|
||||
const rootUrl = getRootBucketPath(params);
|
||||
// Ensure the path ends with a slash and join with the plugin name
|
||||
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + pluginNameWithExt;
|
||||
return rootUrl.toString();
|
||||
};
|
||||
export const getPluginUrl = (params: UrlParams): string =>
|
||||
getAssetUrl(params, pluginNameWithExt);
|
||||
|
||||
/**
|
||||
* Get the URL for the main TXZ file
|
||||
* ex. returns = BASE_URL/TAG/dynamix.unraid.net-4.1.3.txz
|
||||
*/
|
||||
export const getMainTxzUrl = (params: TxzUrlParams): string => {
|
||||
const rootUrl = getRootBucketPath(params);
|
||||
// Ensure the path ends with a slash and join with the txz name
|
||||
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + getTxzName(params.pluginVersion);
|
||||
return rootUrl.toString();
|
||||
};
|
||||
export const getMainTxzUrl = (params: TxzUrlParams): string =>
|
||||
getAssetUrl(params, getTxzName(params.pluginVersion));
|
||||
|
||||
@@ -34,6 +34,7 @@ export const getStagingChangelogFromGit = async ({
|
||||
// Encode HTML entities using the 'he' library
|
||||
return changelog ?? "";
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to get changelog from git: ${err}`);
|
||||
console.log('Non-fatal error: Failed to get changelog from git:', err);
|
||||
return tag;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { join } from "path";
|
||||
import { getTxzName, pluginNameWithExt } from "./consts";
|
||||
import {
|
||||
getTxzName,
|
||||
pluginName,
|
||||
pluginNameWithExt,
|
||||
startingDir,
|
||||
} from "./consts";
|
||||
|
||||
export interface PathConfig {
|
||||
startingDir: string;
|
||||
@@ -11,6 +16,15 @@ export interface TxzPathConfig extends PathConfig {
|
||||
|
||||
export const deployDir = "deploy" as const;
|
||||
|
||||
export const apiDir = join(
|
||||
startingDir,
|
||||
"source",
|
||||
pluginName,
|
||||
"usr",
|
||||
"local",
|
||||
"unraid-api"
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the path to the root plugin directory
|
||||
* @param startingDir - The starting directory
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- ../unraid-ui/dist-wc:/app/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/uui
|
||||
- ../web/.nuxt/nuxt-custom-elements/dist/unraid-components:/app/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt
|
||||
- ../api/deploy/pack/:/app/source/dynamix.unraid.net/usr/local/unraid-api
|
||||
- ../api/deploy/packed-pnpm-store.txz:/app/packed-pnpm-store.txz
|
||||
stdin_open: true # equivalent to -i
|
||||
tty: true # equivalent to -t
|
||||
environment:
|
||||
|
||||
@@ -7,15 +7,28 @@
|
||||
<!ENTITY pluginURL "">
|
||||
<!ENTITY source "/boot/config/plugins/dynamix.my.servers/&name;">
|
||||
<!ENTITY TXZ_SHA256 "">
|
||||
<!-- Node.js Runtime. Required to run the Unraid API. -->
|
||||
<!ENTITY NODEJS_VERSION "22.14.0">
|
||||
<!-- Version is omitted from filename, so we don't need to search/delete other versions when updating the plugin. -->
|
||||
<!ENTITY NODEJS_FILENAME "node-linux-x64.tar.xz">
|
||||
<!-- To get SHA256:
|
||||
wget https://nodejs.org/download/release/v22.14.0/node-v22.14.0-linux-x64.tar.xz
|
||||
sha256sum node-v22.14.0-linux-x64.tar.xz
|
||||
-->
|
||||
<!ENTITY NODEJS_FILENAME "node-v&NODEJS_VERSION;-linux-x64.tar.xz">
|
||||
<!ENTITY NODEJS_SHA256 "69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec">
|
||||
<!ENTITY NODEJS_TXZ "https://nodejs.org/download/release/v&NODEJS_VERSION;/node-v&NODEJS_VERSION;-linux-x64.tar.xz">
|
||||
<!ENTITY MAIN_TXZ "">
|
||||
<!-- PNPM package manager for Node.js. Decouples dependencies from MAIN_TXZ. Prevents supply chain attacks. -->
|
||||
<!-- PNPM_BINARY is the filename of the binary on the boot drive. (In)validated via SHA256. -->
|
||||
<!ENTITY PNPM_BINARY "/boot/config/plugins/dynamix.my.servers/pnpm-linuxstatic-x64">
|
||||
<!ENTITY PNPM_BINARY_URL "https://github.com/pnpm/pnpm/releases/download/v10.7.0/pnpm-linuxstatic-x64">
|
||||
<!ENTITY PNPM_BINARY_SHA256 "714f4c21b63f47ed415f2e59f4bf5c699aa4f58b4d88e15ce6c66cda5631ebb2">
|
||||
<!-- VENDOR_STORE_URL points to an XZ tarball of vendored dependencies (i.e. global pnpm store), specific to the plugin version.
|
||||
This archive may be updated after installation (e.g. when adding api plugins), so we don't verify its hash.
|
||||
It is replaced only when the plugin/api is updated. -->
|
||||
<!ENTITY VENDOR_STORE_URL "">
|
||||
<!-- The archive's filename on the boot drive. Enables reproducible offline installs of the Unraid API. -->
|
||||
<!ENTITY VENDOR_STORE_FILENAME "">
|
||||
<!ENTITY TAG "">
|
||||
]>
|
||||
|
||||
@@ -103,9 +116,17 @@ exit 0
|
||||
<URL>&NODEJS_TXZ;</URL>
|
||||
<SHA256>&NODEJS_SHA256;</SHA256>
|
||||
</FILE>
|
||||
<FILE Name="&PNPM_BINARY;">
|
||||
<URL>&PNPM_BINARY_URL;</URL>
|
||||
<SHA256>&PNPM_BINARY_SHA256;</SHA256>
|
||||
</FILE>
|
||||
<FILE Name="/boot/config/plugins/dynamix.my.servers/&VENDOR_STORE_FILENAME;">
|
||||
<URL>&VENDOR_STORE_URL;</URL>
|
||||
</FILE>
|
||||
<FILE Run="/bin/bash" Method="install">
|
||||
<INLINE>
|
||||
NODE_FILE="&NODEJS_FILENAME;"
|
||||
VENDOR_ARCHIVE="&VENDOR_STORE_FILENAME;"
|
||||
<![CDATA[
|
||||
# Check if the Node.js archive exists
|
||||
if [[ ! -f "/boot/config/plugins/dynamix.my.servers/${NODE_FILE}" ]]; then
|
||||
@@ -132,8 +153,11 @@ exit 0
|
||||
fi
|
||||
|
||||
# Remove all node js archives from the flashdrive that do not match the expected version
|
||||
# deprecated Apr 2025. kept to remove unused archives for users upgrading from versioned node downloads.
|
||||
find /boot/config/plugins/dynamix.my.servers/ -name "node-v*-linux-x64.tar.xz" ! -name "${NODE_FILE}" -delete
|
||||
|
||||
# Remove stale pnpm store archives from the boot drive
|
||||
find /boot/config/plugins/dynamix.my.servers/ -name "pnpm-store-for-v*.txz" ! -name "${VENDOR_ARCHIVE}" -delete
|
||||
|
||||
echo "Node.js installation successful"
|
||||
|
||||
@@ -229,6 +253,7 @@ if [ -e /etc/rc.d/rc.unraid-api ]; then
|
||||
# uninstall the api
|
||||
rm -rf /usr/local/unraid-api
|
||||
rm -rf /var/run/unraid-api.sock
|
||||
rm -rf /usr/.pnpm-store
|
||||
fi
|
||||
]]>
|
||||
</INLINE>
|
||||
@@ -332,6 +357,8 @@ exit 0
|
||||
<FILE Run="/bin/bash" Method="install">
|
||||
<INLINE>
|
||||
TAG="&TAG;" MAINTXZ="&source;.txz"
|
||||
VENDOR_ARCHIVE="/boot/config/plugins/dynamix.my.servers/&VENDOR_STORE_FILENAME;"
|
||||
PNPM_BINARY_FILE="&PNPM_BINARY;"
|
||||
<![CDATA[
|
||||
appendTextIfMissing() {
|
||||
FILE="$1" TEXT="$2"
|
||||
@@ -788,9 +815,24 @@ fi
|
||||
|
||||
# Create symlink to unraid-api binary (to allow usage elsewhere)
|
||||
ln -sf /usr/local/node/bin/node /usr/local/bin/node
|
||||
ln -sf /usr/local/node/bin/npm /usr/local/bin/npm
|
||||
ln -sf /usr/local/node/bin/corepack /usr/local/bin/corepack
|
||||
|
||||
ln -sf ${unraid_binary_path} /usr/local/sbin/unraid-api
|
||||
ln -sf ${unraid_binary_path} /usr/bin/unraid-api
|
||||
|
||||
cp -f "${PNPM_BINARY_FILE}" /usr/local/bin/pnpm
|
||||
chmod +x /usr/local/bin/pnpm
|
||||
|
||||
/etc/rc.d/rc.unraid-api restore-dependencies "$VENDOR_ARCHIVE"
|
||||
echo
|
||||
echo "⚠️ Do not close this window yet"
|
||||
/etc/rc.d/rc.unraid-api pnpm-install
|
||||
echo
|
||||
echo "⚠️ Do not close this window yet"
|
||||
echo
|
||||
echo "About to start the Unraid API"
|
||||
|
||||
|
||||
logger "Starting flash backup (if enabled)"
|
||||
echo "/etc/rc.d/rc.flash_backup start" | at -M now &>/dev/null
|
||||
|
||||
@@ -8,7 +8,9 @@ flash="/boot/config/plugins/dynamix.my.servers"
|
||||
[[ ! -d "${flash}" ]] && echo "Please reinstall the Unraid Connect plugin" && exit 1
|
||||
[[ ! -f "${flash}/env" ]] && echo 'env=production' >"${flash}/env"
|
||||
unraid_binary_path="/usr/local/bin/unraid-api"
|
||||
pnpm_store_dir="/usr/.pnpm-store"
|
||||
|
||||
# Placeholder functions for plugin installation/uninstallation
|
||||
install() {
|
||||
true;
|
||||
}
|
||||
@@ -16,6 +18,115 @@ uninstall() {
|
||||
true;
|
||||
}
|
||||
|
||||
# Creates a backup of the global pnpm store directory
|
||||
# Args:
|
||||
# $1 - Path to the backup file (tar.xz format)
|
||||
# Returns:
|
||||
# 0 on success, 1 on failure
|
||||
backup_pnpm_store() {
|
||||
# Check if backup file path is provided
|
||||
if [ -z "$1" ]; then
|
||||
echo "Error: Backup file path is required"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local backup_file="$1"
|
||||
|
||||
# Check if pnpm command exists
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "pnpm is not installed. Skipping backup."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Determine the global pnpm store directory
|
||||
mkdir -p "$pnpm_store_dir"
|
||||
|
||||
echo "Backing up pnpm store from '$pnpm_store_dir' to '$backup_file'"
|
||||
|
||||
# Create a tar.gz archive of the global pnpm store
|
||||
if tar -cJf "$backup_file" -C "$(dirname "$pnpm_store_dir")" "$(basename "$pnpm_store_dir")"; then
|
||||
echo "pnpm store backup completed successfully."
|
||||
else
|
||||
echo "Error: Failed to create pnpm store backup."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Restores the pnpm store from a backup file
|
||||
# Args:
|
||||
# $1 - Path to the backup file (tar.xz format)
|
||||
# Returns:
|
||||
# 0 on success, 1 on failure
|
||||
# Note: Requires 1.5x the backup size in free space for safe extraction
|
||||
restore_pnpm_store() {
|
||||
# Check if pnpm command exists
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "pnpm is not installed. Cannot restore store."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local backup_file="$1"
|
||||
# Check if backup file exists
|
||||
if [ ! -f "$backup_file" ]; then
|
||||
echo "Backup file not found at '$backup_file'. Skipping restore."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check available disk space in destination
|
||||
local backup_size
|
||||
backup_size=$(stat -c%s "$backup_file")
|
||||
local dest_space
|
||||
dest_space=$(df --output=avail "$(dirname "$pnpm_store_dir")" | tail -n1)
|
||||
dest_space=$((dest_space * 1024)) # Convert KB to bytes
|
||||
|
||||
# Require 1.5x the backup size for safe extraction
|
||||
local required_space=$((backup_size + (backup_size / 2)))
|
||||
|
||||
if [ "$dest_space" -lt "$required_space" ]; then
|
||||
echo "Error: Insufficient disk space in destination. Need at least $((required_space / 1024 / 1024))MB, have $((dest_space / 1024 / 1024))MB"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Restoring pnpm store from '$backup_file' to '$pnpm_store_dir'"
|
||||
# Remove existing store directory if it exists and ensure its parent directory exists
|
||||
rm -rf "$pnpm_store_dir"
|
||||
mkdir -p "$(dirname "$pnpm_store_dir")"
|
||||
|
||||
# Extract directly to final location
|
||||
if ! tar -xJf "$backup_file" -C "$(dirname "$pnpm_store_dir")" --preserve-permissions; then
|
||||
echo "Error: Failed to extract backup to final location."
|
||||
rm -rf "$pnpm_store_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "pnpm store restored successfully."
|
||||
}
|
||||
|
||||
# Installs production dependencies for the unraid-api using pnpm. Prefers offline mode.
|
||||
# Uses the api_base_directory variable or defaults to /usr/local/unraid-api
|
||||
# Returns:
|
||||
# 0 on success, 1 on failure
|
||||
pnpm_install_unraid_api() {
|
||||
# Check if pnpm command exists
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
echo "Error: pnpm command not found. Cannot install dependencies."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use the api_base_directory variable if set, otherwise default to /usr/local/unraid-api
|
||||
local unraid_api_dir="${api_base_directory:-/usr/local/unraid-api}"
|
||||
if [ ! -d "$unraid_api_dir" ]; then
|
||||
echo "Error: unraid API directory '$unraid_api_dir' does not exist."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Executing 'pnpm install' in $unraid_api_dir"
|
||||
rm -rf /usr/local/unraid-api/node_modules
|
||||
# Run pnpm install in a subshell to prevent changing the current working directory of the script
|
||||
( cd "$unraid_api_dir" && pnpm install --prod --prefer-offline )
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
'install')
|
||||
install "$2"
|
||||
@@ -26,6 +137,15 @@ case "$1" in
|
||||
'uninstall')
|
||||
uninstall
|
||||
;;
|
||||
'pnpm-install')
|
||||
pnpm_install_unraid_api
|
||||
;;
|
||||
'backup-dependencies')
|
||||
backup_pnpm_store "$2"
|
||||
;;
|
||||
'restore-dependencies')
|
||||
restore_pnpm_store "$2"
|
||||
;;
|
||||
*)
|
||||
# Pass all other commands to unraid-api
|
||||
"${unraid_binary_path}" "$@"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -101,6 +101,9 @@ importers:
|
||||
casbin:
|
||||
specifier: ^5.32.0
|
||||
version: 5.38.0
|
||||
change-case:
|
||||
specifier: ^5.4.4
|
||||
version: 5.4.4
|
||||
chokidar:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.3
|
||||
@@ -423,6 +426,9 @@ importers:
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.19.3
|
||||
type-fest:
|
||||
specifier: ^4.37.0
|
||||
version: 4.37.0
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.8.2
|
||||
@@ -448,6 +454,8 @@ importers:
|
||||
specifier: ^8.3.2
|
||||
version: 8.4.1
|
||||
|
||||
packages/unraid-api-plugin-health: {}
|
||||
|
||||
plugin:
|
||||
dependencies:
|
||||
commander:
|
||||
@@ -9309,7 +9317,6 @@ packages:
|
||||
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
|
||||
deprecated: |-
|
||||
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
|
||||
|
||||
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
|
||||
|
||||
qs@6.13.0:
|
||||
@@ -10549,8 +10556,8 @@ packages:
|
||||
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
type-fest@4.34.1:
|
||||
resolution: {integrity: sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g==}
|
||||
type-fest@4.37.0:
|
||||
resolution: {integrity: sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
type-fest@4.38.0:
|
||||
@@ -16183,7 +16190,7 @@ snapshots:
|
||||
camelcase: 8.0.0
|
||||
map-obj: 5.0.0
|
||||
quick-lru: 6.1.2
|
||||
type-fest: 4.34.1
|
||||
type-fest: 4.37.0
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
@@ -18549,7 +18556,7 @@ snapshots:
|
||||
lowercase-keys: 3.0.0
|
||||
p-cancelable: 4.0.1
|
||||
responselike: 3.0.0
|
||||
type-fest: 4.34.1
|
||||
type-fest: 4.37.0
|
||||
|
||||
graceful-fs@4.2.10: {}
|
||||
|
||||
@@ -20698,7 +20705,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.26.2
|
||||
index-to-position: 0.1.2
|
||||
type-fest: 4.34.1
|
||||
type-fest: 4.37.0
|
||||
|
||||
parse-json@8.2.0:
|
||||
dependencies:
|
||||
@@ -21444,7 +21451,7 @@ snapshots:
|
||||
dependencies:
|
||||
find-up-simple: 1.0.0
|
||||
read-pkg: 9.0.1
|
||||
type-fest: 4.34.1
|
||||
type-fest: 4.37.0
|
||||
|
||||
read-pkg-up@3.0.0:
|
||||
dependencies:
|
||||
@@ -21475,7 +21482,7 @@ snapshots:
|
||||
'@types/normalize-package-data': 2.4.4
|
||||
normalize-package-data: 6.0.2
|
||||
parse-json: 8.1.0
|
||||
type-fest: 4.34.1
|
||||
type-fest: 4.37.0
|
||||
unicorn-magic: 0.1.0
|
||||
|
||||
read@1.0.7:
|
||||
@@ -22784,7 +22791,7 @@ snapshots:
|
||||
|
||||
type-fest@2.19.0: {}
|
||||
|
||||
type-fest@4.34.1: {}
|
||||
type-fest@4.37.0: {}
|
||||
|
||||
type-fest@4.38.0: {}
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ packages:
|
||||
- "./plugin"
|
||||
- "./unraid-ui"
|
||||
- "./web"
|
||||
- "./packages/*"
|
||||
|
||||
Reference in New Issue
Block a user