Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
5d7da2e
refactor package.json retrieval
pujitm Mar 19, 2025
00cd83e
port over plugin interfaces from #1218
pujitm Mar 19, 2025
5a4cc5d
port over plugin plumbing from pr #1218
pujitm Mar 19, 2025
9d6800a
add helper to get package.json deps
pujitm Mar 19, 2025
9b0ec5f
load plugins from npm dependencies
pujitm Mar 19, 2025
00b51c7
refactor getPackageJson for consistent error logs
pujitm Mar 19, 2025
8d18ede
fix types by moving error to `getPackageJson()`
pujitm Mar 19, 2025
29f4e11
simplify plugin schema, improve logging around plugin validation
pujitm Mar 19, 2025
a9b8c11
fix interface types, test plugin install on-server
pujitm Mar 21, 2025
bc3916a
fix graphqlResolvers plugin interface
pujitm Mar 21, 2025
ffa5efa
fix lint
pujitm Mar 24, 2025
b72fbb7
persist pnpm store to flash drive
pujitm Mar 24, 2025
d566d02
fix pnpm lockfile
pujitm Mar 24, 2025
65ccbca
add padding to 'do not close yet' messages
pujitm Mar 24, 2025
ad64c1c
attempt to build & upload versioned pnpm store
pujitm Mar 24, 2025
d7c5ea3
fix txz tar compression
pujitm Mar 25, 2025
b62b450
wip: plugin scripts
pujitm Mar 25, 2025
011d2b2
pipe pnpm store archive into plg file
pujitm Mar 25, 2025
d9d1db5
implement restoring from VENDOR_ARCHIVE in plg file
pujitm Mar 25, 2025
e11787a
fix pnpm store download
pujitm Mar 25, 2025
ba3000e
replace pnpm store workflow with build-api step
pujitm Mar 25, 2025
8328a30
fix decompression algo in rc.unraid-api restore_pnpm_store
pujitm Mar 25, 2025
ae3f145
rm pnpm store upon uninstall
pujitm Mar 26, 2025
652ba0b
fix pnpm store permissions on disk
pujitm Mar 26, 2025
efa8604
rm abstract class for plugin definition
pujitm Mar 26, 2025
ed48457
make loadPlugins() a "promise singleton"
pujitm Mar 26, 2025
ae497d3
rm unused class from example plugin package
pujitm Mar 26, 2025
487bfe9
document vendor store bundling
pujitm Mar 26, 2025
4a7456b
document rc.unraid-api additions
pujitm Mar 26, 2025
13fb820
rm rogue console.log
pujitm Mar 26, 2025
2000c80
warn on invalid typedefs from plugins
pujitm Mar 26, 2025
5c63f44
replace `this` in static funcs with class name
pujitm Mar 26, 2025
64bb111
rm `registerPlugin` function
pujitm Mar 26, 2025
dab921b
rm `onModuleInit` from plugin.module
pujitm Mar 26, 2025
4cbc487
improve changelog construction
pujitm Mar 26, 2025
3395ef2
improve gql typedef validation
pujitm Mar 26, 2025
d4cb481
replace sudo with a variable
pujitm Mar 26, 2025
c69ee79
add author and description to example plugin package.json
pujitm Mar 26, 2025
d88f8c7
load pnpm bianry, don't update doinst
pujitm Mar 26, 2025
0586650
re: mike's review
pujitm Mar 26, 2025
09e6777
omit untar & pnpm install for debugging
pujitm Mar 27, 2025
2dd88b8
rm doinst.sh
pujitm Mar 27, 2025
a3042bd
gitignore doinst.sh
pujitm Mar 27, 2025
8a83082
replace api's release-only npmrc with inline cmd arg
pujitm Mar 27, 2025
aec5ae1
doc new plg entities & remove version from downloaded node binary
pujitm Mar 27, 2025
d5d9d9c
add deprecation notice to node archive pruning command
pujitm Mar 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions api/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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`;
Expand Down
11 changes: 10 additions & 1 deletion api/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
69 changes: 45 additions & 24 deletions api/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,57 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const getPackageJsonVersion = () => {
try {
// Try different possible locations for package.json
const possibleLocations = ['../package.json', '../../package.json'];
import type { PackageJson, SetRequired } from 'type-fest';

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
}
/**
* 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 {
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'>;
};

/**
* 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 ?? getPackageJsonVersion() ?? new Error('API_VERSION not set');
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';
Expand Down
3 changes: 2 additions & 1 deletion api/src/graphql/schema/loadTypesDefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions api/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions api/src/unraid-api/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -46,6 +47,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
},
]),
UnraidFileModifierModule,
PluginModule.registerPlugins(),
],
controllers: [],
providers: [
Expand Down
100 changes: 73 additions & 27 deletions api/src/unraid-api/cli/cli.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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],
};
}
}
Loading
Loading