refactor: finish feature module tooling

This commit is contained in:
ajnart committed 2026-08-23 22:39:06 +02:00
1 parent 425cb653a1
commit c988c35b29
7 files changed
+377 -111

No files matched your search

@@ -0,0 +1,120 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const integrationsRoot = path.join(repositoryRoot, "packages/integrations/src");
const readOption = (name: string) => {
const optionIndex = process.argv.indexOf(name);
if (optionIndex < 0) return undefined;
return process.argv[optionIndex + 1];
};
const kind = process.argv[2];
if (!kind || kind.startsWith("--")) {
throw new Error(
"Usage: pnpm integrations:new <kind> --icon-url <url> [--slug <docs-slug>] [--name <display-name>] [--category <category>] [--default-port <port>]",
);
}
if (!/^[a-z][A-Za-z0-9]*$/.test(kind)) {
throw new Error("Integration kind must be a stable lower-camel-case identifier");
}
const defaultSlug = kind.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
const slug = readOption("--slug") ?? defaultSlug;
const name =
readOption("--name") ??
slug
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
const category = readOption("--category") ?? "miscellaneous";
const iconUrl = readOption("--icon-url");
const defaultPortInput = readOption("--default-port");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error("Integration slug must use lower-case kebab-case");
if (!iconUrl || !URL.canParse(iconUrl)) throw new Error("--icon-url must be a valid absolute URL");
if (!/^[a-z][A-Za-z0-9]*$/.test(category)) throw new Error("Integration category must be a stable identifier");
let defaultPort: number | undefined;
if (defaultPortInput !== undefined) {
defaultPort = Number(defaultPortInput);
if (!Number.isSafeInteger(defaultPort) || defaultPort < 1 || defaultPort > 65_535) {
throw new Error("--default-port must be an integer between 1 and 65535");
}
}
const className = `${kind.charAt(0).toUpperCase()}${kind.slice(1)}Integration`;
const metadataExportName = `${kind}Integration`;
const directory = path.join(integrationsRoot, slug);
await mkdir(directory);
await mkdir(path.join(directory, "docs"));
const defaultPortLine = defaultPort === undefined ? "" : ` defaultPort: ${defaultPort},\n`;
const files = {
"module.ts": `import { defineIntegrationModule } from "@homarr/definitions";
export default defineIntegrationModule({
kind: ${JSON.stringify(kind)},
name: ${JSON.stringify(name)},
iconUrl: ${JSON.stringify(iconUrl)},
secretKinds: [[]],
categories: [${JSON.stringify(category)}],
${defaultPortLine} documentation: {
slug: ${JSON.stringify(slug)},
sourceDirectory: "docs",
},
creator: {
type: "constructor",
module: "./${slug}-integration",
exportName: ${JSON.stringify(className)},
},
});
`,
[`${slug}-integration.ts`]: `import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
export class ${className} extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/"));
if (!response.ok) return TestConnectionError.StatusResult(response);
return { success: true };
}
}
`,
"docs/index.ts": `export const ${metadataExportName} = {
name: ${JSON.stringify(name)},
description: ${JSON.stringify(`Connect Homarr to ${name}.`)},
iconUrl: ${JSON.stringify(iconUrl)},
path: "../../integrations/${slug}",
} as const;
`,
"docs/index.mdx": `---
title: ${JSON.stringify(name)}
description: ${JSON.stringify(`Connect Homarr to ${name}.`)}
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { ${metadataExportName} } from ".";
<IntegrationHeader integration={${metadataExportName}} categories={["Miscellaneous"]} />
Describe the service, supported Homarr widgets, required permissions, and setup prerequisites here.
### Adding the integration
<AddingIntegration />
`,
} as const;
for (const [relativePath, content] of Object.entries(files)) {
await writeFile(path.join(directory, relativePath), content, { flag: "wx" });
}
await import("./sync-integration-modules.mts");
console.log(`Created integration module ${kind} in ${path.relative(repositoryRoot, directory)}`);
@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { format } from "oxfmt";
import type { IntegrationModuleDefinition } from "../../packages/definitions/src/integration-module";
import { integrationCategories, integrationSecretKinds } from "../../packages/definitions/src/integration";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const integrationsRoot = path.join(repositoryRoot, "packages/integrations/src");
@@ -15,6 +16,8 @@ const integrationCreatorsSource = path.join(integrationsRoot, "base/creator.ts")
const checkOnly = process.argv.includes("--check");
const generatedHeader = "// This file is generated by pnpm integrations:sync. Do not edit it directly.\n\n";
const generatedDocsMarker = ".integration-module-generated";
const integrationCategorySet = new Set<string>(integrationCategories);
const integrationSecretKindSet = new Set<string>(integrationSecretKinds);
interface LoadedIntegrationModule {
definition: IntegrationModuleDefinition;
@@ -106,17 +109,35 @@ const validateIntegrationModuleAsync = async (module: LoadedIntegrationModule) =
if (!Array.isArray(definition.secretKinds) || definition.secretKinds.length === 0) {
throw new Error(`Integration ${definition.kind} must declare at least one secret alternative`);
}
for (const secretAlternative of definition.secretKinds) {
if (!Array.isArray(secretAlternative)) {
throw new Error(`Integration ${definition.kind} secret alternatives must be arrays`);
}
for (const secretKind of secretAlternative) {
if (!integrationSecretKindSet.has(secretKind)) {
throw new Error(`Integration ${definition.kind} has unknown secret kind ${secretKind}`);
}
}
}
if (!Array.isArray(definition.categories) || definition.categories.length === 0) {
throw new Error(`Integration ${definition.kind} must declare at least one category`);
}
for (const category of definition.categories) {
if (!integrationCategorySet.has(category)) {
throw new Error(`Integration ${definition.kind} has unknown category ${category}`);
}
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(definition.documentation.slug)) {
throw new Error(`Integration ${definition.kind} has invalid documentation slug ${definition.documentation.slug}`);
}
if (
definition.defaultPort !== undefined &&
(!Number.isSafeInteger(definition.defaultPort) || definition.defaultPort < 1)
(!Number.isSafeInteger(definition.defaultPort) || definition.defaultPort < 1 || definition.defaultPort > 65_535)
) {
throw new Error(`Integration ${definition.kind} default port must be a positive integer`);
throw new Error(`Integration ${definition.kind} default port must be an integer between 1 and 65535`);
}
if (definition.defaultUrl !== undefined && !URL.canParse(definition.defaultUrl)) {
throw new Error(`Integration ${definition.kind} default URL is invalid`);
}
const docsDirectory = path.resolve(module.directory, definition.documentation.sourceDirectory);
@@ -231,7 +252,14 @@ const renderDefinitionsRegistry = (modules: LoadedIntegrationModule[]) => {
const entries = modules
.map((module) => ` ${JSON.stringify(module.definition.kind)}: ${renderDefinition(module.definition)},`)
.join("\n");
return `${generatedHeader}export const generatedIntegrationDefinitions = {\n${entries}\n} as const;\n`;
const documentationSources = modules
.map((module) => {
const source = path.resolve(module.directory, module.definition.documentation.sourceDirectory);
const relativeSource = toPosixPath(path.relative(repositoryRoot, source));
return ` ${JSON.stringify(module.definition.documentation.slug)}: ${JSON.stringify(relativeSource)},`;
})
.join("\n");
return `${generatedHeader}export const generatedIntegrationDefinitions = {\n${entries}\n} as const;\n\nexport const generatedIntegrationDocumentationSources = {\n${documentationSources}\n} as const;\n`;
};
const getCreatorImportPath = (module: LoadedIntegrationModule) => {