mirror of
https://github.com/unraid/api.git
synced 2026-05-21 00:18:30 -05:00
feat: split plugin builds
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced containerized plugin deployment support with updated Docker Compose configurations. - Added continuous build watch modes for API, web, and UI components for smoother development iterations. - Added a new job for API testing in the CI/CD workflow. - Added a new shell script to determine the local host's IP address for Docker configurations. - Introduced a new entry point and HTTP server setup in the plugin's Docker environment. - Added new scripts for building and watching plugin changes in real-time. - Added a new script for building the project in watch mode for the API and UI components. - **Improvements** - Streamlined the plugin installation process and refined release workflows for a more reliable user experience. - Enhanced overall CI/CD pipelines to ensure efficient, production-ready deployments. - Updated artifact upload paths and job definitions for clarity and efficiency. - Implemented new utility functions for better URL management and changelog generation. - Modified the `.dockerignore` file to ignore all contents within the `node_modules` directory. - Added new constants and functions for managing plugin paths and configurations. - Updated the build process in the Dockerfile to focus on release operations. - **Tests** - Expanded automated testing to validate environment setups and build stability, ensuring higher reliability during updates. - Introduced new test suites for validating plugin environment setups and configurations. - Added tests for validating environment variables and handling of manifest files. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Michael Datelle <mdatelle@icloud.com> Co-authored-by: mdatelle <mike@datelle.net> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Pujit Mehrotra <pujit@lime-technology.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
validatePluginEnv,
|
||||
setupPluginEnv,
|
||||
} from "../../cli/setup-plugin-environment";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
access: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
constants: {
|
||||
F_OK: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(readFile).mockImplementation((path, encoding) => {
|
||||
console.log("Mock readFile called with:", path, encoding);
|
||||
|
||||
// If called with encoding parameter (for release notes)
|
||||
if (encoding === "utf8") {
|
||||
if (path.toString().includes("valid-release-notes.txt")) {
|
||||
return Promise.resolve("Release notes content");
|
||||
}
|
||||
}
|
||||
// If called without encoding (for txz file)
|
||||
if (path.toString().includes("test.txz")) {
|
||||
return Promise.resolve(Buffer.from("test content"));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`File not found: ${path}`));
|
||||
});
|
||||
vi.mocked(access).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("validatePluginEnv", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("validates required fields", async () => {
|
||||
const validEnv = {
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
};
|
||||
|
||||
const result = await validatePluginEnv(validEnv);
|
||||
expect(result).toMatchObject(validEnv);
|
||||
});
|
||||
|
||||
it("throws on invalid URL", async () => {
|
||||
const invalidEnv = {
|
||||
baseUrl: "not-a-url",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
};
|
||||
|
||||
await expect(validatePluginEnv(invalidEnv)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("handles tag option in non-CI mode", async () => {
|
||||
const envWithTag = {
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
tag: "v1.0.0",
|
||||
};
|
||||
|
||||
const result = await validatePluginEnv(envWithTag);
|
||||
|
||||
expect(result.releaseNotes).toBe("FAST_TEST_CHANGELOG");
|
||||
expect(result.tag).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
it("reads release notes when release-notes-path is provided", async () => {
|
||||
const envWithNotes = {
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
releaseNotesPath: "valid-release-notes.txt",
|
||||
};
|
||||
|
||||
const result = await validatePluginEnv(envWithNotes);
|
||||
|
||||
expect(access).toHaveBeenCalledWith("valid-release-notes.txt", 0);
|
||||
expect(readFile).toHaveBeenCalledWith("valid-release-notes.txt", "utf8");
|
||||
expect(result.releaseNotes).toBe("Release notes content");
|
||||
});
|
||||
|
||||
it("throws when release notes file is empty", async () => {
|
||||
// Instead of overwriting the entire mock, just mock this specific case
|
||||
vi.mocked(readFile).mockImplementationOnce((path, encoding) => {
|
||||
if (path === "/path/to/notes.md" && encoding === "utf8") {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected mock call"));
|
||||
});
|
||||
|
||||
const envWithEmptyNotes = {
|
||||
baseUrl: "https://example.com",
|
||||
txzPath: "./test.txz",
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
releaseNotesPath: "/path/to/notes.md",
|
||||
};
|
||||
|
||||
await expect(validatePluginEnv(envWithEmptyNotes)).rejects.toThrow(
|
||||
"Release notes file is empty: /path/to/notes.md"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupPluginEnv", () => {
|
||||
it("sets up environment from CLI arguments", async () => {
|
||||
const argv = [
|
||||
"node",
|
||||
"script.js",
|
||||
"--plugin-version",
|
||||
"2024.05.05.1232",
|
||||
"--txz-path",
|
||||
"./test.txz",
|
||||
"--base-url",
|
||||
"https://example.com",
|
||||
];
|
||||
|
||||
const result = await setupPluginEnv(argv);
|
||||
expect(result).toMatchObject({
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
txzPath: "./test.txz",
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when required options are missing", async () => {
|
||||
const argv = ["node", "script.js"]; // Missing required options
|
||||
await expect(setupPluginEnv(argv)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("handles optional CLI arguments", async () => {
|
||||
const argv = [
|
||||
"node",
|
||||
"script.js",
|
||||
"--txz-path",
|
||||
"./test.txz",
|
||||
"--base-url",
|
||||
"https://example.com",
|
||||
"--tag",
|
||||
"PR1203",
|
||||
"--ci",
|
||||
"--plugin-version",
|
||||
"2024.05.05.1232",
|
||||
];
|
||||
|
||||
try {
|
||||
const result = await setupPluginEnv(argv);
|
||||
expect(result).toMatchObject({
|
||||
pluginVersion: "2024.05.05.1232",
|
||||
txzPath: "./test.txz",
|
||||
txzSha256:
|
||||
"6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72",
|
||||
baseUrl: "https://example.com",
|
||||
tag: "PR1203",
|
||||
ci: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { join } from "path";
|
||||
import { validateTxzEnv, TxzEnv } from "../../cli/setup-txz-environment";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { startingDir } from "../../utils/consts";
|
||||
import { deployDir } from "../../utils/paths";
|
||||
|
||||
describe("setupTxzEnvironment", () => {
|
||||
it("should return default values when no arguments are provided", async () => {
|
||||
const envArgs = {};
|
||||
const expected: TxzEnv = { ci: false, skipValidation: "false", compress: "1", txzOutputDir: join(startingDir, deployDir) };
|
||||
|
||||
const result = await validateTxzEnv(envArgs);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should parse and return provided environment arguments", async () => {
|
||||
const envArgs = { ci: true, skipValidation: "true", txzOutputDir: join(startingDir, "deploy/release/test"), compress: '8' };
|
||||
const expected: TxzEnv = { ci: true, skipValidation: "true", compress: "8", txzOutputDir: join(startingDir, "deploy/release/test") };
|
||||
|
||||
const result = await validateTxzEnv(envArgs);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should warn and skip validation when skipValidation is true", async () => {
|
||||
const envArgs = { skipValidation: "true" };
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, "warn")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await validateTxzEnv(envArgs);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
"skipValidation is true, skipping validation"
|
||||
);
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should throw an error for invalid SKIP_VALIDATION value", async () => {
|
||||
const envArgs = { skipValidation: "invalid" };
|
||||
|
||||
await expect(validateTxzEnv(envArgs)).rejects.toThrow(
|
||||
"Must be true or false"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readFile, writeFile, mkdir, rename } from "fs/promises";
|
||||
import { $ } from "zx";
|
||||
import { escape as escapeHtml } from "html-sloppy-escaper";
|
||||
import { dirname, join } from "node:path";
|
||||
import { getTxzName, pluginName, startingDir } from "./utils/consts";
|
||||
import { getPluginUrl } from "./utils/bucket-urls";
|
||||
import { getMainTxzUrl } from "./utils/bucket-urls";
|
||||
import {
|
||||
deployDir,
|
||||
getDeployPluginPath,
|
||||
getRootPluginPath,
|
||||
} from "./utils/paths";
|
||||
import { PluginEnv, setupPluginEnv } from "./cli/setup-plugin-environment";
|
||||
import { cleanupPluginFiles } from "./utils/cleanup";
|
||||
|
||||
/**
|
||||
* Check if git is available
|
||||
*/
|
||||
const checkGit = async () => {
|
||||
try {
|
||||
await $`git log -1 --pretty=%B`;
|
||||
} catch (err) {
|
||||
console.error(`Error: git not available: ${err}`);
|
||||
throw new Error(`Git not available: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const moveTxzFile = async (txzPath: string, pluginVersion: string) => {
|
||||
const txzName = getTxzName(pluginVersion);
|
||||
await rename(txzPath, join(deployDir, txzName));
|
||||
};
|
||||
|
||||
function updateEntityValue(
|
||||
xmlString: string,
|
||||
entityName: string,
|
||||
newValue: string
|
||||
) {
|
||||
console.log("Updating entity:", entityName, "with value:", newValue);
|
||||
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 buildPlugin = async ({
|
||||
pluginVersion,
|
||||
baseUrl,
|
||||
tag,
|
||||
txzSha256,
|
||||
releaseNotes,
|
||||
}: PluginEnv) => {
|
||||
// Update plg file
|
||||
let plgContent = await readFile(getRootPluginPath({ startingDir }), "utf8");
|
||||
|
||||
// Update entity values
|
||||
const entities: Record<string, string> = {
|
||||
name: pluginName,
|
||||
version: pluginVersion,
|
||||
pluginURL: getPluginUrl({ baseUrl, tag }),
|
||||
MAIN_TXZ: getMainTxzUrl({ baseUrl, pluginVersion, tag }),
|
||||
TXZ_SHA256: txzSha256,
|
||||
...(tag ? { TAG: tag } : {}),
|
||||
};
|
||||
|
||||
console.log("Entities:", entities);
|
||||
// Iterate over entities and update them
|
||||
Object.entries(entities).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
throw new Error(`Entity ${key} not set in entities: ${JSON.stringify(entities)}`);
|
||||
}
|
||||
plgContent = updateEntityValue(plgContent, key, value);
|
||||
});
|
||||
|
||||
if (releaseNotes) {
|
||||
// Update the CHANGES section with release notes
|
||||
plgContent = plgContent.replace(
|
||||
/<CHANGES>.*?<\/CHANGES>/s,
|
||||
`<CHANGES>\n${escapeHtml(releaseNotes)}\n</CHANGES>`
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(dirname(getDeployPluginPath({ startingDir })), {
|
||||
recursive: true,
|
||||
});
|
||||
console.log("Writing plg file to:", getDeployPluginPath({ startingDir }));
|
||||
await writeFile(getDeployPluginPath({ startingDir }), plgContent);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main build script
|
||||
*/
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
const validatedEnv = await setupPluginEnv(process.argv);
|
||||
await checkGit();
|
||||
await cleanupPluginFiles();
|
||||
|
||||
await buildPlugin(validatedEnv);
|
||||
await moveTxzFile(validatedEnv.txzPath, validatedEnv.pluginVersion);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,110 @@
|
||||
import { join } from "path";
|
||||
import { $, cd } from "zx";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { getTxzName, pluginName, startingDir } from "./utils/consts";
|
||||
import { setupTxzEnv, TxzEnv } from "./cli/setup-txz-environment";
|
||||
import { cleanupTxzFiles } from "./utils/cleanup";
|
||||
|
||||
// Recursively search for manifest files
|
||||
const findManifestFiles = async (dir: string): Promise<string[]> => {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await findManifestFiles(fullPath)));
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name === "manifest.json" || entry.name === "ui.manifest.json")
|
||||
) {
|
||||
files.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const validateSourceDir = async (validatedEnv: TxzEnv) => {
|
||||
if (!validatedEnv.ci) {
|
||||
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`);
|
||||
}
|
||||
|
||||
const manifestFiles = await findManifestFiles(webcomponentDir);
|
||||
const hasManifest = manifestFiles.includes("manifest.json");
|
||||
const hasUiManifest = manifestFiles.includes("ui.manifest.json");
|
||||
|
||||
if (!hasManifest || !hasUiManifest) {
|
||||
console.log("Existing Manifest Files:", manifestFiles);
|
||||
throw new Error(
|
||||
`Webcomponents must contain both "ui.manifest.json" and "manifest.json" - be sure to have run pnpm build:wc in unraid-ui`
|
||||
);
|
||||
}
|
||||
|
||||
const apiDir = join(
|
||||
startingDir,
|
||||
"source",
|
||||
pluginName,
|
||||
"usr",
|
||||
"local",
|
||||
"unraid-api"
|
||||
);
|
||||
if (!existsSync(apiDir)) {
|
||||
throw new Error(`API directory ${apiDir} does not exist`);
|
||||
}
|
||||
const packageJson = join(apiDir, "package.json");
|
||||
if (!existsSync(packageJson)) {
|
||||
throw new Error(`API package.json file ${packageJson} does not exist`);
|
||||
}
|
||||
// Now CHMOD the api/dist directory files to allow execution
|
||||
await $`chmod +x ${apiDir}/dist/*.js`;
|
||||
};
|
||||
|
||||
const buildTxz = async (validatedEnv: TxzEnv) => {
|
||||
await validateSourceDir(validatedEnv);
|
||||
const txzPath = join(validatedEnv.txzOutputDir, getTxzName());
|
||||
|
||||
// Create package - must be run from within the pre-pack directory
|
||||
// Use cd option to run command from prePackDir
|
||||
await cd(join(startingDir, "source", pluginName));
|
||||
$.verbose = true;
|
||||
|
||||
await $`${join(startingDir, "scripts/makepkg")} --chown y --compress -${
|
||||
validatedEnv.compress
|
||||
} --linkadd y ${txzPath}`;
|
||||
$.verbose = false;
|
||||
await cd(startingDir);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
try {
|
||||
const validatedEnv = await setupTxzEnv(process.argv);
|
||||
await cleanupTxzFiles();
|
||||
await buildTxz(validatedEnv);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
import { access, constants, readFile } from "node:fs/promises";
|
||||
import { Command } from "commander";
|
||||
import { getStagingChangelogFromGit } from "../utils/changelog";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getTxzPath } from "../utils/paths";
|
||||
|
||||
const safeParseEnvSchema = z.object({
|
||||
ci: z.boolean().optional(),
|
||||
baseUrl: z.string().url(),
|
||||
tag: z.string().optional().default(''),
|
||||
|
||||
txzPath: z.string().refine((val) => val.endsWith(".txz"), {
|
||||
message: "TXZ Path must end with .txz",
|
||||
}),
|
||||
pluginVersion: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d{4}$/, {
|
||||
message: "Plugin version must be in the format YYYY.MM.DD.HHMM",
|
||||
}),
|
||||
releaseNotesPath: z.string().optional(),
|
||||
});
|
||||
|
||||
const pluginEnvSchema = safeParseEnvSchema.extend({
|
||||
releaseNotes: z.string().nonempty("Release notes are required"),
|
||||
txzSha256: z.string().refine((val) => val.length === 64, {
|
||||
message: "TXZ SHA256 must be 64 characters long",
|
||||
}),
|
||||
});
|
||||
|
||||
export type PluginEnv = z.infer<typeof pluginEnvSchema>;
|
||||
|
||||
export const validatePluginEnv = async (
|
||||
envArgs: Record<string, any>
|
||||
): Promise<PluginEnv> => {
|
||||
const safeEnv = safeParseEnvSchema.parse(envArgs);
|
||||
if (safeEnv.releaseNotesPath) {
|
||||
await access(safeEnv.releaseNotesPath, constants.F_OK);
|
||||
const releaseNotes = await readFile(safeEnv.releaseNotesPath, "utf8");
|
||||
if (!releaseNotes || releaseNotes.length === 0) {
|
||||
throw new Error(
|
||||
`Release notes file is empty: ${safeEnv.releaseNotesPath}`
|
||||
);
|
||||
}
|
||||
envArgs.releaseNotes = releaseNotes;
|
||||
} else {
|
||||
envArgs.releaseNotes =
|
||||
process.env.TEST === "true"
|
||||
? "FAST_TEST_CHANGELOG"
|
||||
: await getStagingChangelogFromGit(safeEnv);
|
||||
}
|
||||
|
||||
if (safeEnv.txzPath) {
|
||||
await access(safeEnv.txzPath, constants.F_OK);
|
||||
console.log("Reading txz file from:", safeEnv.txzPath);
|
||||
const txzFile = await readFile(safeEnv.txzPath);
|
||||
if (!txzFile || txzFile.length === 0) {
|
||||
throw new Error(`TXZ Path is empty: ${safeEnv.txzPath}`);
|
||||
}
|
||||
envArgs.txzSha256 = getSha256(txzFile);
|
||||
}
|
||||
|
||||
const validatedEnv = pluginEnvSchema.parse(envArgs);
|
||||
|
||||
if (validatedEnv.tag) {
|
||||
console.warn("Tag is set, will generate a TAGGED build");
|
||||
}
|
||||
|
||||
return validatedEnv;
|
||||
};
|
||||
|
||||
export const getPluginVersion = () => {
|
||||
const now = new Date();
|
||||
|
||||
const formatUtcComponent = (component: number) => String(component).padStart(2, '0');
|
||||
|
||||
const year = now.getUTCFullYear();
|
||||
const month = formatUtcComponent(now.getUTCMonth() + 1);
|
||||
const day = formatUtcComponent(now.getUTCDate());
|
||||
const hour = formatUtcComponent(now.getUTCHours());
|
||||
const minute = formatUtcComponent(now.getUTCMinutes());
|
||||
|
||||
const version = `${year}.${month}.${day}.${hour}${minute}`;
|
||||
console.log("Plugin version:", version);
|
||||
return version;
|
||||
};
|
||||
|
||||
export const setupPluginEnv = async (argv: string[]): Promise<PluginEnv> => {
|
||||
// CLI setup for plugin environment
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.requiredOption(
|
||||
"--base-url <url>",
|
||||
"Base URL - will be used to determine the bucket, and combined with the tag (if set) to form the final URL",
|
||||
process.env.CI === "true"
|
||||
? "This is a CI build, please set the base URL manually"
|
||||
: `http://${process.env.HOST_LAN_IP}:8080`
|
||||
)
|
||||
.option(
|
||||
"--txz-path <path>",
|
||||
"Path to built package, will be used to generate the SHA256 and renamed with the plugin version",
|
||||
getTxzPath({ startingDir: process.cwd() })
|
||||
)
|
||||
.option(
|
||||
"--plugin-version <version>",
|
||||
"Plugin Version in the format YYYY.MM.DD.HHMM",
|
||||
getPluginVersion()
|
||||
)
|
||||
.option("--tag <tag>", "Tag (used for PR and staging builds)", process.env.TAG)
|
||||
.option("--release-notes-path <path>", "Path to release notes file")
|
||||
.option("--ci", "CI mode", process.env.CI === "true")
|
||||
.parse(argv);
|
||||
|
||||
const options = program.opts();
|
||||
console.log("Options:", options);
|
||||
const env = await validatePluginEnv(options);
|
||||
console.log("Plugin environment setup successfully:", env);
|
||||
return env;
|
||||
};
|
||||
|
||||
function getSha256(txzBlob: Buffer): string {
|
||||
return createHash("sha256").update(txzBlob).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { join } from "path";
|
||||
import { z } from "zod";
|
||||
import { Command } from "commander";
|
||||
import { startingDir } from "../utils/consts";
|
||||
import { deployDir } from "../utils/paths";
|
||||
|
||||
const txzEnvSchema = z.object({
|
||||
ci: z.boolean().optional().default(false),
|
||||
skipValidation: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("false")
|
||||
.refine((v) => v === "true" || v === "false", "Must be true or false"),
|
||||
compress: z.string().optional().default("1"),
|
||||
txzOutputDir: z.string().optional().default(join(startingDir, deployDir)),
|
||||
});
|
||||
|
||||
export type TxzEnv = z.infer<typeof txzEnvSchema>;
|
||||
|
||||
export const validateTxzEnv = async (
|
||||
envArgs: Record<string, any>
|
||||
): Promise<TxzEnv> => {
|
||||
const validatedEnv = txzEnvSchema.parse(envArgs);
|
||||
|
||||
if ("skipValidation" in validatedEnv) {
|
||||
console.warn("skipValidation is true, skipping validation");
|
||||
}
|
||||
|
||||
return validatedEnv;
|
||||
};
|
||||
|
||||
export const setupTxzEnv = async (argv: string[]): Promise<TxzEnv> => {
|
||||
// CLI setup for TXZ environment
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.option("--skip-validation", "Skip validation", "false")
|
||||
.option("--ci", "CI mode", process.env.CI === "true")
|
||||
.option("--compress, -z", "Compress level", "1")
|
||||
|
||||
.parse(argv);
|
||||
|
||||
const options = program.opts();
|
||||
|
||||
const env = await validateTxzEnv(options);
|
||||
console.log("TXZ environment setup successfully:", env);
|
||||
return env;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getTxzName, LOCAL_BUILD_TAG, pluginNameWithExt } from "./consts";
|
||||
|
||||
// Define a common interface for URL parameters
|
||||
interface UrlParams {
|
||||
baseUrl: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface TxzUrlParams extends UrlParams {
|
||||
pluginVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bucket path for the given tag
|
||||
* ex. baseUrl = https://stable.dl.unraid.net/unraid-api
|
||||
* ex. tag = PR123
|
||||
* ex. returns = https://stable.dl.unraid.net/unraid-api/tag/PR123
|
||||
*/
|
||||
const getRootBucketPath = ({ baseUrl, tag }: UrlParams): URL => {
|
||||
// Append tag to the baseUrl if tag is set, otherwise return the baseUrl
|
||||
const url = new URL(baseUrl);
|
||||
if (tag && tag !== LOCAL_BUILD_TAG) {
|
||||
// Ensure the path ends with a trailing slash before adding the tag
|
||||
url.pathname = url.pathname.replace(/\/?$/, "/") + "tag/" + tag;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the URL for the plugin file
|
||||
* ex. returns = BASE_URL/TAG/dynamix.unraid.net.plg
|
||||
*/
|
||||
export const getPluginUrl = (params: UrlParams): string => {
|
||||
const rootUrl = getRootBucketPath(params);
|
||||
// Ensure the path ends with a slash and join with the plugin name
|
||||
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + pluginNameWithExt;
|
||||
return rootUrl.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the URL for the main TXZ file
|
||||
* ex. returns = BASE_URL/TAG/dynamix.unraid.net-4.1.3.txz
|
||||
*/
|
||||
export const getMainTxzUrl = (params: TxzUrlParams): string => {
|
||||
const rootUrl = getRootBucketPath(params);
|
||||
// Ensure the path ends with a slash and join with the txz name
|
||||
rootUrl.pathname = rootUrl.pathname.replace(/\/?$/, "/") + getTxzName(params.pluginVersion);
|
||||
return rootUrl.toString();
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import conventionalChangelog from "conventional-changelog";
|
||||
|
||||
import { PluginEnv } from "../cli/setup-plugin-environment";
|
||||
|
||||
export const getStagingChangelogFromGit = async ({
|
||||
pluginVersion,
|
||||
tag,
|
||||
}: Pick<PluginEnv, "pluginVersion" | "tag">): Promise<string> => {
|
||||
try {
|
||||
const changelogStream = conventionalChangelog(
|
||||
{
|
||||
preset: "conventionalcommits",
|
||||
},
|
||||
{
|
||||
version: pluginVersion,
|
||||
},
|
||||
tag
|
||||
? {
|
||||
from: "origin/main",
|
||||
to: "HEAD",
|
||||
}
|
||||
: {},
|
||||
undefined,
|
||||
tag
|
||||
? {
|
||||
headerPartial: `## [${tag}](https://github.com/unraid/api/${tag})\n\n`,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
let changelog = "";
|
||||
for await (const chunk of changelogStream) {
|
||||
changelog += chunk;
|
||||
}
|
||||
// Encode HTML entities using the 'he' library
|
||||
return changelog ?? "";
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to get changelog from git: ${err}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { deployDir } from "./paths";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { startingDir } from "./consts";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const cleanupTxzFiles = async () => {
|
||||
await mkdir(deployDir, { recursive: true });
|
||||
const txzFiles = join(startingDir, deployDir, "*.txz");
|
||||
await execSync(`rm -rf ${txzFiles}`);
|
||||
};
|
||||
|
||||
export const cleanupPluginFiles = async () => {
|
||||
await mkdir(deployDir, { recursive: true });
|
||||
const pluginFiles = join(startingDir, deployDir, "*.plg");
|
||||
await execSync(`rm -rf ${pluginFiles}`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const pluginName = "dynamix.unraid.net" as const;
|
||||
export const pluginNameWithExt = `${pluginName}.plg` as const;
|
||||
|
||||
export const getTxzName = (version?: string) =>
|
||||
version ? `${pluginName}-${version}.txz` : `${pluginName}.txz`;
|
||||
export const startingDir = process.cwd();
|
||||
|
||||
export const BASE_URLS = {
|
||||
STABLE: "https://stable.dl.unraid.net/unraid-api",
|
||||
PREVIEW: "https://preview.dl.unraid.net/unraid-api",
|
||||
} as const;
|
||||
|
||||
export const LOCAL_BUILD_TAG = "LOCAL_PLUGIN_BUILD" as const;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { join } from "path";
|
||||
import { getTxzName, pluginNameWithExt } from "./consts";
|
||||
|
||||
export interface PathConfig {
|
||||
startingDir: string;
|
||||
}
|
||||
|
||||
export interface TxzPathConfig extends PathConfig {
|
||||
pluginVersion?: string;
|
||||
}
|
||||
|
||||
export const deployDir = "deploy" as const;
|
||||
|
||||
/**
|
||||
* Get the path to the root plugin directory
|
||||
* @param startingDir - The starting directory
|
||||
* @returns The path to the root plugin directory
|
||||
*/
|
||||
export function getRootPluginPath({ startingDir }: PathConfig): string {
|
||||
return join(startingDir, "/plugins/", pluginNameWithExt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the deploy plugin directory
|
||||
* @param startingDir - The starting directory
|
||||
* @returns The path to the deploy plugin directory
|
||||
*/
|
||||
export function getDeployPluginPath({ startingDir }: PathConfig): string {
|
||||
return join(startingDir, deployDir, pluginNameWithExt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the TXZ file
|
||||
* @param startingDir - The starting directory
|
||||
* @param pluginVersion - The plugin version
|
||||
* @returns The path to the TXZ file
|
||||
*/
|
||||
export function getTxzPath({
|
||||
startingDir,
|
||||
pluginVersion,
|
||||
}: TxzPathConfig): string {
|
||||
return join(startingDir, deployDir, getTxzName(pluginVersion));
|
||||
}
|
||||
Reference in New Issue
Block a user