diff --git a/api/docs/developer/api-plugins.md b/api/docs/developer/api-plugins.md new file mode 100644 index 000000000..c0a7a5278 --- /dev/null +++ b/api/docs/developer/api-plugins.md @@ -0,0 +1,31 @@ +# Working with API plugins + +Under the hood, API plugins (i.e. plugins to the `@unraid/api` project) are represented +as npm `peerDependencies`. This is npm's intended package plugin mechanism, and given that +peer dependencies are installed by default as of npm v7, it supports bi-directional plugin functionality, +where the API provides dependencies for the plugin while the plugin provides functionality to the API. + +## Private Workspace plugins + +### Adding a local workspace package as an API plugin + +The challenge with local workspace plugins is that they aren't available via npm during production. +To solve this, we vendor them inside `dist/plugins`. To prevent the build from breaking, however, +you should mark the workspace dependency as optional. For example: + +```json +{ + "peerDependencies": { + "unraid-api-plugin-connect": "workspace:*" + }, + "peerDependenciesMeta": { + "unraid-api-plugin-connect": { + "optional": true + } + }, +} +``` + +By marking the workspace dependency "optional", npm will not attempt to install it. +Thus, even though the "workspace:*" identifier will be invalid during build-time and run-time, +it will not cause problems. diff --git a/api/package.json b/api/package.json index b8921b058..ee0c80678 100644 --- a/api/package.json +++ b/api/package.json @@ -20,7 +20,7 @@ "command:raw": "./dist/cli.js", "// Build and Deploy": "", "build": "vite build --mode=production", - "postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js", + "postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js && node scripts/copy-plugins.js", "build:watch": "nodemon --watch src --ext ts,js,json --exec 'tsx ./scripts/build.ts'", "build:docker": "./scripts/dc.sh run --rm builder", "build:release": "tsx ./scripts/build.ts", @@ -136,6 +136,14 @@ "zen-observable-ts": "^1.1.0", "zod": "^3.23.8" }, + "peerDependencies": { + "unraid-api-plugin-connect": "workspace:*" + }, + "peerDependenciesMeta": { + "unraid-api-plugin-connect": { + "optional": true + } + }, "devDependencies": { "@eslint/js": "^9.21.0", "@graphql-codegen/add": "^5.0.3", diff --git a/api/scripts/build.ts b/api/scripts/build.ts index d19c13692..0d5332234 100755 --- a/api/scripts/build.ts +++ b/api/scripts/build.ts @@ -1,11 +1,17 @@ #!/usr/bin/env zx -import { mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import { exit } from 'process'; +import type { PackageJson } from 'type-fest'; import { $, cd } from 'zx'; import { getDeploymentVersion } from './get-deployment-version.js'; +type ApiPackageJson = PackageJson & { + version: string; + peerDependencies: Record; +}; + try { // Create release and pack directories await mkdir('./deploy/release', { recursive: true }); @@ -19,13 +25,12 @@ try { // Get package details const packageJson = await readFile('./package.json', 'utf-8'); - const parsedPackageJson = JSON.parse(packageJson); - + const parsedPackageJson = JSON.parse(packageJson) as ApiPackageJson; const deploymentVersion = await getDeploymentVersion(process.env, parsedPackageJson.version); // Update the package.json version to the deployment version parsedPackageJson.version = deploymentVersion; - // omit dev dependencies from release build + // omit dev dependencies from vendored dependencies in release build parsedPackageJson.devDependencies = {}; // Create a temporary directory for packaging @@ -42,7 +47,6 @@ try { $.verbose = true; await $`npm install --omit=dev`; - // Now write the package.json back to the pack directory await writeFile('package.json', JSON.stringify(parsedPackageJson, null, 4)); const sudoCheck = await $`command -v sudo`.nothrow(); diff --git a/api/scripts/copy-plugins.js b/api/scripts/copy-plugins.js new file mode 100644 index 000000000..e2a5abbff --- /dev/null +++ b/api/scripts/copy-plugins.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +/** + * This AI-generated script copies workspace plugin dist folders to the dist/plugins directory + * to ensure they're available for dynamic imports in production. + */ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Get the package.json to find workspace dependencies +const packageJsonPath = path.resolve(__dirname, '../package.json'); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + +// Create the plugins directory if it doesn't exist +const pluginsDir = path.resolve(__dirname, '../dist/plugins'); +if (!fs.existsSync(pluginsDir)) { + fs.mkdirSync(pluginsDir, { recursive: true }); +} + +// Find all workspace plugins +const pluginPrefix = 'unraid-api-plugin-'; +const workspacePlugins = Object.keys(packageJson.peerDependencies || {}).filter((pkgName) => + pkgName.startsWith(pluginPrefix) +); + +// Copy each plugin's dist folder to the plugins directory +for (const pkgName of workspacePlugins) { + const pluginPath = path.resolve(__dirname, `../../packages/${pkgName}`); + const pluginDistPath = path.resolve(pluginPath, 'dist'); + const targetPath = path.resolve(pluginsDir, pkgName); + + console.log(`Building ${pkgName}...`); + try { + execSync('pnpm build', { + cwd: pluginPath, + stdio: 'inherit', + }); + console.log(`Successfully built ${pkgName}`); + } catch (error) { + console.error(`Failed to build ${pkgName}:`, error.message); + process.exit(1); + } + + if (!fs.existsSync(pluginDistPath)) { + console.warn(`Plugin ${pkgName} dist folder not found at ${pluginDistPath}`); + process.exit(1); + } + console.log(`Copying ${pkgName} dist folder to ${targetPath}`); + fs.mkdirSync(targetPath, { recursive: true }); + fs.cpSync(pluginDistPath, targetPath, { recursive: true }); + console.log(`Successfully copied ${pkgName} dist folder`); +} + +console.log('Plugin dist folders copied successfully'); diff --git a/api/scripts/deploy-dev.sh b/api/scripts/deploy-dev.sh index a549269b1..a8ab7aa9e 100755 --- a/api/scripts/deploy-dev.sh +++ b/api/scripts/deploy-dev.sh @@ -29,7 +29,7 @@ fi destination_directory="/usr/local/unraid-api" # Replace the value inside the rsync command with the user's input -rsync_command="rsync -avz --progress --stats -e ssh \"$source_directory\" \"root@${server_name}:$destination_directory\"" +rsync_command="rsync -avz --delete --progress --stats -e ssh \"$source_directory\" \"root@${server_name}:$destination_directory\"" echo "Executing the following command:" echo "$rsync_command" diff --git a/api/src/unraid-api/plugin/plugin.module.ts b/api/src/unraid-api/plugin/plugin.module.ts index 00ce4c936..238dd0aae 100644 --- a/api/src/unraid-api/plugin/plugin.module.ts +++ b/api/src/unraid-api/plugin/plugin.module.ts @@ -37,7 +37,7 @@ export class PluginCliModule { .map((plugin) => plugin.CliModule!); const cliList = cliModules.map((plugin) => plugin.name).join(', '); - PluginCliModule.logger.log(`Found ${cliModules.length} CLI plugins: ${cliList}`); + PluginCliModule.logger.debug(`Found ${cliModules.length} CLI plugins: ${cliList}`); return { module: PluginCliModule, diff --git a/api/src/unraid-api/plugin/plugin.service.ts b/api/src/unraid-api/plugin/plugin.service.ts index 1d4ce53e4..2180581f5 100644 --- a/api/src/unraid-api/plugin/plugin.service.ts +++ b/api/src/unraid-api/plugin/plugin.service.ts @@ -48,7 +48,21 @@ export class PluginService { const pluginPackages = await PluginService.listPlugins(); const plugins = await batchProcess(pluginPackages, async ([pkgName]) => { try { - const plugin = await import(/* @vite-ignore */ pkgName); + const possibleImportSources = [ + pkgName, + /**---------------------------------------------- + * Importing private workspace plugins + * + * Private workspace packages are not available in production, + * so we bundle and copy them to a plugins folder instead. + * + * See scripts/copy-plugins.js for more details. + *---------------------------------------------**/ + `../plugins/${pkgName}/index.js`, + ]; + const plugin = await Promise.any( + possibleImportSources.map((source) => import(/* @vite-ignore */ source)) + ); return apiNestPluginSchema.parse(plugin); } catch (error) { PluginService.logger.error(`Plugin from ${pkgName} is invalid`, error); @@ -59,6 +73,7 @@ export class PluginService { if (plugins.errorOccured) { PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`); } + PluginService.logger.log(`Loaded ${plugins.data.length} plugins.`); return plugins.data; } @@ -66,12 +81,12 @@ export class PluginService { /** 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 - const { dependencies } = getPackageJson(); - if (!dependencies) { - PluginService.logger.warn('Unraid-API dependencies not found; skipping plugins.'); + const { peerDependencies } = getPackageJson(); + if (!peerDependencies) { + PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.'); return []; } - const plugins = Object.entries(dependencies).filter((entry): entry is [string, string] => { + const plugins = Object.entries(peerDependencies).filter((entry): entry is [string, string] => { const [pkgName, version] = entry; return pkgName.startsWith(pluginPrefix) && typeof version === 'string'; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 937a98a21..4624b1c0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: systeminformation: specifier: ^5.25.11 version: 5.25.11 + unraid-api-plugin-connect: + specifier: workspace:* + version: link:../packages/unraid-api-plugin-connect uuid: specifier: ^11.0.2 version: 11.1.0 @@ -12060,7 +12063,7 @@ snapshots: '@babel/traverse': 7.26.10 '@babel/types': 7.26.10 convert-source-map: 2.0.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12431,7 +12434,7 @@ snapshots: '@babel/parser': 7.27.0 '@babel/template': 7.26.9 '@babel/types': 7.27.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -12443,7 +12446,7 @@ snapshots: '@babel/parser': 7.26.8 '@babel/template': 7.26.8 '@babel/types': 7.26.8 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -12761,7 +12764,7 @@ snapshots: '@eslint/config-array@0.19.2': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -12798,7 +12801,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) espree: 10.3.0 globals: 14.0.0 ignore: 5.3.2 @@ -13397,12 +13400,12 @@ snapshots: '@types/js-yaml': 4.0.9 '@whatwg-node/fetch': 0.10.3 chalk: 4.1.2 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) dotenv: 16.4.7 graphql: 16.10.0 graphql-request: 6.1.0(graphql@16.10.0) http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@9.4.0) jose: 5.10.0 js-yaml: 4.1.0 lodash: 4.17.21 @@ -13712,7 +13715,7 @@ snapshots: dependencies: consola: 3.4.2 detect-libc: 2.0.3 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@9.4.0) node-fetch: 2.7.0 nopt: 8.1.0 semver: 7.7.1 @@ -14502,7 +14505,7 @@ snapshots: '@pm2/pm2-version-check@1.0.4': dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -15403,7 +15406,7 @@ snapshots: '@typescript-eslint/types': 8.28.0 '@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.28.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) eslint: 9.23.0(jiti@2.4.2) typescript: 5.8.2 transitivePeerDependencies: @@ -15423,7 +15426,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2) '@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2) - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) eslint: 9.23.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.8.2) typescript: 5.8.2 @@ -15451,7 +15454,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.28.0 '@typescript-eslint/visitor-keys': 8.28.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -15623,7 +15626,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -15759,7 +15762,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.12 tinyrainbow: 2.0.0 - vitest: 3.0.9(@types/node@22.13.13)(@vitest/ui@3.0.9)(happy-dom@17.4.4)(jiti@2.4.2)(jsdom@26.0.0)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) + vitest: 3.0.9(@types/node@22.14.0)(@vitest/ui@3.0.9)(happy-dom@17.4.4)(jiti@2.4.2)(jsdom@26.0.0)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) '@vitest/utils@2.0.5': dependencies: @@ -17722,7 +17725,7 @@ snapshots: docker-modem@5.0.6: dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) readable-stream: 3.6.2 split-ca: 1.0.1 ssh2: 1.16.0 @@ -18378,7 +18381,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) escape-string-regexp: 4.0.0 eslint-scope: 8.3.0 eslint-visitor-keys: 4.2.0 @@ -18922,7 +18925,7 @@ snapshots: dependencies: basic-ftp: 5.0.5 data-uri-to-buffer: 6.0.2 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -19389,7 +19392,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -19433,13 +19436,6 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.3 - debug: 4.4.0(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6(supports-color@9.4.0): dependencies: agent-base: 7.1.3 @@ -19871,7 +19867,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -19958,7 +19954,7 @@ snapshots: form-data: 4.0.2 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@9.4.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.16 parse5: 7.2.1 @@ -21212,10 +21208,10 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.3 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) get-uri: 6.0.4 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@9.4.0) pac-resolver: 7.0.1 socks-proxy-agent: 8.0.5 transitivePeerDependencies: @@ -21479,7 +21475,7 @@ snapshots: pm2-axon-rpc@0.7.1: dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -21487,7 +21483,7 @@ snapshots: dependencies: amp: 0.3.1 amp-message: 0.1.2 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) escape-string-regexp: 4.0.0 transitivePeerDependencies: - supports-color @@ -21504,7 +21500,7 @@ snapshots: pm2-sysmonit@1.2.8: dependencies: async: 3.2.6 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) pidusage: 2.0.21 systeminformation: 5.25.11 tx2: 1.0.5 @@ -21526,7 +21522,7 @@ snapshots: commander: 2.15.1 croner: 4.1.97 dayjs: 1.11.13 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) enquirer: 2.3.6 eventemitter2: 5.0.1 fclone: 1.0.11 @@ -21880,9 +21876,9 @@ snapshots: proxy-agent@6.4.0: dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@9.4.0) lru-cache: 7.18.3 pac-proxy-agent: 7.1.0 proxy-from-env: 1.1.0 @@ -22269,7 +22265,7 @@ snapshots: require-in-the-middle@5.2.0: dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) module-details-from-path: 1.0.3 resolve: 1.22.10 transitivePeerDependencies: @@ -22736,7 +22732,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) socks: 2.8.4 transitivePeerDependencies: - supports-color @@ -22992,7 +22988,7 @@ snapshots: stylus@0.57.0: dependencies: css: 3.0.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) glob: 7.2.3 safer-buffer: 2.1.2 sax: 1.2.4 @@ -23725,7 +23721,7 @@ snapshots: vite-node@3.0.9(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0): dependencies: cac: 6.7.14 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) es-module-lexer: 1.6.0 pathe: 2.0.3 vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) @@ -23858,7 +23854,7 @@ snapshots: dependencies: '@rollup/pluginutils': 4.2.1 chalk: 4.1.2 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) vite: 6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0) optionalDependencies: '@swc/core': 1.11.13(@swc/helpers@0.5.15) @@ -23921,7 +23917,7 @@ snapshots: vite-tsconfig-paths@5.1.4(typescript@5.8.2)(vite@6.2.3(@types/node@22.13.13)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0)): dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.2) optionalDependencies: @@ -23996,7 +23992,7 @@ snapshots: '@vitest/spy': 3.0.9 '@vitest/utils': 3.0.9 chai: 5.2.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0(supports-color@9.4.0) expect-type: 1.2.0 magic-string: 0.30.17 pathe: 2.0.3