From 2d82cd54e27aea96c20445535e1bd573cb1d2098 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Wed, 22 Jul 2026 23:57:35 -0400 Subject: [PATCH] fix plugin packaging and generated scaffold --- api/Dockerfile | 6 ++- .../unraid-api-plugin-generator/package.json | 5 ++- .../src/create-plugin.test.ts | 43 +++++++++++++++++++ .../src/create-plugin.ts | 21 ++++----- .../src/templates/config.persistence.ts | 25 ----------- .../src/templates/index.ts | 3 +- .../src/templates/plugin-name.resolver.ts | 3 +- .../tsconfig.build.json | 2 +- plugin/builder/build-txz.ts | 5 ++- plugin/builder/utils/vendor-archive.test.ts | 28 ++++++++++++ plugin/builder/utils/vendor-archive.ts | 37 ++++++++++++++++ plugin/package.json | 2 +- pnpm-lock.yaml | 16 +++++-- 13 files changed, 146 insertions(+), 50 deletions(-) create mode 100644 packages/unraid-api-plugin-generator/src/create-plugin.test.ts delete mode 100644 packages/unraid-api-plugin-generator/src/templates/config.persistence.ts create mode 100644 plugin/builder/utils/vendor-archive.test.ts create mode 100644 plugin/builder/utils/vendor-archive.ts diff --git a/api/Dockerfile b/api/Dockerfile index a3df71e925..e3a481019c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -21,6 +21,10 @@ WORKDIR /app ENV NODE_ENV=development ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" +# Prefer sharp's prebuilt libvips instead of probing a host/global libvips +# installation. This keeps dependency installation portable on builders whose +# libvips ABI is newer than the sharp binary expects. +ENV SHARP_IGNORE_GLOBAL_LIBVIPS=1 # Install pnpm RUN corepack enable && corepack prepare pnpm@8.15.4 --activate && npm i -g npm@latest @@ -42,4 +46,4 @@ ENV NODE_ENV=production COPY . . -CMD ["pnpm", "run", "build:release"] \ No newline at end of file +CMD ["pnpm", "run", "build:release"] diff --git a/packages/unraid-api-plugin-generator/package.json b/packages/unraid-api-plugin-generator/package.json index 87abf3dc05..9a2558b5ba 100644 --- a/packages/unraid-api-plugin-generator/package.json +++ b/packages/unraid-api-plugin-generator/package.json @@ -7,6 +7,7 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", + "test": "vitest run", "prepare": "npm run build" }, "dependencies": { @@ -27,8 +28,10 @@ "@types/inquirer": "9.0.9", "@types/node": "22.18.0", "@types/validate-npm-package-name": "4.0.2", + "graphql": "16.11.0", "class-transformer": "0.5.1", "class-validator": "0.15.1", - "typescript": "5.9.2" + "typescript": "5.9.2", + "vitest": "3.2.6" } } diff --git a/packages/unraid-api-plugin-generator/src/create-plugin.test.ts b/packages/unraid-api-plugin-generator/src/create-plugin.test.ts new file mode 100644 index 0000000000..4d4fc4c558 --- /dev/null +++ b/packages/unraid-api-plugin-generator/src/create-plugin.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPlugin } from "./create-plugin"; + +const generatedDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + generatedDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }), + ), + ); +}); + +describe("createPlugin", () => { + it("generates a self-contained scaffold with build and validation commands", async () => { + const targetDirectory = await mkdtemp(join(tmpdir(), "unraid-plugin-generator-")); + generatedDirectories.push(targetDirectory); + + const pluginDirectory = await createPlugin("example-plugin", targetDirectory); + const packageJson = JSON.parse( + await readFile(join(pluginDirectory, "package.json"), "utf8"), + ); + const tsconfig = JSON.parse( + await readFile(join(pluginDirectory, "tsconfig.json"), "utf8"), + ); + const indexSource = await readFile(join(pluginDirectory, "src", "index.ts"), "utf8"); + const generatedFiles = await Promise.all( + ["config.entity.ts", "index.ts", "example-plugin.resolver.ts"].map((file) => + readFile(join(pluginDirectory, "src", file), "utf8"), + ), + ); + + expect(packageJson.scripts.validate).toBe("npm run build && npm pack --dry-run"); + expect(packageJson.devDependencies.graphql).toBeDefined(); + expect(packageJson.peerDependencies.graphql).toBeDefined(); + expect(tsconfig.compilerOptions.skipLibCheck).toBe(true); + expect(indexSource).not.toContain("ConfigPersister"); + expect(generatedFiles.join("\n")).not.toContain("@unraid/shared"); + }); +}); diff --git a/packages/unraid-api-plugin-generator/src/create-plugin.ts b/packages/unraid-api-plugin-generator/src/create-plugin.ts index ad76d3d031..749f0fbb57 100644 --- a/packages/unraid-api-plugin-generator/src/create-plugin.ts +++ b/packages/unraid-api-plugin-generator/src/create-plugin.ts @@ -41,8 +41,8 @@ export async function createPlugin(pluginName: string, targetDir: string = proce type: "module", files: ["dist"], scripts: { - test: "echo \"Error: no test specified\" && exit 1", build: "tsc", + validate: "npm run build && npm pack --dry-run", prepare: "npm run build" }, keywords: [], @@ -53,29 +53,25 @@ export async function createPlugin(pluginName: string, targetDir: string = proce "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.11", "@nestjs/graphql": "^13.0.3", - "@types/ini": "^4.1.1", "@types/node": "^22.14.0", - "camelcase-keys": "^9.1.3", + "@types/ws": "^8.18.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", - "ini": "^5.0.0", - "nest-authz": "^2.14.0", + "graphql": "^16.11.0", + "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "typescript": "^5.8.2", - "zod": "^3.23.8" + "typescript": "^5.8.2" }, peerDependencies: { "@nestjs/common": "^11.0.11", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.11", "@nestjs/graphql": "^13.0.3", - "camelcase-keys": "^9.1.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", - "ini": "^5.0.0", - "nest-authz": "^2.14.0", - "rxjs": "^7.8.2", - "zod": "^3.23.8" + "graphql": "^16.11.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" } }; @@ -93,6 +89,7 @@ export async function createPlugin(pluginName: string, targetDir: string = proce emitDecoratorMetadata: true, esModuleInterop: true, strict: true, + skipLibCheck: true, outDir: "dist", rootDir: "src" }, diff --git a/packages/unraid-api-plugin-generator/src/templates/config.persistence.ts b/packages/unraid-api-plugin-generator/src/templates/config.persistence.ts deleted file mode 100644 index fc747301d5..0000000000 --- a/packages/unraid-api-plugin-generator/src/templates/config.persistence.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigFilePersister } from "@unraid/shared/services/config-file.js"; // npm install @unraid/shared -import { PluginNameConfig } from "./config.entity.js"; -import { ConfigService } from "@nestjs/config"; - -@Injectable() -export class PluginNameConfigPersister extends ConfigFilePersister { - constructor(configService: ConfigService) { - super(configService); - } - - fileName(): string { - return "plugin-name.json"; // Use kebab-case for the filename - } - - configKey(): string { - return "plugin-name"; - } - - defaultConfig(): PluginNameConfig { - // Return the default configuration for your plugin - // This should match the structure defined in your config.entity.ts - return {} as PluginNameConfig; - } -} diff --git a/packages/unraid-api-plugin-generator/src/templates/index.ts b/packages/unraid-api-plugin-generator/src/templates/index.ts index c353f1e276..3eea3e165c 100644 --- a/packages/unraid-api-plugin-generator/src/templates/index.ts +++ b/packages/unraid-api-plugin-generator/src/templates/index.ts @@ -1,6 +1,5 @@ import { Module, Logger, Inject } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; -import { PluginNameConfigPersister } from "./config.persistence.js"; import { configFeature } from "./config.entity.js"; import { PluginNameResolver } from "./plugin-name.resolver.js"; @@ -8,7 +7,7 @@ export const adapter = "nestjs"; @Module({ imports: [ConfigModule.forFeature(configFeature)], - providers: [PluginNameResolver, PluginNameConfigPersister], + providers: [PluginNameResolver], }) class PluginNamePluginModule { logger = new Logger(PluginNamePluginModule.name); diff --git a/packages/unraid-api-plugin-generator/src/templates/plugin-name.resolver.ts b/packages/unraid-api-plugin-generator/src/templates/plugin-name.resolver.ts index d83833bc9e..6d4a38bf9a 100644 --- a/packages/unraid-api-plugin-generator/src/templates/plugin-name.resolver.ts +++ b/packages/unraid-api-plugin-generator/src/templates/plugin-name.resolver.ts @@ -17,7 +17,8 @@ export class PluginNameResolver { const currentStatus = this.configService.get("plugin-name.enabled", true); const newStatus = !currentStatus; this.configService.set("plugin-name.enabled", newStatus); - // The config persister will automatically save the changes. + // This starter mutation is intentionally process-local. Add a host-backed + // persistence service before treating this value as durable configuration. return newStatus; } } diff --git a/packages/unraid-api-plugin-generator/tsconfig.build.json b/packages/unraid-api-plugin-generator/tsconfig.build.json index 9ab0926573..517fd91c75 100644 --- a/packages/unraid-api-plugin-generator/tsconfig.build.json +++ b/packages/unraid-api-plugin-generator/tsconfig.build.json @@ -10,5 +10,5 @@ "rootDir": "src" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/templates/**/*"] + "exclude": ["node_modules", "dist", "src/templates/**/*", "src/**/*.test.ts"] } diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index ce1bafaa7f..7be6a7fc89 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -1,7 +1,7 @@ import { join } from "path"; import { $, cd } from "zx"; import { existsSync } from "node:fs"; -import { readdir, writeFile } from "node:fs/promises"; +import { readdir } from "node:fs/promises"; import { getTxzName, pluginName, startingDir } from "./utils/consts"; import { ensureNodeJs } from "./utils/nodejs-helper"; @@ -11,6 +11,7 @@ import { apiDir } from "./utils/paths"; import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store"; import { getAssetUrl } from "./utils/bucket-urls"; import { validateStandaloneManifest, getStandaloneManifestPath } from "./utils/manifest-validator"; +import { writeVendorArchiveInfo } from "./utils/vendor-archive"; // Check for manifest files in expected locations @@ -89,7 +90,7 @@ const storeVendorArchiveInfo = async (version: string, vendorUrl: string, vendor }); const configPath = join(configDir, "vendor_archive.json"); - await writeFile(configPath, JSON.stringify(configData, null, 2)); + await writeVendorArchiveInfo(configPath, configData); console.log(`Vendor archive information stored in ${configPath}`); console.log(`API Version: ${version}`); diff --git a/plugin/builder/utils/vendor-archive.test.ts b/plugin/builder/utils/vendor-archive.test.ts new file mode 100644 index 0000000000..5c4161072a --- /dev/null +++ b/plugin/builder/utils/vendor-archive.test.ts @@ -0,0 +1,28 @@ +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { writeVendorArchiveInfo } from "./vendor-archive"; + +describe("writeVendorArchiveInfo", () => { + it("keeps generated content and archive metadata deterministic", async () => { + const directory = await mkdtemp(join(tmpdir(), "vendor-archive-")); + const configPath = join(directory, "config", "vendor_archive.json"); + const config = { + vendor_store_path: "/boot/config/plugins/dynamix.my.servers/vendor-4.36.0.tar.zst", + api_version: "4.36.0", + }; + + await writeVendorArchiveInfo(configPath, config); + const firstContents = await readFile(configPath); + const firstStat = await stat(configPath); + + await writeVendorArchiveInfo(configPath, config); + const secondContents = await readFile(configPath); + const secondStat = await stat(configPath); + + expect(secondContents).toEqual(firstContents); + expect(firstStat.mtimeMs).toBe(0); + expect(secondStat.mtimeMs).toBe(0); + }); +}); diff --git a/plugin/builder/utils/vendor-archive.ts b/plugin/builder/utils/vendor-archive.ts new file mode 100644 index 0000000000..1c62f833e3 --- /dev/null +++ b/plugin/builder/utils/vendor-archive.ts @@ -0,0 +1,37 @@ +import { mkdir, readFile, utimes, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export interface VendorArchiveInfo { + vendor_store_path: string; + api_version: string; +} + +// vendor_archive.json is generated during every TXZ build. A wall-clock mtime +// on that one file made otherwise unchanged package trees produce different +// tar streams and hashes. Epoch is valid for tar and gives every builder the +// same metadata without depending on checkout or invocation time. +const DETERMINISTIC_MTIME = new Date(0); + +export async function writeVendorArchiveInfo( + configPath: string, + configData: VendorArchiveInfo, +): Promise { + const contents = `${JSON.stringify(configData, null, 2)}\n`; + + await mkdir(dirname(configPath), { recursive: true }); + + let currentContents: string | undefined; + try { + currentContents = await readFile(configPath, "utf8"); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + throw error; + } + } + + if (currentContents !== contents) { + await writeFile(configPath, contents, "utf8"); + } + + await utimes(configPath, DETERMINISTIC_MTIME, DETERMINISTIC_MTIME); +} diff --git a/plugin/package.json b/plugin/package.json index 7c801e6a86..5d0b923b41 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -27,7 +27,7 @@ "build:watch": "./scripts/dc.sh pnpm run build:watcher", "docker:build": "docker compose build", "docker:run": "SKIP_HOST_BUILD=true ./scripts/dc.sh bash -c 'pnpm build && exec bash'", - "predocker:build-and-run": "pnpm install && pnpm --filter @unraid/ui run build:wc && pnpm --filter @unraid/web run build && pnpm --filter @unraid/api run build:release", + "predocker:build-and-run": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install && pnpm --filter @unraid/ui run build:wc && pnpm --filter @unraid/web run build && pnpm --filter @unraid/api run build:release", "docker:build-and-run": "pnpm run docker:build && pnpm run docker:run", "// Environment management": "", "env:init": "cp .env.example .env", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55989c4b68..ea27da0b87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,9 +726,15 @@ importers: class-validator: specifier: 0.15.1 version: 0.15.1 + graphql: + specifier: 16.11.0 + version: 16.11.0 typescript: specifier: 5.9.2 version: 5.9.2 + vitest: + specifier: 3.2.6 + version: 3.2.6(@types/node@22.18.0)(@vitest/ui@3.2.6)(happy-dom@20.10.3)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.9.0) packages/unraid-api-plugin-health: devDependencies: @@ -8929,6 +8935,7 @@ packages: lucide-vue-next@0.542.0: resolution: {integrity: sha512-cJfyhFoneDgYTouHwUJEutXaCW5EQuRrBsvfELudWnMiwfqvcEtpZTFZLdZ5Nrqow+znzn+Iyhu3KeYIfa3mEg==} + deprecated: Package deprecated. Please use @lucide/vue instead. peerDependencies: vue: '>=3.0.1' @@ -11115,6 +11122,7 @@ packages: tsconfck@3.1.5: resolution: {integrity: sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -11719,8 +11727,8 @@ packages: vue-component-type-helpers@3.3.5: resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==} - vue-component-type-helpers@3.3.7: - resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==} + vue-component-type-helpers@3.3.8: + resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -15313,7 +15321,7 @@ snapshots: storybook: 9.1.20(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.3.5(@types/node@22.18.0)(jiti@2.7.0)(lightningcss@1.32.0)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.9.0)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.3.7 + vue-component-type-helpers: 3.3.8 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -23584,7 +23592,7 @@ snapshots: vue-component-type-helpers@3.3.5: {} - vue-component-type-helpers@3.3.7: {} + vue-component-type-helpers@3.3.8: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: