Skip to content

Commit 3dcbfbe

Browse files
authored
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 -->
1 parent 184b76d commit 3dcbfbe

File tree

16 files changed

+434
-217
lines changed

16 files changed

+434
-217
lines changed

api/dev/configs/api.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"https://test.com"
66
],
77
"sandbox": true,
8-
"ssoSubIds": []
8+
"ssoSubIds": [],
9+
"plugins": []
910
}

api/src/__test__/utils.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { csvStringToArray, formatDatetime } from '@app/utils.js';
3+
import { csvStringToArray, formatDatetime, parsePackageArg } from '@app/utils.js';
44

55
describe('formatDatetime', () => {
66
const testDate = new Date('2024-02-14T12:34:56');
@@ -103,3 +103,78 @@ describe('csvStringToArray', () => {
103103
expect(csvStringToArray(',one,')).toEqual(['one']);
104104
});
105105
});
106+
107+
describe('parsePackageArg', () => {
108+
it('parses simple package names without version', () => {
109+
expect(parsePackageArg('lodash')).toEqual({ name: 'lodash' });
110+
expect(parsePackageArg('express')).toEqual({ name: 'express' });
111+
expect(parsePackageArg('react')).toEqual({ name: 'react' });
112+
});
113+
114+
it('parses simple package names with version', () => {
115+
expect(parsePackageArg('[email protected]')).toEqual({ name: 'lodash', version: '4.17.21' });
116+
expect(parsePackageArg('[email protected]')).toEqual({ name: 'express', version: '4.18.2' });
117+
expect(parsePackageArg('[email protected]')).toEqual({ name: 'react', version: '18.2.0' });
118+
});
119+
120+
it('parses scoped package names without version', () => {
121+
expect(parsePackageArg('@types/node')).toEqual({ name: '@types/node' });
122+
expect(parsePackageArg('@angular/core')).toEqual({ name: '@angular/core' });
123+
expect(parsePackageArg('@nestjs/common')).toEqual({ name: '@nestjs/common' });
124+
});
125+
126+
it('parses scoped package names with version', () => {
127+
expect(parsePackageArg('@types/[email protected]')).toEqual({
128+
name: '@types/node',
129+
version: '18.15.0',
130+
});
131+
expect(parsePackageArg('@angular/[email protected]')).toEqual({
132+
name: '@angular/core',
133+
version: '15.2.0',
134+
});
135+
expect(parsePackageArg('@nestjs/[email protected]')).toEqual({
136+
name: '@nestjs/common',
137+
version: '9.3.12',
138+
});
139+
});
140+
141+
it('handles version ranges and tags', () => {
142+
expect(parsePackageArg('lodash@^4.17.0')).toEqual({ name: 'lodash', version: '^4.17.0' });
143+
expect(parsePackageArg('react@~18.2.0')).toEqual({ name: 'react', version: '~18.2.0' });
144+
expect(parsePackageArg('express@latest')).toEqual({ name: 'express', version: 'latest' });
145+
expect(parsePackageArg('vue@beta')).toEqual({ name: 'vue', version: 'beta' });
146+
expect(parsePackageArg('@types/node@next')).toEqual({ name: '@types/node', version: 'next' });
147+
});
148+
149+
it('handles multiple @ symbols correctly', () => {
150+
expect(parsePackageArg('[email protected]@extra')).toEqual({
151+
152+
version: 'extra',
153+
});
154+
expect(parsePackageArg('@scope/[email protected]@extra')).toEqual({
155+
name: '@scope/[email protected]',
156+
version: 'extra',
157+
});
158+
});
159+
160+
it('ignores versions that contain forward slashes', () => {
161+
expect(parsePackageArg('package@github:user/repo')).toEqual({
162+
name: 'package@github:user/repo',
163+
});
164+
expect(parsePackageArg('@scope/pkg@git+https://github.com/user/repo.git')).toEqual({
165+
name: '@scope/pkg@git+https://github.com/user/repo.git',
166+
});
167+
});
168+
169+
it('handles edge cases', () => {
170+
expect(parsePackageArg('@')).toEqual({ name: '@' });
171+
expect(parsePackageArg('@scope')).toEqual({ name: '@scope' });
172+
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
173+
expect(parsePackageArg('@scope/pkg@')).toEqual({ name: '@scope/pkg@' });
174+
});
175+
176+
it('handles empty version strings', () => {
177+
expect(parsePackageArg('package@')).toEqual({ name: 'package@' });
178+
expect(parsePackageArg('@scope/package@')).toEqual({ name: '@scope/package@' });
179+
});
180+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Injectable } from '@nestjs/common';
2+
import * as path from 'path';
3+
4+
import { execa } from 'execa';
5+
6+
import { fileExists } from '@app/core/utils/files/file-exists.js';
7+
import { getPackageJsonPath } from '@app/environment.js';
8+
9+
@Injectable()
10+
export class DependencyService {
11+
constructor() {}
12+
13+
/**
14+
* Executes an npm command.
15+
*
16+
* @param npmArgs - The arguments to pass to npm.
17+
* @returns The execa result of the npm command.
18+
*/
19+
async npm(...npmArgs: string[]) {
20+
return await execa('npm', [...npmArgs], {
21+
stdio: 'inherit',
22+
cwd: path.dirname(getPackageJsonPath()),
23+
});
24+
}
25+
26+
/**
27+
* Installs dependencies for the api using npm.
28+
*
29+
* @throws {Error} from execa if the npm install command fails.
30+
*/
31+
async npmInstall(): Promise<void> {
32+
await this.npm('install');
33+
}
34+
35+
/**
36+
* Rebuilds the vendored dependency archive for the api and stores it on the boot drive.
37+
* If the rc.unraid-api script is not found, an error is thrown.
38+
*
39+
* @throws {Error} from execa if the rc.unraid-api command fails.
40+
*/
41+
async rebuildVendorArchive(): Promise<void> {
42+
const rcUnraidApi = '/etc/rc.d/rc.unraid-api';
43+
if (!(await fileExists(rcUnraidApi))) {
44+
throw new Error('[rebuild-vendor-archive] rc.unraid-api not found; no action taken!');
45+
}
46+
await execa(rcUnraidApi, ['archive-dependencies'], { stdio: 'inherit' });
47+
}
48+
}

api/src/unraid-api/cli/cli.module.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Module } from '@nestjs/common';
22

3+
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
34
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
45
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
56
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
1011
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
1112
import { LogService } from '@app/unraid-api/cli/log.service.js';
1213
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
13-
import { PluginCommandModule } from '@app/unraid-api/cli/plugins/plugin.cli.module.js';
14+
import {
15+
InstallPluginCommand,
16+
ListPluginCommand,
17+
PluginCommand,
18+
RemovePluginCommand,
19+
} from '@app/unraid-api/cli/plugins/plugin.command.js';
1420
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
1521
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
1622
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';
3036
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
3137
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
3238

33-
// cli - plugin add/remove
34-
// plugin generator
35-
3639
const DEFAULT_COMMANDS = [
3740
ApiKeyCommand,
3841
ConfigCommand,
3942
DeveloperCommand,
4043
LogsCommand,
4144
ReportCommand,
45+
VersionCommand,
46+
// Lifecycle commands
47+
SwitchEnvCommand,
4248
RestartCommand,
4349
StartCommand,
4450
StatusCommand,
4551
StopCommand,
46-
SwitchEnvCommand,
47-
VersionCommand,
52+
// SSO commands
4853
SSOCommand,
4954
ValidateTokenCommand,
5055
AddSSOUserCommand,
5156
RemoveSSOUserCommand,
5257
ListSSOUserCommand,
58+
// Plugin commands
59+
PluginCommand,
60+
ListPluginCommand,
61+
InstallPluginCommand,
62+
RemovePluginCommand,
5363
] as const;
5464

5565
const DEFAULT_PROVIDERS = [
@@ -62,10 +72,11 @@ const DEFAULT_PROVIDERS = [
6272
PM2Service,
6373
ApiKeyService,
6474
SsoUserService,
75+
DependencyService,
6576
] as const;
6677

6778
@Module({
68-
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register(), PluginCommandModule],
79+
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register()],
6980
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
7081
})
7182
export class CliModule {}

api/src/unraid-api/cli/log.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export class LogService {
1919
return shouldLog;
2020
}
2121

22+
table(level: LogLevel, data: unknown, columns?: string[]) {
23+
if (this.shouldLog(level)) {
24+
console.table(data, columns);
25+
}
26+
}
27+
2228
log(...messages: unknown[]): void {
2329
if (this.shouldLog('info')) {
2430
this.logger.log(...messages);

api/src/unraid-api/cli/plugins/dependency.service.ts

Lines changed: 0 additions & 131 deletions
This file was deleted.

api/src/unraid-api/cli/plugins/plugin.cli.module.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.

0 commit comments

Comments
 (0)