mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: api plugin management via CLI (#1416)
implements unraid-api `plugins list`, `plugins install`, `plugins remove` commands via a new `DependencyService` that invokes npm. ## Summary by CodeRabbit - **New Features** - Enhanced plugin management with install, remove, and list commands supporting bundled plugins and restart control. - Added plugin persistence and configuration synchronization across API settings and interfaces. - Introduced dependency management service for streamlined npm operations and vendor archive rebuilding. - **Bug Fixes** - Improved plugin listing accuracy with warnings for configured but missing plugins. - **Chores** - Refactored CLI modules and services for unified plugin management and dependency handling. - Updated API configuration loading and persistence for better separation of concerns. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -5,5 +5,6 @@
|
|||||||
"https://test.com"
|
"https://test.com"
|
||||||
],
|
],
|
||||||
"sandbox": true,
|
"sandbox": true,
|
||||||
"ssoSubIds": []
|
"ssoSubIds": [],
|
||||||
|
"plugins": []
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { csvStringToArray, formatDatetime } from '@app/utils.js';
|
import { csvStringToArray, formatDatetime, parsePackageArg } from '@app/utils.js';
|
||||||
|
|
||||||
describe('formatDatetime', () => {
|
describe('formatDatetime', () => {
|
||||||
const testDate = new Date('2024-02-14T12:34:56');
|
const testDate = new Date('2024-02-14T12:34:56');
|
||||||
@@ -103,3 +103,78 @@ describe('csvStringToArray', () => {
|
|||||||
expect(csvStringToArray(',one,')).toEqual(['one']);
|
expect(csvStringToArray(',one,')).toEqual(['one']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parsePackageArg', () => {
|
||||||
|
it('parses simple package names without version', () => {
|
||||||
|
expect(parsePackageArg('lodash')).toEqual({ name: 'lodash' });
|
||||||
|
expect(parsePackageArg('express')).toEqual({ name: 'express' });
|
||||||
|
expect(parsePackageArg('react')).toEqual({ name: 'react' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses simple package names with version', () => {
|
||||||
|
expect(parsePackageArg('lodash@4.17.21')).toEqual({ name: 'lodash', version: '4.17.21' });
|
||||||
|
expect(parsePackageArg('express@4.18.2')).toEqual({ name: 'express', version: '4.18.2' });
|
||||||
|
expect(parsePackageArg('react@18.2.0')).toEqual({ name: 'react', version: '18.2.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses scoped package names without version', () => {
|
||||||
|
expect(parsePackageArg('@types/node')).toEqual({ name: '@types/node' });
|
||||||
|
expect(parsePackageArg('@angular/core')).toEqual({ name: '@angular/core' });
|
||||||
|
expect(parsePackageArg('@nestjs/common')).toEqual({ name: '@nestjs/common' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses scoped package names with version', () => {
|
||||||
|
expect(parsePackageArg('@types/node@18.15.0')).toEqual({
|
||||||
|
name: '@types/node',
|
||||||
|
version: '18.15.0',
|
||||||
|
});
|
||||||
|
expect(parsePackageArg('@angular/core@15.2.0')).toEqual({
|
||||||
|
name: '@angular/core',
|
||||||
|
version: '15.2.0',
|
||||||
|
});
|
||||||
|
expect(parsePackageArg('@nestjs/common@9.3.12')).toEqual({
|
||||||
|
name: '@nestjs/common',
|
||||||
|
version: '9.3.12',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles version ranges and tags', () => {
|
||||||
|
expect(parsePackageArg('lodash@^4.17.0')).toEqual({ name: 'lodash', version: '^4.17.0' });
|
||||||
|
expect(parsePackageArg('react@~18.2.0')).toEqual({ name: 'react', version: '~18.2.0' });
|
||||||
|
expect(parsePackageArg('express@latest')).toEqual({ name: 'express', version: 'latest' });
|
||||||
|
expect(parsePackageArg('vue@beta')).toEqual({ name: 'vue', version: 'beta' });
|
||||||
|
expect(parsePackageArg('@types/node@next')).toEqual({ name: '@types/node', version: 'next' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple @ symbols correctly', () => {
|
||||||
|
expect(parsePackageArg('package@1.0.0@extra')).toEqual({
|
||||||
|
name: 'package@1.0.0',
|
||||||
|
version: 'extra',
|
||||||
|
});
|
||||||
|
expect(parsePackageArg('@scope/pkg@1.0.0@extra')).toEqual({
|
||||||
|
name: '@scope/pkg@1.0.0',
|
||||||
|
version: 'extra',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores versions that contain forward slashes', () => {
|
||||||
|
expect(parsePackageArg('package@github:user/repo')).toEqual({
|
||||||
|
name: 'package@github:user/repo',
|
||||||
|
});
|
||||||
|
expect(parsePackageArg('@scope/pkg@git+https://github.com/user/repo.git')).toEqual({
|
||||||
|
name: '@scope/pkg@git+https://github.com/user/repo.git',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles edge cases', () => {
|
||||||
|
expect(parsePackageArg('@')).toEqual({ name: '@' });
|
||||||
|
expect(parsePackageArg('@scope')).toEqual({ name: '@scope' });
|
||||||
|
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
|
||||||
|
expect(parsePackageArg('@scope/pkg@')).toEqual({ name: '@scope/pkg@' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty version strings', () => {
|
||||||
|
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
|
||||||
|
expect(parsePackageArg('@scope/package@')).toEqual({ name: '@scope/package@' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
48
api/src/unraid-api/app/dependency.service.ts
Normal file
48
api/src/unraid-api/app/dependency.service.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { execa } from 'execa';
|
||||||
|
|
||||||
|
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||||
|
import { getPackageJsonPath } from '@app/environment.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DependencyService {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an npm command.
|
||||||
|
*
|
||||||
|
* @param npmArgs - The arguments to pass to npm.
|
||||||
|
* @returns The execa result of the npm command.
|
||||||
|
*/
|
||||||
|
async npm(...npmArgs: string[]) {
|
||||||
|
return await execa('npm', [...npmArgs], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: path.dirname(getPackageJsonPath()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs dependencies for the api using npm.
|
||||||
|
*
|
||||||
|
* @throws {Error} from execa if the npm install command fails.
|
||||||
|
*/
|
||||||
|
async npmInstall(): Promise<void> {
|
||||||
|
await this.npm('install');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the vendored dependency archive for the api and stores it on the boot drive.
|
||||||
|
* If the rc.unraid-api script is not found, an error is thrown.
|
||||||
|
*
|
||||||
|
* @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))) {
|
||||||
|
throw new Error('[rebuild-vendor-archive] rc.unraid-api not found; no action taken!');
|
||||||
|
}
|
||||||
|
await execa(rcUnraidApi, ['archive-dependencies'], { stdio: 'inherit' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||||
@@ -10,7 +11,12 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman
|
|||||||
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
|
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
|
||||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||||
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
|
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
|
||||||
import { PluginCommandModule } from '@app/unraid-api/cli/plugins/plugin.cli.module.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 { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||||
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
||||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||||
@@ -30,26 +36,30 @@ import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
|||||||
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
||||||
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||||
|
|
||||||
// cli - plugin add/remove
|
|
||||||
// plugin generator
|
|
||||||
|
|
||||||
const DEFAULT_COMMANDS = [
|
const DEFAULT_COMMANDS = [
|
||||||
ApiKeyCommand,
|
ApiKeyCommand,
|
||||||
ConfigCommand,
|
ConfigCommand,
|
||||||
DeveloperCommand,
|
DeveloperCommand,
|
||||||
LogsCommand,
|
LogsCommand,
|
||||||
ReportCommand,
|
ReportCommand,
|
||||||
|
VersionCommand,
|
||||||
|
// Lifecycle commands
|
||||||
|
SwitchEnvCommand,
|
||||||
RestartCommand,
|
RestartCommand,
|
||||||
StartCommand,
|
StartCommand,
|
||||||
StatusCommand,
|
StatusCommand,
|
||||||
StopCommand,
|
StopCommand,
|
||||||
SwitchEnvCommand,
|
// SSO commands
|
||||||
VersionCommand,
|
|
||||||
SSOCommand,
|
SSOCommand,
|
||||||
ValidateTokenCommand,
|
ValidateTokenCommand,
|
||||||
AddSSOUserCommand,
|
AddSSOUserCommand,
|
||||||
RemoveSSOUserCommand,
|
RemoveSSOUserCommand,
|
||||||
ListSSOUserCommand,
|
ListSSOUserCommand,
|
||||||
|
// Plugin commands
|
||||||
|
PluginCommand,
|
||||||
|
ListPluginCommand,
|
||||||
|
InstallPluginCommand,
|
||||||
|
RemovePluginCommand,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DEFAULT_PROVIDERS = [
|
const DEFAULT_PROVIDERS = [
|
||||||
@@ -62,10 +72,11 @@ const DEFAULT_PROVIDERS = [
|
|||||||
PM2Service,
|
PM2Service,
|
||||||
ApiKeyService,
|
ApiKeyService,
|
||||||
SsoUserService,
|
SsoUserService,
|
||||||
|
DependencyService,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register(), PluginCommandModule],
|
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register()],
|
||||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||||
})
|
})
|
||||||
export class CliModule {}
|
export class CliModule {}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export class LogService {
|
|||||||
return shouldLog;
|
return shouldLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table(level: LogLevel, data: unknown, columns?: string[]) {
|
||||||
|
if (this.shouldLog(level)) {
|
||||||
|
console.table(data, columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log(...messages: unknown[]): void {
|
log(...messages: unknown[]): void {
|
||||||
if (this.shouldLog('info')) {
|
if (this.shouldLog('info')) {
|
||||||
this.logger.log(...messages);
|
this.logger.log(...messages);
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Command, CommandRunner, Option, SubCommand } from 'nest-commander';
|
import { Command, CommandRunner, Option, SubCommand } from 'nest-commander';
|
||||||
|
|
||||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
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 { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||||
|
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
|
||||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||||
|
import { parsePackageArg } from '@app/utils.js';
|
||||||
|
|
||||||
interface InstallPluginCommandOptions {
|
interface InstallPluginCommandOptions {
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
|
restart: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubCommand({
|
@SubCommand({
|
||||||
@@ -17,30 +21,28 @@ interface InstallPluginCommandOptions {
|
|||||||
})
|
})
|
||||||
export class InstallPluginCommand extends CommandRunner {
|
export class InstallPluginCommand extends CommandRunner {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dependencyService: DependencyService,
|
|
||||||
private readonly logService: LogService,
|
private readonly logService: LogService,
|
||||||
private readonly restartCommand: RestartCommand
|
private readonly restartCommand: RestartCommand,
|
||||||
|
private readonly pluginManagementService: PluginManagementService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
|
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
|
||||||
const [packageName] = passedParams;
|
if (passedParams.length === 0) {
|
||||||
if (!packageName) {
|
|
||||||
this.logService.error('Package name is required.');
|
this.logService.error('Package name is required.');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
if (options.bundled) {
|
||||||
await this.dependencyService.addPeerDependency(packageName, options.bundled);
|
await this.pluginManagementService.addBundledPlugin(...passedParams);
|
||||||
this.logService.log(`Added ${packageName} as a peer dependency.`);
|
this.logService.log(`Added bundled plugin ${passedParams.join(', ')}`);
|
||||||
if (!options.bundled) {
|
} else {
|
||||||
await this.dependencyService.rebuildVendorArchive();
|
await this.pluginManagementService.addPlugin(...passedParams);
|
||||||
}
|
this.logService.log(`Added plugin ${passedParams.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (options.restart) {
|
||||||
await this.restartCommand.run();
|
await this.restartCommand.run();
|
||||||
} catch (error) {
|
|
||||||
this.logService.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,15 @@ export class InstallPluginCommand extends CommandRunner {
|
|||||||
parseBundled(): boolean {
|
parseBundled(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Option({
|
||||||
|
flags: '--no-restart',
|
||||||
|
description: 'do NOT restart the service after deploy',
|
||||||
|
defaultValue: true,
|
||||||
|
})
|
||||||
|
parseRestart(value: boolean): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubCommand({
|
@SubCommand({
|
||||||
@@ -62,27 +73,47 @@ export class InstallPluginCommand extends CommandRunner {
|
|||||||
})
|
})
|
||||||
export class RemovePluginCommand extends CommandRunner {
|
export class RemovePluginCommand extends CommandRunner {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pluginService: DependencyService,
|
|
||||||
private readonly logService: LogService,
|
private readonly logService: LogService,
|
||||||
|
private readonly pluginManagementService: PluginManagementService,
|
||||||
private readonly restartCommand: RestartCommand
|
private readonly restartCommand: RestartCommand
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(passedParams: string[]): Promise<void> {
|
async run(passedParams: string[], options: InstallPluginCommandOptions): Promise<void> {
|
||||||
const [packageName] = passedParams;
|
if (passedParams.length === 0) {
|
||||||
if (!packageName) {
|
|
||||||
this.logService.error('Package name is required.');
|
this.logService.error('Package name is required.');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
if (options.bundled) {
|
||||||
await this.pluginService.removePeerDependency(packageName);
|
await this.pluginManagementService.removeBundledPlugin(...passedParams);
|
||||||
await this.restartCommand.run();
|
this.logService.log(`Removed bundled plugin ${passedParams.join(', ')}`);
|
||||||
} catch (error) {
|
} else {
|
||||||
this.logService.error(`Failed to remove plugin '${packageName}':`, error);
|
await this.pluginManagementService.removePlugin(...passedParams);
|
||||||
process.exitCode = 1;
|
this.logService.log(`Removed plugin ${passedParams.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
if (options.restart) {
|
||||||
|
await this.restartCommand.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option({
|
||||||
|
flags: '-b, --bundled',
|
||||||
|
description: 'Uninstall a bundled plugin',
|
||||||
|
defaultValue: false,
|
||||||
|
})
|
||||||
|
parseBundled(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Option({
|
||||||
|
flags: '--no-restart',
|
||||||
|
description: 'do NOT restart the service after deploy',
|
||||||
|
defaultValue: true,
|
||||||
|
})
|
||||||
|
parseRestart(value: boolean): boolean {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,27 +123,52 @@ export class RemovePluginCommand extends CommandRunner {
|
|||||||
options: { isDefault: true },
|
options: { isDefault: true },
|
||||||
})
|
})
|
||||||
export class ListPluginCommand extends CommandRunner {
|
export class ListPluginCommand extends CommandRunner {
|
||||||
constructor(private readonly logService: LogService) {
|
constructor(
|
||||||
|
private readonly logService: LogService,
|
||||||
|
private readonly pluginManagementService: PluginManagementService
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
const plugins = await PluginService.listPlugins();
|
const configPlugins = this.pluginManagementService.plugins;
|
||||||
this.logService.log('Installed plugins:');
|
const installedPlugins = await PluginService.listPlugins();
|
||||||
plugins.forEach(([name, version]) => {
|
|
||||||
|
// this can happen if configPlugins is a super set of installedPlugins
|
||||||
|
if (installedPlugins.length !== configPlugins.length) {
|
||||||
|
const configSet = new Set(configPlugins.map((plugin) => parsePackageArg(plugin).name));
|
||||||
|
const installedSet = new Set(installedPlugins.map(([name]) => name));
|
||||||
|
const notInstalled = Array.from(configSet.difference(installedSet));
|
||||||
|
this.logService.warn(`${notInstalled.length} plugins are not installed:`);
|
||||||
|
this.logService.table('warn', notInstalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installedPlugins.length === 0) {
|
||||||
|
this.logService.log('No plugins installed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logService.log('Installed plugins:\n');
|
||||||
|
installedPlugins.forEach(([name, version]) => {
|
||||||
this.logService.log(`☑️ ${name}@${version}`);
|
this.logService.log(`☑️ ${name}@${version}`);
|
||||||
});
|
});
|
||||||
|
this.logService.log(); // for spacing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
description: 'Manage Unraid API plugins (peer dependencies)',
|
description: 'Manage Unraid API plugins (peer dependencies)',
|
||||||
subCommands: [InstallPluginCommand, RemovePluginCommand, ListPluginCommand],
|
subCommands: [InstallPluginCommand, RemovePluginCommand, ListPluginCommand],
|
||||||
})
|
})
|
||||||
export class PluginCommand extends CommandRunner {
|
export class PluginCommand extends CommandRunner {
|
||||||
constructor() {
|
constructor(private readonly logger: LogService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
async run(): Promise<void> {}
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
this.logger.info('Please provide a subcommand or use --help for more information');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ const createDefaultConfig = (): ApiConfig => ({
|
|||||||
extraOrigins: [],
|
extraOrigins: [],
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
ssoSubIds: [],
|
ssoSubIds: [],
|
||||||
|
plugins: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const persistApiConfig = async (config: ApiConfig) => {
|
||||||
* Loads the API config from disk. If not found, returns the default config, but does not persist it.
|
const apiConfig = new ApiStateConfig<ApiConfig>(
|
||||||
*/
|
{
|
||||||
export const apiConfig = registerAs<ApiConfig>('api', async () => {
|
name: 'api',
|
||||||
|
defaultConfig: config,
|
||||||
|
parse: (data) => data as ApiConfig,
|
||||||
|
},
|
||||||
|
new ConfigPersistenceHelper()
|
||||||
|
);
|
||||||
|
return await apiConfig.persist(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadApiConfig = async () => {
|
||||||
const defaultConfig = createDefaultConfig();
|
const defaultConfig = createDefaultConfig();
|
||||||
const apiConfig = new ApiStateConfig<ApiConfig>(
|
const apiConfig = new ApiStateConfig<ApiConfig>(
|
||||||
{
|
{
|
||||||
@@ -38,7 +48,12 @@ export const apiConfig = registerAs<ApiConfig>('api', async () => {
|
|||||||
...diskConfig,
|
...diskConfig,
|
||||||
version: API_VERSION,
|
version: API_VERSION,
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the API config from disk. If not found, returns the default config, but does not persist it.
|
||||||
|
*/
|
||||||
|
export const apiConfig = registerAs<ApiConfig>('api', loadApiConfig);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
class ApiConfigPersistence {
|
class ApiConfigPersistence {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class ApiSettings {
|
|||||||
sandbox: this.configService.get('api.sandbox', { infer: true }),
|
sandbox: this.configService.get('api.sandbox', { infer: true }),
|
||||||
extraOrigins: this.configService.get('api.extraOrigins', { infer: true }),
|
extraOrigins: this.configService.get('api.extraOrigins', { infer: true }),
|
||||||
ssoSubIds: this.configService.get('api.ssoSubIds', { infer: true }),
|
ssoSubIds: this.configService.get('api.ssoSubIds', { infer: true }),
|
||||||
|
plugins: this.configService.get('api.plugins', { infer: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
116
api/src/unraid-api/plugin/plugin-management.service.ts
Normal file
116
api/src/unraid-api/plugin/plugin-management.service.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { ApiConfig } from '@unraid/shared/services/api-config.js';
|
||||||
|
|
||||||
|
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||||
|
import { persistApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PluginManagementService {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService<{ api: ApiConfig }, true>,
|
||||||
|
private readonly dependencyService: DependencyService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get plugins() {
|
||||||
|
return this.configService.get('api.plugins', [], { infer: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPlugin(...plugins: string[]) {
|
||||||
|
const added = this.addPluginToConfig(...plugins);
|
||||||
|
await this.persistConfig();
|
||||||
|
await this.installPlugins(...added);
|
||||||
|
await this.dependencyService.rebuildVendorArchive();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePlugin(...plugins: string[]) {
|
||||||
|
const removed = this.removePluginFromConfig(...plugins);
|
||||||
|
await this.persistConfig();
|
||||||
|
await this.uninstallPlugins(...removed);
|
||||||
|
await this.dependencyService.rebuildVendorArchive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds plugins to the config.
|
||||||
|
*
|
||||||
|
* @param plugins - The plugins to add.
|
||||||
|
* @returns The list of plugins added to the config
|
||||||
|
*/
|
||||||
|
private addPluginToConfig(...plugins: string[]) {
|
||||||
|
const pluginSet = new Set(this.plugins);
|
||||||
|
const added: string[] = [];
|
||||||
|
plugins.forEach((plugin) => {
|
||||||
|
if (!pluginSet.has(plugin)) {
|
||||||
|
added.push(plugin);
|
||||||
|
}
|
||||||
|
pluginSet.add(plugin);
|
||||||
|
});
|
||||||
|
// @ts-expect-error - This is a valid config key
|
||||||
|
this.configService.set<string[]>('api.plugins', Array.from(pluginSet));
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes plugins from the config.
|
||||||
|
*
|
||||||
|
* @param plugins - The plugins to remove.
|
||||||
|
* @returns The list of plugins removed from the config
|
||||||
|
*/
|
||||||
|
private removePluginFromConfig(...plugins: string[]) {
|
||||||
|
const pluginSet = new Set(this.plugins);
|
||||||
|
const removed = plugins.filter((plugin) => pluginSet.delete(plugin));
|
||||||
|
const pluginsArray = Array.from(pluginSet);
|
||||||
|
// @ts-expect-error - This is a valid config key
|
||||||
|
this.configService.set('api.plugins', pluginsArray);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs plugins using npm.
|
||||||
|
*
|
||||||
|
* @param plugins - The plugins to install.
|
||||||
|
* @returns The execa result of the npm command.
|
||||||
|
*/
|
||||||
|
private installPlugins(...plugins: string[]) {
|
||||||
|
return this.dependencyService.npm('i', '--save-peer', '--save-exact', ...plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstalls plugins using npm.
|
||||||
|
*
|
||||||
|
* @param plugins - The plugins to uninstall.
|
||||||
|
* @returns The execa result of the npm command.
|
||||||
|
*/
|
||||||
|
private uninstallPlugins(...plugins: string[]) {
|
||||||
|
return this.dependencyService.npm('uninstall', ...plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**------------------------------------------------------------------------
|
||||||
|
* Bundled Plugins
|
||||||
|
* Plugins that are not published to npm, but vendored as tarballs in the
|
||||||
|
* `/usr/local/unraid-api/packages` directory.
|
||||||
|
*
|
||||||
|
* We don't know their versions ahead of time, so for simplicity, they
|
||||||
|
* are installed to node_modules at build time and are never un/installed.
|
||||||
|
*
|
||||||
|
* We use the `api.plugins` config setting to control whether these plugins
|
||||||
|
* are loaded/enabled at runtime.
|
||||||
|
*------------------------------------------------------------------------**/
|
||||||
|
|
||||||
|
async addBundledPlugin(...plugins: string[]) {
|
||||||
|
const added = this.addPluginToConfig(...plugins);
|
||||||
|
await this.persistConfig();
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBundledPlugin(...plugins: string[]) {
|
||||||
|
const removed = this.removePluginFromConfig(...plugins);
|
||||||
|
await this.persistConfig();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistConfig() {
|
||||||
|
return await persistApiConfig(this.configService.get('api', { infer: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { DynamicModule, Logger, Module } from '@nestjs/common';
|
import { DynamicModule, Logger, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||||
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
|
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
|
||||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||||
|
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
|
||||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
@@ -20,8 +22,8 @@ export class PluginModule {
|
|||||||
return {
|
return {
|
||||||
module: PluginModule,
|
module: PluginModule,
|
||||||
imports: [GlobalDepsModule, ResolversModule, ...apiModules],
|
imports: [GlobalDepsModule, ResolversModule, ...apiModules],
|
||||||
providers: [PluginService],
|
providers: [PluginService, PluginManagementService, DependencyService],
|
||||||
exports: [PluginService, GlobalDepsModule],
|
exports: [PluginService, PluginManagementService, DependencyService, GlobalDepsModule],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,8 @@ export class PluginCliModule {
|
|||||||
return {
|
return {
|
||||||
module: PluginCliModule,
|
module: PluginCliModule,
|
||||||
imports: [GlobalDepsModule, ...cliModules],
|
imports: [GlobalDepsModule, ...cliModules],
|
||||||
exports: [GlobalDepsModule],
|
providers: [PluginManagementService, DependencyService],
|
||||||
|
exports: [PluginManagementService, DependencyService, GlobalDepsModule],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
|
|
||||||
import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||||
import { getPackageJson } from '@app/environment.js';
|
import { getPackageJson } from '@app/environment.js';
|
||||||
|
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||||
import {
|
import {
|
||||||
NotificationImportance,
|
NotificationImportance,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
|
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
|
||||||
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
|
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
|
||||||
import { apiNestPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
|
import { apiNestPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||||
import { batchProcess } from '@app/utils.js';
|
import { batchProcess, parsePackageArg } from '@app/utils.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PluginService {
|
export class PluginService {
|
||||||
@@ -51,19 +52,31 @@ export class PluginService {
|
|||||||
return plugins.data;
|
return plugins.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all plugins that are installed as peer dependencies of the unraid-api package.
|
||||||
|
*
|
||||||
|
* @returns A tuple of the plugin name and version.
|
||||||
|
*/
|
||||||
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 { plugins = [] } = await loadApiConfig();
|
||||||
const pluginPrefix = 'unraid-api-plugin-';
|
const pluginNames = new Set(
|
||||||
// All api plugins must be installed as dependencies of the unraid-api package
|
plugins.map((plugin) => {
|
||||||
|
const { name } = parsePackageArg(plugin);
|
||||||
|
return name;
|
||||||
|
})
|
||||||
|
);
|
||||||
const { peerDependencies } = getPackageJson();
|
const { peerDependencies } = getPackageJson();
|
||||||
|
// All api plugins must be installed as peer dependencies of the unraid-api package
|
||||||
if (!peerDependencies) {
|
if (!peerDependencies) {
|
||||||
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
|
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const plugins = Object.entries(peerDependencies).filter((entry): entry is [string, string] => {
|
const pluginTuples = Object.entries(peerDependencies).filter(
|
||||||
const [pkgName, version] = entry;
|
(entry): entry is [string, string] => {
|
||||||
return pkgName.startsWith(pluginPrefix) && typeof version === 'string';
|
const [pkgName, version] = entry;
|
||||||
});
|
return pluginNames.has(pkgName) && typeof version === 'string';
|
||||||
return plugins;
|
}
|
||||||
|
);
|
||||||
|
return pluginTuples;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,3 +286,25 @@ export const convertWebGuiPathToAssetPath = (webGuiPath: string): string => {
|
|||||||
const assetPath = webGuiPath.replace('/usr/local/emhttp/', '/');
|
const assetPath = webGuiPath.replace('/usr/local/emhttp/', '/');
|
||||||
return assetPath;
|
return assetPath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an npm package argument into a name and version.
|
||||||
|
*
|
||||||
|
* @param packageArg - The package argument to parse.
|
||||||
|
* @returns The name and version of the package.
|
||||||
|
*/
|
||||||
|
export function 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,4 +21,9 @@ export class ApiConfig {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
ssoSubIds!: string[];
|
ssoSubIds!: string[];
|
||||||
|
|
||||||
|
@Field(() => [String])
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
plugins!: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,9 +228,6 @@ for txz_file in /boot/config/plugins/dynamix.my.servers/dynamix.unraid.net-*.txz
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Clean up any old node_modules archives (on the boot drive) that don't match our current version
|
|
||||||
/etc/rc.d/rc.unraid-api cleanup-dependencies
|
|
||||||
|
|
||||||
# Remove existing node_modules directory
|
# Remove existing node_modules directory
|
||||||
echo "Cleaning up existing node_modules directory..."
|
echo "Cleaning up existing node_modules directory..."
|
||||||
if [ -d "/usr/local/unraid-api/node_modules" ]; then
|
if [ -d "/usr/local/unraid-api/node_modules" ]; then
|
||||||
@@ -245,12 +242,19 @@ if [ $? -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Clean up any old node_modules archives (on the boot drive) that don't match our current version
|
||||||
|
#
|
||||||
|
# Must run after package installation because it provides an update api config file,
|
||||||
|
# which determines the current API version and vendor archive to keep.
|
||||||
|
/etc/rc.d/rc.unraid-api cleanup-dependencies
|
||||||
|
|
||||||
if [[ -n "$TAG" && "$TAG" != "" ]]; then
|
if [[ -n "$TAG" && "$TAG" != "" ]]; then
|
||||||
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
|
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
|
||||||
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
|
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting Unraid API service"
|
echo "Starting Unraid API service"
|
||||||
|
/etc/rc.d/rc.unraid-api plugins add unraid-api-plugin-connect -b --no-restart
|
||||||
/etc/rc.d/rc.unraid-api start
|
/etc/rc.d/rc.unraid-api start
|
||||||
|
|
||||||
echo "Unraid API service started"
|
echo "Unraid API service started"
|
||||||
|
|||||||
Reference in New Issue
Block a user