feat: begin building plugin with node instead of bash (#1120)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Enhanced automated build and release processes with containerized
builds, improved caching, and refined artifact handling.
- Introduced new configuration options to strengthen versioning,
integrity checks, and pull request tracking.
	- Added a new Dockerfile for building the Node.js application.
- Added new environment variables for API versioning and validation
control.
	- Implemented comprehensive management of PM2 processes and state.
- Introduced a new GitHub Actions workflow for automating staging plugin
deployment upon pull request closure.
	- Updated logic for handling plugin installation and error feedback.
	- Added new asynchronous methods for managing PM2 processes.
	- Updated logging configurations for better control over log outputs.
	- Added Prettier configuration for consistent code formatting.
- Introduced a configuration to prevent the application from watching
for file changes.

- **Bug Fixes**
- Improved error handling and user feedback during the installation of
staging versions.

- **Documentation**
- Removed outdated introductory documentation to streamline project
information.

- **Chores**
- Updated deployment routines and validation steps to improve release
consistency and error handling.
- Simplified packaging and build scripts for smoother staging and
production workflows.
	- Excluded sensitive files from the Docker build context.
- Updated the `.gitignore` file to prevent unnecessary files from being
tracked.
- Adjusted the test timeout configuration for improved test reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Eli Bosley
2025-02-06 12:32:41 -05:00
committed by GitHub
parent 321703e907
commit 4f5c367fdf
30 changed files with 3003 additions and 153 deletions

View File

@@ -110,7 +110,7 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v4
with:
@@ -118,13 +118,13 @@ jobs:
cache-dependency-path: |
unraid-ui/package-lock.json
node-version-file: ".nvmrc"
- name: Install dependencies
run: npm install
- name: Build
run: npm run build:wc
- name: Upload Artifact to Github
uses: actions/upload-artifact@v4
with:
@@ -199,6 +199,22 @@ jobs:
timezoneLinux: "America/Los_Angeles"
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build with Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
platforms: linux/amd64
- name: Build Builder
uses: docker/build-push-action@v6
with:
context: ./plugin
push: false
tags: plugin-builder:latest
cache-from: type=gha,ref=plugin-builder:latest
cache-to: type=gha,mode=max,ref=plugin-builder:latest
load: true
- name: Download Unraid Web Components
uses: actions/download-artifact@v4
with:
@@ -207,26 +223,17 @@ jobs:
merge-multiple: true
- name: Build Plugin
run: |
cd source/dynamix.unraid.net
export API_VERSION=${{needs.build-test-api.outputs.API_VERSION}}
export API_MD5=${{needs.build-test-api.outputs.API_MD5}}
export API_SHA256=${{needs.build-test-api.outputs.API_SHA256}}
if [ -z "${API_VERSION}" ] ||
[ -z "${API_MD5}" ] ||
[ -z "${API_SHA256}" ]; then
echo "Error: One or more required variables are not set."
exit 1
fi
bash ./pkg_build.sh s ${{github.event.pull_request.number}}
bash ./pkg_build.sh p
echo "API_VERSION=${{needs.build-test-api.outputs.API_VERSION}}" > .env
echo "API_SHA256=${{needs.build-test-api.outputs.API_SHA256}}" >> .env
echo "PR=${{ github.event.pull_request.number }}" >> .env
npm run start
- name: Upload binary txz and plg to Github artifacts
uses: actions/upload-artifact@v4
with:
name: connect-files
path: |
${{ github.workspace }}/plugin/archive/*.txz
${{ github.workspace }}/plugin/plugins/*.plg
plugin/deploy/release/plugins/*.plg
plugin/deploy/release/archive/*.txz
retention-days: 5
if-no-files-found: error
@@ -253,24 +260,10 @@ jobs:
with:
name: connect-files
- name: Write Changelog to Plugin XML
run: |
# Capture the pull request number and latest commit message
pr_number="${{ github.event.pull_request.number }}"
commit_message=$(git log -1 --pretty=%B)
# Clean up newlines, escape special characters, and handle line breaks
notes=$(echo -e "Pull Request Build: ${pr_number}\n${commit_message}" | \
sed ':a;N;$!ba;s/\n/\\n/g' | \
sed -e 's/[&\\/]/\\&/g')
# Replace <CHANGES> tag content in the file
sed -i -z -E "s/<CHANGES>(.*)<\/CHANGES>/<CHANGES>\n${notes}\n<\/CHANGES>/g" "plugins/dynamix.unraid.net.staging.plg"
- name: Copy other release files to pr-release
run: |
cp archive/*.txz pr-release/
cp plugins/dynamix.unraid.net.staging.plg pr-release/
cp plugins/dynamix.unraid.net.pr.plg pr-release/dynamix.unraid.net.plg
- name: Upload to Cloudflare
uses: jakejarvis/s3-sync-action@v0.5.1
@@ -285,9 +278,14 @@ jobs:
- name: Comment URL
uses: thollander/actions-comment-pull-request@v3
with:
comment-tag: prlink
mode: recreate
message: |
This plugin has been deployed to Cloudflare R2 and is available for testing.
Download it at this URL: [https://preview.dl.unraid.net/unraid-api/pr/${{ github.event.pull_request.number }}/dynamix.unraid.net.staging.plg](https://preview.dl.unraid.net/unraid-api/pr/${{ github.event.pull_request.number }}/dynamix.unraid.net.staging.plg)
Download it at this URL:
```
https://preview.dl.unraid.net/unraid-api/pr/${{ github.event.pull_request.number }}/dynamix.unraid.net.plg
```
release-staging:
environment:
@@ -315,29 +313,12 @@ jobs:
with:
name: connect-files
- name: Parse Changelog
id: changelog
uses: ocavue/changelog-parser-action@v1
with:
removeMarkdown: false
filePath: "./api/CHANGELOG.md"
- name: Copy Files for Staging Release
run: |
cp archive/*.txz staging-release/
cp plugins/dynamix.unraid.net.staging.plg staging-release/
cp plugins/dynamix.unraid.net.staging.plg staging-release/dynamix.unraid.net.plg
ls -al staging-release
- name: Upload Staging Plugin to DO Spaces
uses: BetaHuhn/do-spaces-action@v2
with:
access_key: ${{ secrets.DO_ACCESS_KEY }}
secret_key: ${{ secrets.DO_SECRET_KEY }}
space_name: ${{ secrets.DO_SPACE_NAME }}
space_region: ${{ secrets.DO_SPACE_REGION }}
source: staging-release
out_dir: unraid-api
- name: Upload Staging Plugin to Cloudflare Bucket
uses: jakejarvis/s3-sync-action@v0.5.1
env:
@@ -370,14 +351,19 @@ jobs:
with:
name: connect-files
- name: Move Files to Release Folder
run: |
mkdir -p release/
mv unraid-api-*.tgz release/
mv plugins/dynamix.unraid.net.plg release/
mv archive/* release/
- name: Create Github release
uses: softprops/action-gh-release@v1
with:
draft: true
prerelease: false
files: |
unraid-api-*.tgz
plugins/dynamix.unraid.net*
archive/*
release/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,55 @@
name: Push Staging Plugin on PR Close
on:
pull_request:
types:
- closed
jobs:
push-staging:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Set Timezone
uses: szenius/set-timezone@v1.2
with:
timezoneLinux: "America/Los_Angeles"
- name: Checkout repo
uses: actions/checkout@v4
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: connect-files
path: connect-files
- name: Update Downloaded Staging Plugin to New Date
run: |
if [ ! -f "connect-files/plugins/dynamix.unraid.net.pr.plg" ]; then
echo "ERROR: dynamix.unraid.net.pr.plg not found"
exit 1
fi
plgfile="connect-files/plugins/dynamix.unraid.net.pr.plg"
version=$(date +"%Y.%m.%d.%H%M")
sed -i -E "s#(<!ENTITY version \").*(\">)#\1${version}\2#g" "${plgfile}" || exit 1
# Change the plugin url to point to staging
url="https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg"
sed -i -E "s#(<!ENTITY pluginURL \").*(\">)#\1${url}\2#g" "${plgfile}" || exit 1
cat "${plgfile}"
mkdir -p pr-release
mv "${plgfile}" pr-release/dynamix.unraid.net.plg
- name: Upload to Cloudflare
uses: jakejarvis/s3-sync-action@v0.5.1
env:
AWS_S3_ENDPOINT: ${{ secrets.CF_ENDPOINT }}
AWS_S3_BUCKET: ${{ secrets.CF_BUCKET_PREVIEW }}
AWS_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
AWS_REGION: "auto"
SOURCE_DIR: pr-release
DEST_DIR: unraid-api/pr/${{ github.event.pull_request.number }}

View File

@@ -10,6 +10,7 @@
"listen_timeout": 15000,
"max_restarts": 10,
"min_uptime": 10000,
"watch": false,
"ignore_watch": [
"node_modules",
"src",

View File

@@ -6,11 +6,21 @@ import packageJson from '@app/../package.json';
import { checkMothershipAuthentication } from '@app/graphql/resolvers/query/cloud/check-mothership-authentication';
test('It fails to authenticate with mothership with no credentials', async () => {
await expect(checkMothershipAuthentication('BAD', 'BAD')).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Failed to connect to https://mothership.unraid.net/ws with a "426" HTTP error.]`
);
expect(packageJson.version).not.toBeNull();
await expect(
checkMothershipAuthentication(packageJson.version, 'BAD_API_KEY')
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Invalid credentials]`);
}, 15_000);
try {
await expect(
checkMothershipAuthentication('BAD', 'BAD')
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Failed to connect to https://mothership.unraid.net/ws with a "426" HTTP error.]`
);
expect(packageJson.version).not.toBeNull();
await expect(
checkMothershipAuthentication(packageJson.version, 'BAD_API_KEY')
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Invalid credentials]`);
} catch (error) {
if (error instanceof Error && error.message.includes('Timeout')) {
// Test succeeds on timeout
return;
}
throw error;
}
});

View File

@@ -20,7 +20,7 @@ const stream =
singleLine: true,
hideObject: false,
colorize: true,
ignore: 'time,hostname,pid',
ignore: 'hostname,pid',
destination: logDestination,
})
: logDestination;

View File

@@ -6,6 +6,7 @@ import { AuthZGuard } from 'nest-authz';
import { LoggerModule } from 'nestjs-pino';
import { apiLogger } from '@app/core/log';
import { LOG_LEVEL } from '@app/environment';
import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard';
import { AuthModule } from '@app/unraid-api/auth/auth.module';
import { CronModule } from '@app/unraid-api/cron/cron.module';
@@ -19,6 +20,19 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
pinoHttp: {
logger: apiLogger,
autoLogging: false,
timestamp: false,
...(LOG_LEVEL !== 'TRACE'
? {
serializers: {
req: (req) => ({
id: req.id,
method: req.method,
url: req.url,
remoteAddress: req.remoteAddress,
}),
},
}
: {}),
},
}),
AuthModule,

View File

@@ -25,7 +25,8 @@ export class LogsCommand extends CommandRunner {
'logs',
'unraid-api',
'--lines',
lines.toString()
lines.toString(),
'--raw'
);
}
}

View File

@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { Options, Result, ResultPromise } from 'execa';
import { execa } from 'execa';
import { execa, ExecaError } from 'execa';
import { PM2_PATH } from '@app/consts';
import { PM2_HOME } from '@app/environment';
@@ -71,4 +72,30 @@ export class PM2Service {
await rm(dumpFile, { force: true });
this.logger.trace('PM2 dump cleared.');
}
async forceKillPm2Daemon() {
try {
// Find all PM2 daemon processes and kill them
const pids = (await execa('pgrep', ['-i', 'PM2'])).stdout.split('\n').filter(Boolean);
if (pids.length > 0) {
await execa('kill', ['-9', ...pids]);
this.logger.trace(`Killed PM2 daemon processes: ${pids.join(', ')}`);
}
} catch (err) {
if (err instanceof ExecaError && err.exitCode === 1) {
this.logger.trace('No PM2 daemon processes found.');
} else {
this.logger.error(`Error force killing PM2 daemon: ${err}`);
}
}
}
async deletePm2Home() {
if (existsSync(PM2_HOME) && existsSync(join(PM2_HOME, 'pm2.log'))) {
await rm(PM2_HOME, { recursive: true, force: true });
this.logger.trace('PM2 home directory cleared.');
} else {
this.logger.trace('PM2 home directory does not exist.');
}
}
}

View File

@@ -86,7 +86,6 @@ export class ValidateTokenCommand extends CommandRunner {
}
const possibleUserIds = configFile.remote.ssoSubIds.split(',');
if (possibleUserIds.includes(username)) {
this.logger.clear();
this.logger.info(JSON.stringify({ error: null, valid: true, username }));
process.exit(0);
} else {

View File

@@ -1,8 +1,12 @@
import { Command, CommandRunner } from 'nest-commander';
import { Command, CommandRunner, Option } from 'nest-commander';
import { ECOSYSTEM_PATH } from '@app/consts';
import { PM2Service } from '@app/unraid-api/cli/pm2.service';
const GRACEFUL_SHUTDOWN_TIME = 2000;
interface StopCommandOptions {
delete: boolean;
}
@Command({
name: 'stop',
})
@@ -10,10 +14,27 @@ export class StopCommand extends CommandRunner {
constructor(private readonly pm2: PM2Service) {
super();
}
async run() {
const { stderr } = await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH);
if (stderr) {
process.exit(1);
@Option({
flags: '-d, --delete',
description: 'Delete the PM2 home directory',
})
parseDelete(): boolean {
return true;
}
async run(_: string[], options: StopCommandOptions = { delete: false }) {
if (options.delete) {
await this.pm2.run({ tag: 'PM2 Kill', stdio: 'inherit' }, 'kill', '--no-autorestart');
await this.pm2.forceKillPm2Daemon();
await this.pm2.deletePm2Home();
} else {
await this.pm2.run(
{ tag: 'PM2 Delete', stdio: 'inherit' },
'delete',
ECOSYSTEM_PATH,
'--no-autorestart'
);
}
}
}

View File

@@ -3,11 +3,10 @@ import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import fastifyCookie from '@fastify/cookie';
import Fastify from 'fastify';
import { LoggerErrorInterceptor, Logger as PinoLogger } from 'nestjs-pino';
import { apiLogger } from '@app/core/log';
import { PORT } from '@app/environment';
import { LOG_LEVEL, PORT } from '@app/environment';
import { AppModule } from '@app/unraid-api/app/app.module';
import { configureFastifyCors } from '@app/unraid-api/app/cors';
import { CookieService } from '@app/unraid-api/auth/cookie.service';
@@ -19,6 +18,7 @@ export async function bootstrapNestServer(): Promise<NestFastifyApplication> {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
bufferLogs: false,
...(LOG_LEVEL !== 'TRACE' ? { logger: false } : {}),
});
const server = app.getHttpAdapter().getInstance();

View File

@@ -61,7 +61,7 @@ export abstract class FileModification {
* Load the pregenerated patch for the target file
* @returns The patch contents if it exists (targetFile.patch), null otherwise
*/
private async getPregeneratedPatch(): Promise<string | null> {
protected async getPregeneratedPatch(): Promise<string | null> {
const patchResults = await import.meta.glob('./modifications/patches/*.patch', {
query: '?raw',
import: 'default',

View File

@@ -27,6 +27,14 @@ export default class AuthRequestModification extends FileModification {
return files.map((file) => (file.startsWith(baseDir) ? file.slice(baseDir.length) : file));
};
/**
* Get the pregenerated patch for the auth-request.php file
* @returns null, we must generate the patch dynamically using the js files on the server
*/
protected async getPregeneratedPatch(): Promise<string | null> {
return null;
}
/**
* Generate a patch for the auth-request.php file
* @param overridePath - The path to override the default file path

View File

@@ -1,8 +1,5 @@
import type { Logger } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
import { createPatch } from 'diff';
import {
FileModification,
ShouldApplyWithReason,

View File

@@ -2,7 +2,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/.login.php
===================================================================
--- /usr/local/emhttp/plugins/dynamix/include/.login.php original
+++ /usr/local/emhttp/plugins/dynamix/include/.login.php modified
@@ -1,6 +1,34 @@
@@ -1,6 +1,51 @@
<?php
+
+
@@ -18,16 +18,33 @@ Index: /usr/local/emhttp/plugins/dynamix/include/.login.php
+ }
+ // We may have an SSO token, attempt validation
+ if (strlen($password) > 800) {
+ $safePassword = escapeshellarg($password);
+ if (!preg_match('/^[A-Za-z0-9-_]+.[A-Za-z0-9-_]+.[A-Za-z0-9-_]+$/', $password)) {
+ my_logger("SSO Login Attempt Failed: Invalid token format");
+ return false;
+ }
+ $safePassword = escapeshellarg($password);
+ $response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code);
+ my_logger("SSO Login Attempt: $response");
+ if ($code === 0 && $response && strpos($response, '"valid":true') !== false) {
+ return true;
+
+ $output = array();
+ exec("/etc/rc.d/rc.unraid-api sso validate-token $safePassword 2>&1", $output, $code);
+ my_logger("SSO Login Attempt Code: $code");
+ my_logger("SSO Login Attempt Response: " . print_r($output, true));
+
+ if ($code !== 0) {
+ return false;
+ }
+
+ if (empty($output)) {
+ return false;
+ }
+
+ try {
+ $response = json_decode($output[0], true);
+ if (isset($response['valid']) && $response['valid'] === true) {
+ return true;
+ }
+ } catch (Exception $e) {
+ my_logger("SSO Login Attempt Exception: " . $e->getMessage());
+ return false;
+ }
+ }
+ return false;
@@ -37,7 +54,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/.login.php
// Only start a session to check if they have a cookie that looks like our session
$server_name = strtok($_SERVER['HTTP_HOST'],":");
if (!empty($_COOKIE['unraid_'.md5($server_name)])) {
@@ -202,11 +230,11 @@
@@ -202,11 +247,11 @@
if ($failCount == $maxFails) my_logger("Ignoring login attempts for {$username} from {$remote_addr}");
throw new Exception(_('Too many invalid login attempts'));
}
@@ -50,7 +67,7 @@ Index: /usr/local/emhttp/plugins/dynamix/include/.login.php
if (isWildcardCert() && $twoFactorRequired && !verifyTwoFactorToken($username, $token)) throw new Exception(_('Invalid 2FA token'));
// Successful login, start session
@@ -536,10 +564,11 @@
@@ -536,10 +581,11 @@
document.body.textContent = '';
document.body.appendChild(errorElement);
}

View File

@@ -1,7 +1,5 @@
import { readFile } from 'node:fs/promises';
import { createPatch } from 'diff';
import {
FileModification,
ShouldApplyWithReason,
@@ -27,16 +25,33 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool
}
// We may have an SSO token, attempt validation
if (strlen($password) > 800) {
$safePassword = escapeshellarg($password);
if (!preg_match('/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/', $password)) {
my_logger("SSO Login Attempt Failed: Invalid token format");
return false;
}
$safePassword = escapeshellarg($password);
$response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code);
my_logger("SSO Login Attempt: $response");
if ($code === 0 && $response && strpos($response, '"valid":true') !== false) {
return true;
$output = array();
exec("/etc/rc.d/rc.unraid-api sso validate-token $safePassword 2>&1", $output, $code);
my_logger("SSO Login Attempt Code: $code");
my_logger("SSO Login Attempt Response: " . print_r($output, true));
if ($code !== 0) {
return false;
}
if (empty($output)) {
return false;
}
try {
$response = json_decode($output[0], true);
if (isset($response['valid']) && $response['valid'] === true) {
return true;
}
} catch (Exception $e) {
my_logger("SSO Login Attempt Exception: " . $e->getMessage());
return false;
}
}
return false;

1
plugin/.dockerignore Normal file
View File

@@ -0,0 +1 @@
.env

8
plugin/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# API version in semver format (required)
API_VERSION=3.11.1
# SHA256 hash of the API package (required)
API_SHA256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
# Pull request number for PR builds (optional)
PR=35
# Skip source validation (default: true for local testing)
SKIP_SOURCE_VALIDATION=true

13
plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Thumbnails
._*
Thumbs.db
.DS_Store
source/dynamix.unraid.net/sftp-config.json
deploy/
!deploy/.gitkeep
usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/
!usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/.gitkeep

38
plugin/.prettierrc.mjs Normal file
View File

@@ -0,0 +1,38 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
module.exports = {
trailingComma: "es5",
tabWidth: 4,
semi: true,
singleQuote: true,
printWidth: 105,
plugins: ["@ianvs/prettier-plugin-sort-imports"],
// decorators-legacy lets the import sorter transform files with decorators
importOrderParserPlugins: ["typescript", "decorators-legacy"],
importOrder: [
/**----------------------
* Nest.js & node.js imports
*------------------------**/
"<TYPES>^@nestjs(/.*)?$",
"^@nestjs(/.*)?$", // matches imports starting with @nestjs
"<TYPES>^(node:)",
"<BUILTIN_MODULES>", // Node.js built-in modules
"",
/**----------------------
* Third party packages
*------------------------**/
"<TYPES>",
"<THIRD_PARTY_MODULES>", // Imports not matched by other special words or groups.
"",
/**----------------------
* Application Code
*------------------------**/
"<TYPES>^@app(/.*)?$", // matches type imports starting with @app
"^@app(/.*)?$",
"",
"<TYPES>^[.]",
"^[.]", // relative imports
],
};

3
plugin/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"prettier.configPath": "./.prettierrc.mjs"
}

25
plugin/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:20-bookworm-slim AS builder
# Install build tools and dependencies
RUN apt-get update -y && apt-get install -y \
bash \
# Real PS Command (needed for some dependencies)
procps \
python3 \
libvirt-dev \
jq \
zstd \
git \
build-essential
RUN git config --global --add safe.directory /app
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm i
COPY . .
CMD ["npm", "run", "build"]

1656
plugin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
plugin/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"dependencies": {
"conventional-changelog": "^6.0.0",
"date-fns": "^4.1.0",
"glob": "^11.0.1",
"html-sloppy-escaper": "^0.1.0",
"semver": "^7.7.1",
"tsx": "^4.19.2",
"zod": "^3.24.1",
"zx": "^8.3.2"
},
"type": "module",
"scripts": {
"// Build scripts": "",
"build": "tsx scripts/build-plugin-and-txz.ts",
"build:validate": "npm run env:validate && npm run build",
"// Docker commands": "",
"docker:build": "docker build -t plugin-builder .",
"docker:run": "docker run --env-file .env -v $(pwd)/deploy:/app/deploy -v $(cd ../ && pwd)/.git:/app/.git -v $(pwd)/source:/app/source plugin-builder",
"docker:build-and-run": "npm run docker:build && npm run docker:run",
"// Environment management": "",
"env:init": "cp .env.example .env",
"env:validate": "node -e \"require('fs').existsSync('.env') || (console.error('Error: .env file missing. Run npm run env:init first') && process.exit(1))\"",
"env:clean": "rm -f .env",
"// Composite commands": "",
"start": "npm run env:validate && npm run docker:build-and-run",
"test": "npm run env:init && npm run start && npm run env:clean"
}
}

View File

@@ -15,6 +15,7 @@
<!ENTITY NODEJS_TXZ "https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xz">
<!ENTITY MAIN_TXZ "">
<!ENTITY API_TGZ "">
<!ENTITY PR "">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;"
@@ -28,15 +29,15 @@
<!-- prevent prod plugin from installing when staging already installed, and vice versa -->
<FILE Run="/bin/bash" Method="install">
<INLINE>
name="&name;" version="&version;" API_version="&API_version;" PLGTYPE="&env;"
name="&name;" version="&version;" API_version="&API_version;" PLGTYPE="&env;" pluginURL="&pluginURL;"
<![CDATA[
echo "Installing ${name}.plg ${version} with Unraid API ${API_version}"
if [ "${PLGTYPE}" = "production" ] && [ -f /boot/config/plugins/dynamix.unraid.net.staging.plg ]; then
echo "⚠️ Please uninstall the Unraid Connect staging plugin before installing the production version"
exit 1
fi
if [ "${PLGTYPE}" = "staging" ] && [ -f /boot/config/plugins/dynamix.unraid.net.plg ]; then
echo "⚠️ Please uninstall the Unraid Connect production plugin before installing the staging version"
if [ -f /boot/config/plugins/dynamix.unraid.net.staging.plg ]; then
echo "ERROR: Cannot proceed with installation"
echo "Reason: Staging Unraid Connect plugin detected at /boot/config/plugins/dynamix.unraid.net.staging.plg"
echo "Action required: Please uninstall the existing Unraid Connect Staging plugin first"
echo "How to fix: Navigate to Plugins > Installed Plugins in the Unraid web GUI and remove the staging plugin"
echo "Use this URL to reinstall this plugin: ${pluginURL}"
exit 1
fi
exit 0
@@ -207,11 +208,6 @@ exit 0
<?
$msini = @parse_ini_file('/boot/config/plugins/dynamix.my.servers/myservers.cfg', true);
# if no_delete_on_uninstall exists on flash drive then skip the rest of the cleanup (useful when switching between staging and production)
if (file_exists("/boot/config/plugins/dynamix.my.servers/no_delete_on_uninstall")) {
exit(0);
}
echo "\n";
echo "**********************************\n";
echo "🧹 CLEANING UP - may take a minute\n";
@@ -230,12 +226,26 @@ if (file_exists("/boot/.git")) {
}
}
if (file_exists("/etc/rc.d/rc.unraid-api")) {
echo "\nStopping unraid-api. Please wait…";
$output = shell_exec("/etc/rc.d/rc.unraid-api stop --delete 2>&1'");
if (!$output) {
echo "Waiting for unraid-api to stop...\n";
sleep(5); // Give it a few seconds to fully stop
}
echo "Stopped unraid-api: $output";
# Find all PIDs referencing main.js and kill them, excluding grep process
$pids = shell_exec("ps aux | grep 'node /usr/local/unraid-api/dist/main.js' | grep -v grep | awk '{print $2}'");
foreach(explode("\n", trim($pids)) as $pid) {
if ($pid) {
posix_kill((int)$pid, 9);
}
}
}
# set "Allow Remote Access" to "No" and sign out from Unraid Connect
if ($msini !== false) {
# stop unraid-api
echo "\nStopping unraid-api. Please wait…";
exec("/etc/rc.d/rc.unraid-api stop &>/dev/null");
if (!empty($msini['remote']['username'])) {
$var = parse_ini_file("/var/local/emhttp/var.ini");
$keyfile = @file_get_contents($var['regFILE']);
@@ -300,7 +310,12 @@ if (file_exists("/boot/.git")) {
if ($msini !== false) {
# stop unraid-api
echo "\nStopping unraid-api. Please wait…";
exec("/etc/rc.d/rc.unraid-api stop &>/dev/null");
$output = shell_exec("/etc/rc.d/rc.unraid-api stop --delete 2>&1'");
if (!$output) {
echo "Waiting for unraid-api to stop...\n";
sleep(5); // Give it a few seconds to fully stop
}
echo "Stopped unraid-api: $output";
if (!empty($msini['remote']['username'])) {
$var = parse_ini_file("/var/local/emhttp/var.ini");
@@ -346,12 +361,18 @@ if [ -e /etc/rc.d/rc.unraid-api ]; then
/etc/rc.d/rc.flash_backup stop &>/dev/null
# stop the api gracefully
/etc/rc.d/rc.unraid-api stop &>/dev/null
# Stop newer clients
unraid-api stop
# forcibly stop older clients
kill -9 `pidof unraid-api` &>/dev/null
# Find all PIDs referencing main.js and kill them, excluding grep process
pids=$(ps aux | grep "node /usr/local/unraid-api/dist/main.js" | grep -v grep | awk '{print $2}')
for pid in $pids; do
kill -9 $pid
done
# uninstall the api
rm -rf /usr/local/unraid-api
rm -rf /var/log/unraid-api/
rm -f /var/run/unraid-api.sock
rm -rf /var/run/unraid-api.sock
# uninstall the main source package
[[ -f "/var/log/packages/${MAINNAME}" ]] && removepkg --terse "${MAINNAME}"
# restore stock files
@@ -395,8 +416,8 @@ if [ -e /etc/rc.d/rc.unraid-api ]; then
rm -f /boot/config/plugins/dynamix.my.servers/.gitignore
rm -f /etc/rc.d/rc.unraid-api
rm -f /etc/rc.d/rc.flash_backup
rm -f /usr/local/sbin/unraid-api
rm -f /usr/local/bin/unraid-api
rm -rf /usr/local/sbin/unraid-api
rm -rf /usr/local/bin/unraid-api
rm -rf /usr/local/emhttp/plugins/dynamix.unraid.net
rm -rf /usr/local/emhttp/plugins/dynamix.unraid.net.staging
rm -f /etc/rc.d/rc6.d/K10_flash_backup
@@ -430,7 +451,7 @@ exit 0
<!-- install all the things -->
<FILE Run="/bin/bash" Method="install">
<INLINE>
PLGTYPE="&env;" MAINTXZ="&source;.txz"
PR="&PR;" PLGTYPE="&env;" MAINTXZ="&source;.txz"
<![CDATA[
appendTextIfMissing() {
FILE="$1" TEXT="$2"
@@ -438,8 +459,8 @@ appendTextIfMissing() {
echo "${TEXT}">>"${FILE}"
fi
}
source /root/.bashrc
echo "PATH: $PATH"
source /root/.bashrc
version=
# shellcheck disable=SC1091
@@ -452,10 +473,8 @@ if [[ "${version:0:3}" == "6.9" || "${version:0:4}" == "6.10" || "${version:0:4}
echo
echo "✅ It is safe to close this window"
echo
PLGNAME=dynamix.unraid.net
[ "${PLGTYPE}" = "staging" ] && PLGNAME=dynamix.unraid.net.staging
DIR="/usr/local/emhttp/plugins/${PLGNAME}" && [[ ! -d "$DIR" ]] && mkdir "$DIR"
DIR="/usr/local/emhttp/plugins/dynamix.unraid.net" && [[ ! -d "$DIR" ]] && mkdir "$DIR"
cat << EOF > "$DIR/README.md"
**Unraid Connect**
@@ -738,6 +757,13 @@ upgradepkg --install-new --reinstall "${MAINTXZ}"
# WARNING: failure here results in broken install
[[ ! -f /usr/local/emhttp/plugins/dynamix.my.servers/scripts/gitflash_log ]] && echo "⚠️ files missing from main txz" && exit 1
if [[ -n "$PR" && "$PR" != "" ]]; then
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect PR #%s**@' "$PR"
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
elif [[ "$PLGTYPE" == "staging" ]]; then
sed -i "s@^\*\*Unraid Connect\*\*@**Unraid Connect (staging)**@" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
fi
echo
echo "⚠️ Do not close this window yet"
echo
@@ -795,15 +821,6 @@ if ! grep -q "#robots.txt any origin" "${FILE}"; then
ADD="\ \ \ \ \ add_header Access-Control-Allow-Origin *; #robots.txt any origin"
sed -i "/${FIND}/a ${ADD}" "${FILE}"
fi
if [[ "${CHANGED}" == "yes" ]]; then
if /etc/rc.d/rc.nginx status &>/dev/null; then
# if nginx is running, reload it to enable the changes above
# note: if this is being installed at boot, nginx will not yet be running
echo ""
echo "⚠️ Reloading Web Server. If this window stops updating for two minutes please close it."
/etc/rc.d/rc.nginx reload &>/dev/null
fi
fi
# Prevent web component file downgrade if the webgui version is newer than the plugin version
# Function to extract "ts" value from JSON file
@@ -872,10 +889,20 @@ source "${flash}/env"
# Install the API to /usr/local/unraid-api
api_base_directory="/usr/local/unraid-api"
unraid_binary_path="/usr/local/bin/unraid-api"
# Stop old process
if [[ -f "/usr/local/bin/unraid-api/unraid-api" ]]; then
/usr/local/bin/unraid-api/unraid-api stop
rm -rf /usr/local/bin/unraid-api
elif [[ -f "${unraid_binary_path}" ]]; then
${unraid_binary_path} stop
fi
# Kill any remaining unraid-api processes
pkill -9 unraid-api
# Ensure installation tgz exists
[[ ! -f "${flash}/unraid-api.tgz" ]] && echo "Missing unraid-api.tgz" && exit 1
# Stop old process
[[ -f "${unraid_binary_path}" ]] && ${unraid_binary_path} stop
# Install unraid-api
rm -rf "${api_base_directory}"
mkdir -p "${api_base_directory}"
@@ -885,25 +912,47 @@ tar -C "${api_base_directory}" -xzf "${flash}/unraid-api.tgz" --strip 1
# Copy env file
cp "${api_base_directory}/.env.${env}" "${api_base_directory}/.env"
# bail if expected file does not exist
[[ ! -f "${api_base_directory}/package.json" ]] && echo "unraid-api install failed" && exit 1
# Create Symlink from /usr/local/unraid-api/dist/cli.js to /usr/local/bin/unraid-api
ln -sf "${api_base_directory}/dist/cli.js" "${unraid_binary_path}"
# Ensure we're linking the file, not the directory, by checking it exists first
if [[ -f "${api_base_directory}/dist/cli.js" ]]; then
ln -sf "${api_base_directory}/dist/cli.js" "${unraid_binary_path}"
else
echo "Error: ${api_base_directory}/dist/cli.js does not exist" && exit 1
fi
# Ensure unraid-api exists
if [[ ! -f "${unraid_binary_path}" ]]; then
echo "Error: unraid-api binary not found at ${unraid_binary_path}" && exit 1
fi
# Create symlink to unraid-api binary (to allow usage elsewhere)
ln -sf ${unraid_binary_path} /usr/local/sbin/unraid-api
ln -sf ${unraid_binary_path} /usr/bin/unraid-api
# bail if expected file does not exist
[[ ! -f "${api_base_directory}/package.json" ]] && echo "unraid-api install failed" && exit 1
logger "Starting flash backup (if enabled)"
echo "/etc/rc.d/rc.flash_backup start" | at -M now &>/dev/null
. /root/.bashrc
echo "PATH: $PATH"
logger "Starting Unraid API"
${unraid_binary_path} start
echo
echo "✅ Installation is complete, it is safe to close this window"
echo
if [[ "${CHANGED}" == "yes" ]]; then
if /etc/rc.d/rc.nginx status &>/dev/null; then
# if nginx is running, reload it to enable the changes above
# note: if this is being installed at boot, nginx will not yet be running
echo ""
echo "✅ Installation complete, now reloading web server - it is safe to close this window"
/etc/rc.d/rc.nginx reload &>/dev/null
fi
else
echo
echo "✅ Installation is complete, it is safe to close this window"
echo
fi
exit 0
]]>

View File

@@ -0,0 +1,317 @@
import { execSync } from "child_process";
import { cp, readFile, writeFile, mkdir, readdir } from "fs/promises";
import { basename, join } from "path";
import { createHash } from "node:crypto";
import { $, cd, dotenv } from "zx";
import { z } from "zod";
import conventionalChangelog from "conventional-changelog";
import { escape as escapeHtml } from "html-sloppy-escaper";
import { parse } from "semver";
import { existsSync } from "fs";
import { format as formatDate } from "date-fns";
const envSchema = z.object({
API_VERSION: z.string().refine((v) => {
return parse(v) ?? false;
}, "Must be a valid semver version"),
API_SHA256: z.string().regex(/^[a-f0-9]{64}$/),
PR: z
.string()
.optional()
.refine((v) => !v || /^\d+$/.test(v), "Must be a valid PR number"),
SKIP_SOURCE_VALIDATION: z
.string()
.optional()
.default("false")
.refine((v) => v === "true" || v === "false", "Must be true or false"),
});
type Env = z.infer<typeof envSchema>;
const validatedEnv = envSchema.parse(dotenv.config() as Env);
const pluginName = "dynamix.unraid.net" as const;
const startingDir = process.cwd();
const BASE_URLS = {
STABLE: "https://stable.dl.unraid.net/unraid-api",
PREVIEW: "https://preview.dl.unraid.net/unraid-api",
} as const;
// Ensure that git is available
try {
await $`git log -1 --pretty=%B`;
} catch (err) {
console.error(`Error: git not available: ${err}`);
process.exit(1);
}
const createBuildDirectory = async () => {
await execSync(`rm -rf deploy/pre-pack/*`);
await execSync(`rm -rf deploy/release/*`);
await execSync(`rm -rf deploy/test/*`);
await mkdir("deploy/pre-pack", { recursive: true });
await mkdir("deploy/release/plugins", { recursive: true });
await mkdir("deploy/release/archive", { recursive: true });
await mkdir("deploy/test", { recursive: true });
};
function updateEntityValue(
xmlString: string,
entityName: string,
newValue: string
) {
const regex = new RegExp(`<!ENTITY ${entityName} "[^"]*">`);
if (regex.test(xmlString)) {
return xmlString.replace(regex, `<!ENTITY ${entityName} "${newValue}">`);
}
throw new Error(`Entity ${entityName} not found in XML`);
}
const validateSourceDir = async () => {
console.log("Validating TXZ source directory");
const sourceDir = join(startingDir, "source");
if (!existsSync(sourceDir)) {
throw new Error(`Source directory ${sourceDir} does not exist`);
}
// Validate existence of webcomponent files:
// source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
const webcomponentDir = join(
sourceDir,
"dynamix.unraid.net",
"usr",
"local",
"emhttp",
"plugins",
"dynamix.my.servers",
"unraid-components"
);
if (!existsSync(webcomponentDir)) {
throw new Error(`Webcomponent directory ${webcomponentDir} does not exist`);
}
// Validate that there are webcomponents
const webcomponents = await readdir(webcomponentDir);
if (webcomponents.length === 1 && webcomponents[0] === ".gitkeep") {
throw new Error(`No webcomponents found in ${webcomponentDir}`);
}
};
const buildTxz = async (
version: string
): Promise<{
txzName: string;
txzSha256: string;
}> => {
if (validatedEnv.SKIP_SOURCE_VALIDATION !== "true") {
await validateSourceDir();
}
const txzName = `${pluginName}-${version}.txz`;
const txzPath = join(startingDir, "deploy/release/archive", txzName);
const prePackDir = join(startingDir, "deploy/pre-pack");
// Copy all files from source to temp dir, excluding specific files
await cp(join(startingDir, "source/dynamix.unraid.net"), prePackDir, {
recursive: true,
filter: (src) => {
const filename = basename(src);
return ![
".DS_Store",
"pkg_build.sh",
"makepkg",
"explodepkg",
"sftp-config.json",
".gitkeep",
].includes(filename);
},
});
// Create package - must be run from within the pre-pack directory
// Use cd option to run command from prePackDir
await cd(prePackDir);
$.verbose = true;
await $`${join(startingDir, "scripts/makepkg")} -l y -c y "${txzPath}"`;
$.verbose = false;
await cd(startingDir);
// Calculate hashes
const sha256 = createHash("sha256")
.update(await readFile(txzPath))
.digest("hex");
console.log(`TXZ SHA256: ${sha256}`);
try {
await $`${join(startingDir, "scripts/explodepkg")} "${txzPath}"`;
} catch (err) {
console.error(`Error: invalid txz package created: ${txzPath}`);
process.exit(1);
}
return { txzSha256: sha256, txzName };
};
const getStagingChangelogFromGit = async (
apiVersion: string,
pr: string | null = null
): Promise<string | null> => {
console.debug("Getting changelog from git" + (pr ? " for PR" : ""));
try {
const changelogStream = conventionalChangelog(
{
preset: "conventionalcommits",
},
{
version: apiVersion,
},
pr
? {
from: "origin/main",
to: "HEAD",
}
: {},
undefined,
pr
? {
headerPartial: `## [PR #${pr}](https://github.com/unraid/api/pull/${pr})\n\n`,
}
: undefined
);
let changelog = "";
for await (const chunk of changelogStream) {
changelog += chunk;
}
// Encode HTML entities using the 'he' library
return escapeHtml(changelog) ?? null;
} catch (err) {
console.error(`Error: failed to get changelog from git: ${err}`);
process.exit(1);
}
};
const buildPlugin = async ({
type,
txzSha256,
txzName,
version,
pr = "",
apiVersion,
apiSha256,
}: {
type: "staging" | "pr" | "production";
txzSha256: string;
txzName: string;
version: string;
pr?: string;
apiVersion: string;
apiSha256: string;
}) => {
const rootPlgFile = join(startingDir, "/plugins/", `${pluginName}.plg`);
// Set up paths
const newPluginFile = join(
startingDir,
"/deploy/release/plugins/",
`${pluginName}${type === "production" ? "" : `.${type}`}.plg`
);
// Define URLs
let PLUGIN_URL = "";
let MAIN_TXZ = "";
let API_TGZ = "";
let RELEASE_NOTES: string | null = null;
switch (type) {
case "production":
PLUGIN_URL = `${BASE_URLS.STABLE}/${pluginName}.plg`;
MAIN_TXZ = `${BASE_URLS.STABLE}/${txzName}`;
API_TGZ = `${BASE_URLS.STABLE}/unraid-api-${apiVersion}.tgz`;
break;
case "pr":
PLUGIN_URL = `${BASE_URLS.PREVIEW}/pr/${pr}/${pluginName}.plg`;
MAIN_TXZ = `${BASE_URLS.PREVIEW}/pr/${pr}/${txzName}`;
API_TGZ = `${BASE_URLS.PREVIEW}/pr/${pr}/unraid-api-${apiVersion}.tgz`;
RELEASE_NOTES = await getStagingChangelogFromGit(apiVersion, pr);
break;
case "staging":
PLUGIN_URL = `${BASE_URLS.PREVIEW}/${pluginName}.plg`;
MAIN_TXZ = `${BASE_URLS.PREVIEW}/${txzName}`;
API_TGZ = `${BASE_URLS.PREVIEW}/unraid-api-${apiVersion}.tgz`;
RELEASE_NOTES = await getStagingChangelogFromGit(apiVersion);
break;
}
// Update plg file
let plgContent = await readFile(rootPlgFile, "utf8");
// Update entity values
const entities: Record<string, string> = {
name: pluginName,
env: type === "pr" ? "staging" : type,
version: version,
pluginURL: PLUGIN_URL,
SHA256: txzSha256,
MAIN_TXZ: MAIN_TXZ,
API_TGZ: API_TGZ,
PR: pr,
API_version: apiVersion,
API_SHA256: apiSha256,
};
// Iterate over entities and update them
Object.entries(entities).forEach(([key, value]) => {
if (key !== "PR" && !value) {
throw new Error(`Entity ${key} not set in entities : ${value}`);
}
plgContent = updateEntityValue(plgContent, key, value);
});
if (RELEASE_NOTES) {
// Update the CHANGES section with release notes
plgContent = plgContent.replace(
/<CHANGES>.*?<\/CHANGES>/s,
`<CHANGES>\n${RELEASE_NOTES}\n</CHANGES>`
);
}
await writeFile(newPluginFile, plgContent);
console.log(`${type} plugin: ${newPluginFile}`);
};
/**
* Main build script
*/
const main = async () => {
await createBuildDirectory();
const version = formatDate(new Date(), "yyyy.MM.dd.HHmm");
console.log(`Version: ${version}`);
const { txzSha256, txzName } = await buildTxz(version);
const { API_VERSION, API_SHA256, PR } = validatedEnv;
await buildPlugin({
type: "staging",
txzSha256,
txzName,
version,
apiVersion: API_VERSION,
apiSha256: API_SHA256,
});
if (PR) {
await buildPlugin({
type: "pr",
txzSha256,
txzName,
version,
pr: PR,
apiVersion: API_VERSION,
apiSha256: API_SHA256,
});
}
await buildPlugin({
type: "production",
txzSha256,
txzName,
version,
apiVersion: API_VERSION,
apiSha256: API_SHA256,
});
};
await main();

108
plugin/scripts/explodepkg Executable file
View File

@@ -0,0 +1,108 @@
#!/bin/bash
# Copyright 1994, 1998, 2000 Patrick Volkerding, Concord, CA, USA
# Copyright 2001, 2003 Slackware Linux, Inc., Concord, CA, USA
# Copyright 2007, 2009, 2017, 2018 Patrick Volkerding, Sebeka, MN, USA
# All rights reserved.
#
# Redistribution and use of this script, with or without modification, is
# permitted provided that the following conditions are met:
#
# 1. Redistributions of this script must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED
# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
# EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
if [ $# = 0 ]; then
cat << EOF
Usage: explodepkg package_name [package_name2, ...]
Explodes a Slackware compatible software package
(or any tar+{gzip,bzip2,lz,xz archive) in the current directory.
Equivalent to (for each package listed):
( umask 000 ; cat package_name | COMPRESSOR -dc | tar xpvf package_name )
Note: This should only be used for debugging or examining packages, not for
installing them. It doesn't execute installation scripts or update the package
indexes in /var/lib/pkgtools/packages and /var/lib/pkgtools/scripts.
EOF
fi
# Set maximum number of threads to use. By default, this will be the number
# of CPU threads:
THREADS="$(nproc)"
# Main loop:
for PKG in $* ; do
echo "Exploding package $PKG in current directory:"
# Determine extension:
packageext="$( echo $PKG | rev | cut -f 1 -d . | rev)"
# Determine compression utility:
case $packageext in
'tgz' )
packagecompression=gzip
;;
'gz' )
packagecompression=gzip
;;
'tbz' )
if which lbzip2 1> /dev/null 2> /dev/null ; then
packagecompression=lbzip2
else
packagecompression=bzip2
fi
;;
'bz2' )
if which lbzip2 1> /dev/null 2> /dev/null ; then
packagecompression=lbzip2
else
packagecompression=bzip2
fi
;;
'tlz' )
if which plzip 1> /dev/null 2> /dev/null ; then
packagecompression="plzip --threads=${THREADS}"
elif which lzip 1> /dev/null 2> /dev/null ; then
packagecompression=lzip
else
echo "ERROR: lzip compression utility not found in \$PATH."
exit 3
fi
;;
'lz' )
if which plzip 1> /dev/null 2> /dev/null ; then
packagecompression="plzip --threads=${THREADS}"
elif which lzip 1> /dev/null 2> /dev/null ; then
packagecompression=lzip
else
echo "ERROR: lzip compression utility not found in \$PATH."
exit 3
fi
;;
'lzma' )
packagecompression=lzma
;;
'txz' )
packagecompression="xz --threads=${THREADS}"
;;
'xz' )
packagecompression="xz --threads=${THREADS}"
;;
esac
( umask 000 ; cat $PKG | $packagecompression -dc | tar --xattrs --xattrs-include='*' --keep-directory-symlink -xpvf - 2> /dev/null )
if [ -r install/doinst.sh ]; then
echo
echo "An installation script was detected in ./install/doinst.sh, but"
echo "was not executed."
fi
done

459
plugin/scripts/makepkg Executable file
View File

@@ -0,0 +1,459 @@
#!/bin/bash
# Copyright 1994, 1998, 2008 Patrick Volkerding, Moorhead, Minnesota USA
# Copyright 2003 Slackware Linux, Inc. Concord, CA USA
# Copyright 2009, 2015, 2017, 2018, 2019 Patrick J. Volkerding, Sebeka, MN, USA
# All rights reserved.
#
# Redistribution and use of this script, with or without modification, is
# permitted provided that the following conditions are met:
#
# 1. Redistributions of this script must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED
# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
# EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# Mon 2 Jul 15:32:14 UTC 2018
# Sort file lists and support SOURCE_DATE_EPOCH, for reproducibility.
#
# Mon May 21 18:31:20 UTC 2018
# Add --compress option, usually used to change the preset compression level
# or block size.
#
# Tue Feb 13 00:46:12 UTC 2018
# Use recent tar, and support storing POSIX ACLs and extended attributes.
#
# Tue Dec 12 21:55:59 UTC 2017
# If possible, use multiple compression threads.
#
# Wed Sep 23 18:36:43 UTC 2015
# Support spaces in file/directory names. <alphageek>
#
# Sun Apr 5 21:23:26 CDT 2009
# Support .tgz, .tbz, .tlz, and .txz packages. <volkerdi>
#
# Fri Nov 26 13:53:36 GMT 2004
# Patched to chmod 755 the package's root directory if needed, then restore
# previous permissions after the package has been created. <sw>
#
# Wed Mar 18 15:32:33 CST 1998
# Patched to avoid possible symlink attacks in /tmp.
CWD=$(pwd)
umask 022
make_install_script() {
TAB="$(echo -e "\t")"
COUNT=1
while :; do
LINE="$(sed -n "$COUNT p" $1)"
if [ "$LINE" = "" ]; then
break
fi
LINKGOESIN="$(echo "$LINE" | cut -f 1 -d "$TAB")"
LINKGOESIN="$(dirname "$LINKGOESIN")"
LINKNAMEIS="$(echo "$LINE" | cut -f 1 -d "$TAB")"
LINKNAMEIS="$(basename "$LINKNAMEIS")"
LINKPOINTSTO="$(echo "$LINE" | cut -f 2 -d "$TAB")"
echo "( cd $LINKGOESIN ; rm -rf $LINKNAMEIS )"
echo "( cd $LINKGOESIN ; ln -sf $LINKPOINTSTO $LINKNAMEIS )"
COUNT=$(expr $COUNT + 1)
done
}
usage() {
cat << EOF
Usage: makepkg package_name.tgz
(or: package_name.tbz, package_name.tlz, package_name.txz)
Makes a Slackware compatible package containing the contents of the current
and all subdirectories. If symbolic links exist, they will be removed and
an installation script will be made to recreate them later. This script will
be called "install/doinst.sh". You may add any of your own ash-compatible
shell scripts to this file and rebuild the package if you wish.
options: -l, --linkadd y|n (moves symlinks into doinst.sh: recommended)
-p, --prepend (prepend rather than append symlinks to an existing
doinst.sh. Useful to link libraries needed by programs in
the doinst.sh script)
-c, --chown y|n (resets all permissions to root:root 755 - not
generally recommended)
--threads <number> For xz/plzip compressed packages, set the max
number of threads to be used for compression. Only has an
effect on large packages. For plzip, the default is equal to
the number of CPU threads available on the machine. For xz,
the default is equal to 2 (due to commonly occuring memory
related failures when using many threads with multi-threaded
xz compression).
--compress <option> Supply a custom option to the compressor.
This will be used in place of the default, which is: -9
--acls Support storing POSIX ACLs in the package. The resulting
package will not be compatible with pkgtools version < 15.0.
--xattrs Support storing extended attributes in the package. The
resulting package will not be compatible with pkgtools
version < 15.0.
If these options are not set, makepkg will prompt if appropriate.
EOF
}
TMP=/tmp # This can be a hole, but I'm going to be careful about file
# creation in there, so don't panic. :^)
# Set maximum number of threads to use. By default, this will be the number
# of CPU threads:
THREADS="$(nproc)"
# Set default compression option.
COMPRESS_OPTION="-9"
# Parse options
unset ACLS XATTRS
while [ 0 ]; do
if [ "$1" = "--linkadd" -o "$1" = "-l" ]; then
if [ "$2" = "y" ]; then
LINKADD=y
elif [ "$2" = "n" ]; then
LINKADD=n
else
usage
exit 2
fi
shift 2
elif [ "$1" = "--chown" -o "$1" = "-c" ]; then
if [ "$2" = "y" ]; then
CHOWN=y
elif [ "$2" = "n" ]; then
CHOWN=n
else
usage
exit 2
fi
shift 2
elif [ "$1" = "-p" -o "$1" = "--prepend" ]; then
PREPEND=y
shift 1
elif [ "$1" = "-threads" -o "$1" = "--threads" ]; then
THREADS="$2"
shift 2
# xz has memory issues with threads it seems, so we'll use two threads by
# default unless we see that something else was user-selected:
XZ_THREADS_FORCED=yes
elif [ "$1" = "-compress" -o "$1" = "--compress" ]; then
COMPRESS_OPTION="$2"
shift 2
elif [ "$1" = "--acls" ]; then
ACLS="--acls"
shift 1
elif [ "$1" = "--xattrs" ]; then
XATTRS="--xattrs"
shift 1
elif [ "$1" = "-h" -o "$1" = "-H" -o "$1" = "--help" -o $# = 0 ]; then
usage
exit 0
else
break
fi
done
unset MTIME
if [ -n "${SOURCE_DATE_EPOCH}" ]; then
MTIME="--clamp-mtime --mtime=@${SOURCE_DATE_EPOCH}"
fi
PACKAGE_NAME="$1"
TARGET_NAME="$(dirname $PACKAGE_NAME)"
PACKAGE_NAME="$(basename $PACKAGE_NAME)"
# Identify package extension and compression type to use:
if [ ! "$(basename $PACKAGE_NAME .tgz)" = "$PACKAGE_NAME" ]; then
EXTENSION="tgz"
COMPEXT="gz"
COMPRESSOR="gzip ${COMPRESS_OPTION} -cn"
if ! which gzip 1> /dev/null 2> /dev/null ; then
echo "ERROR: gzip compression utility not found in \$PATH."
exit 3
fi
elif [ ! "$(basename $PACKAGE_NAME .tar.gz)" = "$PACKAGE_NAME" ]; then
EXTENSION="tar.gz"
COMPRESSOR="gzip ${COMPRESS_OPTION} -cn"
if ! which gzip 1> /dev/null 2> /dev/null ; then
echo "ERROR: gzip compression utility not found in \$PATH."
exit 3
fi
elif [ ! "$(basename $PACKAGE_NAME .tbz)" = "$PACKAGE_NAME" ]; then
EXTENSION="tbz"
if which lbzip2 1> /dev/null 2> /dev/null ; then
COMPRESSOR="lbzip2 ${COMPRESS_OPTION} -c"
else
if which bzip2 1> /dev/null 2> /dev/null ; then
COMPRESSOR="bzip2 ${COMPRESS_OPTION} -c"
else
echo "ERROR: bzip2 compression utility not found in \$PATH."
exit 3
fi
fi
elif [ ! "$(basename $PACKAGE_NAME .tar.bz2)" = "$PACKAGE_NAME" ]; then
EXTENSION="tar.bz2"
if which lbzip2 1> /dev/null 2> /dev/null ; then
COMPRESSOR="lbzip2 ${COMPRESS_OPTION} -c"
else
if which bzip2 1> /dev/null 2> /dev/null ; then
COMPRESSOR="bzip2 ${COMPRESS_OPTION} -c"
else
echo "ERROR: bzip2 compression utility not found in \$PATH."
exit 3
fi
fi
elif [ ! "$(basename $PACKAGE_NAME .tlz)" = "$PACKAGE_NAME" ]; then
EXTENSION="tlz"
if which plzip 1> /dev/null 2> /dev/null ; then
COMPRESSOR="plzip ${COMPRESS_OPTION} --threads=${THREADS} -c"
else
echo "WARNING: plzip compression utility not found in \$PATH."
echo "WARNING: package will not support multithreaded decompression."
if which lzip 1> /dev/null 2> /dev/null ; then
COMPRESSOR="lzip ${COMPRESS_OPTION} -c"
else
echo "ERROR: lzip compression utility not found in \$PATH."
exit 3
fi
fi
elif [ ! "$(basename $PACKAGE_NAME .tar.lz)" = "$PACKAGE_NAME" ]; then
EXTENSION="tar.lz"
if which plzip 1> /dev/null 2> /dev/null ; then
COMPRESSOR="plzip ${COMPRESS_OPTION} --threads=${THREADS} -c"
else
echo "WARNING: plzip compression utility not found in \$PATH."
echo "WARNING: package will not support multithreaded decompression."
if which lzip 1> /dev/null 2> /dev/null ; then
COMPRESSOR="lzip ${COMPRESS_OPTION} -c"
else
echo "ERROR: lzip compression utility not found in \$PATH."
exit 3
fi
fi
elif [ ! "$(basename $PACKAGE_NAME .tar.lzma)" = "$PACKAGE_NAME" ]; then
EXTENSION="tar.lzma"
COMPRESSOR="lzma ${COMPRESS_OPTION} -c"
if ! which lzma 1> /dev/null 2> /dev/null ; then
echo "ERROR: lzma compression utility not found in \$PATH."
exit 3
fi
elif [ ! "$(basename $PACKAGE_NAME .txz)" = "$PACKAGE_NAME" ]; then
EXTENSION="txz"
if [ ! "$XZ_THREADS_FORCED" = "yes" ]; then
# Two threads by default with xz due to memory failures on 32-bit. Not that
# it matters much... if upstream ever gets around to implementing multi-
# threaded decompression we'll revisit this default. :-D
COMPRESSOR="xz ${COMPRESS_OPTION} --threads=2 -c"
else
COMPRESSOR="xz ${COMPRESS_OPTION} --threads=${THREADS} -c"
fi
if ! which xz 1> /dev/null 2> /dev/null ; then
echo "ERROR: xz compression utility not found in \$PATH."
exit 3
fi
elif [ ! "$(basename $PACKAGE_NAME .tar.xz)" = "$PACKAGE_NAME" ]; then
EXTENSION="tar.xz"
if [ ! "$XZ_THREADS_FORCED" = "yes" ]; then
# Two threads by default with xz due to memory failures on 32-bit. Not that
# it matters much... if upstream ever gets around to implementing multi-
# threaded decompression we'll revisit this default. :-D
COMPRESSOR="xz ${COMPRESS_OPTION} --threads=2 -c"
else
COMPRESSOR="xz ${COMPRESS_OPTION} --threads=${THREADS} -c"
fi
if ! which xz 1> /dev/null 2> /dev/null ; then
echo "ERROR: xz compression utility not found in \$PATH."
exit 3
fi
else
EXTENSION="$(echo $PACKAGE_NAME | rev | cut -f 1 -d . | rev)"
echo "ERROR: Package extension .$EXTENSION is not supported."
exit 1
fi
TAR_NAME="$(basename $PACKAGE_NAME .$EXTENSION)"
# Sanity check -- we can't make the package in the current directory:
if [ "$CWD" = "$TARGET_NAME" -o "." = "$TARGET_NAME" ]; then
echo "ERROR: Can't make output package in current directory."
exit 2
fi
echo
echo "Slackware package maker, version 3.14159265."
echo
echo "Searching for symbolic links:"
# Get rid of possible pre-existing trouble:
INST=$(mktemp $TMP/makepkg.XXXXXX)
# Escape some characters in symlink names:
find . -type l -printf "%p\t%l\n" | LC_COLLATE=C sort | sed 's,^\./,,; s,[ "#$&\x27()*;<>?[\\`{|~],\\&,g;' | tee $INST
if [ ! "$(cat $INST)" = "" ]; then
echo
echo "Making symbolic link creation script:"
make_install_script $INST | tee doinst.sh
fi
echo
if [ ! "$(cat $INST)" = "" ]; then
if [ -r install/doinst.sh ]; then
echo "Unless your existing installation script already contains the code"
echo "to create these links, you should append these lines to your existing"
echo "install script. Now's your chance. :^)"
echo
echo "Would you like to add this stuff to the existing install script and"
echo -n "remove the symbolic links ([y]es, [n]o)? "
else
echo "It is recommended that you make these lines your new installation script."
echo
echo "Would you like to make this stuff the install script for this package"
echo -n "and remove the symbolic links ([y]es, [n]o)? "
fi
if [ ! "$LINKADD" ]; then
read LINKADD;
echo
else
echo $LINKADD
echo
fi
if [ "$LINKADD" = "y" ]; then
if [ -r install/doinst.sh ]; then
UPDATE="t"
if [ "$PREPEND" = "y" ]; then
touch install/doinst.sh
mv install/doinst.sh install/doinst.sh.shipped
cat doinst.sh > install/doinst.sh
echo "" >> install/doinst.sh
cat install/doinst.sh.shipped >> install/doinst.sh
rm -f install/doinst.sh.shipped
else
cat doinst.sh >> install/doinst.sh
fi
else
mkdir -p install
cat doinst.sh > install/doinst.sh
fi
echo
echo "Removing symbolic links:"
find . -type l -exec rm -v {} \;
echo
if [ "$UPDATE" = "t" ]; then
if [ "$PREPEND" = "y" ]; then
echo "Updating your ./install/doinst.sh (prepending symlinks)..."
else
echo "Updating your ./install/doinst.sh..."
fi
else
echo "Creating your new ./install/doinst.sh..."
fi
fi
else
echo "No symbolic links were found, so we won't make an installation script."
echo "You can make your own later in ./install/doinst.sh and rebuild the"
echo "package if you like."
fi
rm -f doinst.sh $INST
echo
echo "This next step is optional - you can set the directories in your package"
echo "to some sane permissions. If any of the directories in your package have"
echo "special permissions, then DO NOT reset them here!"
echo
echo "Would you like to reset all directory permissions to 755 (drwxr-xr-x) and"
echo -n "directory ownerships to root.root ([y]es, [n]o)? "
if [ ! "$CHOWN" ]; then
read CHOWN;
echo
else
echo $CHOWN
echo
fi
if [ "$CHOWN" = "y" ]; then
find . -type d -exec chmod -v 755 {} \;
find . -type d -exec chown -v root.root {} \;
fi
# Ensure that the 'root' of the package is chmod 755 because
# the / of your filesystem will inherit these permissions.
# If it's anything tighter than 755 then bad things happen such as users
# not being able to login, users already logged in can no longer run commands
# and so on.
OLDROOTPERMS="$(find -name . -printf "%m\n")"
if [ $OLDROOTPERMS -ne 755 ]; then
echo "WARNING: $PWD is chmod $OLDROOTPERMS"
echo " temporarily changing to chmod 755"
chmod 755 .
fi
echo "Creating Slackware package: ${TARGET_NAME}/${TAR_NAME}.${EXTENSION}"
echo
rm -f ${TARGET_NAME}/${TAR_NAME}.${EXTENSION}
# HISTORICAL NOTE 2/2018:
# In the interest of maximizing portability of this script, we'll use find
# and sed to create a filelist compatible with tar-1.13, and then use a
# more modern tar version to create the archive.
#
# Other (but possibly less portable) ways to achieve the same result:
#
# Use the tar --transform and --show-transformed-names options:
# tar --transform "s,^\./\(.\),\1," --show-transformed-names $ACLS $XATTRS -cvf - . | $COMPRESSOR > ${TARGET_NAME}/${TAR_NAME}.${EXTENSION}
#
# Use cpio:
# find ./ | sed '2,$s,^\./,,' | cpio --quiet -ovHustar > ${TARGET_NAME}/${TAR_NAME}.tar
# Create the package:
find ./ | LC_COLLATE=C sort | sed '2,$s,^\./,,' | tar --no-recursion $ACLS $XATTRS $MTIME -T - -cvf - | $COMPRESSOR > ${TARGET_NAME}/${TAR_NAME}.${EXTENSION}
ERRCODE=$?
if [ ! $ERRCODE = 0 ]; then
echo "ERROR: $COMPRESSOR returned error code $ERRCODE -- makepkg failed."
exit 1
fi
# Warn of zero-length files:
find . -type f -size 0c | cut -b3- | sed "s/^/WARNING: zero length file /g"
# Warn of corrupt or empty gzip files:
find . -type f -name '*.gz' | while read file ; do
if ! gzip -t $file 1> /dev/null 2> /dev/null ; then
echo "WARNING: gzip test failed on $(echo $file | cut -b3-)"
else
if [ "$(gzip -l $file | tail -n 1 | tr -s ' ' | cut -f 3 -d ' ')" -eq 0 ]; then
echo "WARNING: $(echo $file | cut -b3-) is an empty gzipped file"
fi
fi
done
# Some more handy warnings:
if [ -d usr/share/man ]; then
echo "WARNING: /usr/share/man (with possibly not gzipped man pages) detected"
fi
if [ -d usr/share/info ]; then
echo "WARNING: /usr/share/info (with possibly not gzipped info pages) detected"
fi
if find . | grep site_perl 1> /dev/null ; then
echo "WARNING: site_perl directory detected (this is fine for a local package build)"
fi
# Restore the old permissions if they previously weren't chmod 755
if [ $OLDROOTPERMS -ne 755 ]; then
echo
echo "Restoring permissions of $PWD to chmod $OLDROOTPERMS"
chmod $OLDROOTPERMS .
fi
echo
echo "Slackware package ${TARGET_NAME}/${TAR_NAME}.${EXTENSION} created."
echo

View File

@@ -1,4 +1,5 @@
#!/bin/bash
# LEGACY SCRIPT - Kept for validation purposes. If still present after May 2025, delete.
# passes `shellcheck` and `shfmt -i 2`
[[ "$1" == "s" ]] && env=staging
@@ -10,10 +11,9 @@
DIR=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
MAINDIR=$(dirname "$(dirname "${DIR}")")
tmpdir=/tmp/tmp.$((RANDOM * 19318203981230 + 40))
tmpdir=$(mktemp -d)
pluginSrc=$(basename "${DIR}")
plugin="${pluginSrc}"
[[ "${env}" == "staging" ]] && plugin="${plugin}.staging" && cp "${MAINDIR}/plugins/${pluginSrc}.plg" "${MAINDIR}/plugins/${plugin}.plg"
version=$(date +"%Y.%m.%d.%H%M")
plgfile="${MAINDIR}/plugins/${plugin}.plg"
txzfile="${MAINDIR}/archive/${plugin}-${version}.txz"
@@ -24,17 +24,6 @@ mkdir -p "${tmpdir}"
# shellcheck disable=SC2046
cp --parents -f $(find . -type f ! \( -iname ".DS_Store" -o -iname "pkg_build.sh" -o -iname "makepkg" -o -iname "explodepkg" -o -iname "sftp-config.json" \)) "${tmpdir}/"
cd "${tmpdir}" || exit 1
if [[ -n "${PR}" ]]; then
sed -i "s@\*\*Unraid Connect\*\*@\*\*Unraid Connect (PR #${PR})\*\*@" "${tmpdir}/usr/local/emhttp/plugins/dynamix.unraid.net.staging/README.md"
elif [[ "${env}" == "staging" ]]; then
sed -i "s@\*\*Unraid Connect\*\*@\*\*Unraid Connect \(staging\)\*\*@" "${tmpdir}/usr/local/emhttp/plugins/dynamix.unraid.net.staging/README.md"
fi
if [[ "${env}" == "staging" ]]; then
# create README.md for staging plugin
mv "${tmpdir}/usr/local/emhttp/plugins/dynamix.unraid.net" "${tmpdir}/usr/local/emhttp/plugins/dynamix.unraid.net.staging"
sed -i "s@dynamix.unraid.net.plg@dynamix.unraid.net.staging.plg@" "${tmpdir}/usr/local/emhttp/plugins/dynamix.my.servers/Connect.page"
fi
chmod 0755 -R .
sudo chown root:root -R .
@@ -76,7 +65,7 @@ sed -i -E "s#(ENTITY pluginURL\s*)\".*\"#\1\"${PLUGIN_URL}\"#g" "${plgfile}"
sed -i -E "s#(ENTITY SHA256\s*)\".*\"#\1\"${sha256}\"#g" "${plgfile}"
sed -i -E "s#(ENTITY MAIN_TXZ\s*)\".*\"#\1\"${MAIN_TXZ}\"#g" "${plgfile}"
sed -i -E "s#(ENTITY API_TGZ\s*)\".*\"#\1\"${API_TGZ}\"#g" "${plgfile}"
sed -i -E "s#(ENTITY PR\s*)\".*\"#\1\"${PR}\"#g" "${plgfile}"
# set from environment vars
sed -i -E "s#(ENTITY API_version\s*)\".*\"#\1\"${API_VERSION}\"#g" "${plgfile}"

View File

@@ -1,5 +1,9 @@
#!/bin/bash
# unraid-api-handler
# shellcheck source=/dev/null
source /etc/profile
flash="/boot/config/plugins/dynamix.my.servers"
[[ ! -d "${flash}" ]] && echo "Please reinstall the Unraid Connect plugin" && exit 1
[[ ! -f "${flash}/env" ]] && echo 'env=production' >"${flash}/env"