enhance release v2 experience

This commit is contained in:
ajnart committed 2026-08-14 02:18:46 +02:00
1 parent 9b4970583e
commit ef2ed6c602
152 files changed
+7236 -764

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
import { resolve } from "node:path";
import { checkFeatureContracts } from "./feature-platform/contracts.mts";
const root = resolve(import.meta.dirname, "..");
const problems = checkFeatureContracts(root);
if (problems.length > 0) {
console.error("Feature contract check failed:");
for (const problem of problems) console.error(`\n- ${problem.message}\n Repair: ${problem.repair}`);
process.exitCode = 1;
} else {
console.log("Feature contracts are consistent.");
}
+25
View File
@@ -0,0 +1,25 @@
import { resolve } from "node:path";
import {
createFeatureCheckPlan,
executeFeatureCheckPlan,
formatFeatureCheckPlan,
} from "./feature-platform/check-feature.mts";
const args = process.argv.slice(2);
const planOnly = args.includes("--plan") || args.includes("--dry-run");
const kind = args.find((arg) => !arg.startsWith("--"));
if (!kind) {
console.error("Usage: pnpm check:feature <integration-or-widget-kind> [--plan]");
process.exitCode = 1;
} else {
try {
const plan = createFeatureCheckPlan(resolve(import.meta.dirname, ".."), kind);
console.log(formatFeatureCheckPlan(plan));
if (!planOnly) process.exitCode = executeFeatureCheckPlan(plan);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { resolve } from "node:path";
import { createFeatureCheckPlan, executeFeatureCheckPlan, formatFeatureCheckPlan } from "./check-feature.mts";
const root = resolve(import.meta.dirname, "../..");
test("plans a focused standalone widget check deterministically", () => {
const plan = createFeatureCheckPlan(root, "clock");
assert.deepEqual(plan, createFeatureCheckPlan(root, "clock"));
assert.deepEqual(plan.types, ["widget"]);
assert.ok(plan.files.includes("packages/widgets/src/clock/index.ts"));
assert.ok(plan.files.includes("apps/docs/docs/widgets/clock/index.mdx"));
assert.ok(plan.files.includes("packages/translation/src/lang/en.json"));
assert.ok(plan.commands.some((command) => command.args.includes("packages/widgets/src/manifest.spec.ts")));
assert.match(formatFeatureCheckPlan(plan), /LIVE SERVICE \(optional, not run\)/);
assert.match(formatFeatureCheckPlan(plan), /VISUAL \(optional, not run\)/);
});
test("combines integration and widget checks for a shared kind", () => {
const plan = createFeatureCheckPlan(root, "wud");
assert.deepEqual(plan.types, ["integration", "widget"]);
assert.ok(plan.files.includes("packages/integrations/src/wud/wud-integration.ts"));
assert.ok(plan.files.includes("packages/widgets/src/wud/index.ts"));
assert.ok(plan.files.includes("apps/docs/docs/integrations/whats-up-docker/index.mdx"));
assert.ok(plan.files.includes("apps/docs/docs/widgets/whats-up-docker/index.mdx"));
assert.ok(
plan.commands.some((command) =>
command.args.includes("packages/integrations/src/wud/test/wud-integration.spec.ts"),
),
);
});
test("rejects unknown kinds before creating a command plan", () => {
assert.throws(() => createFeatureCheckPlan(root, "notAFeature"), /Unknown feature kind/);
});
test("executes commands in order and stops on the first failure", () => {
const plan = createFeatureCheckPlan(root, "clock");
const visited: string[] = [];
const status = executeFeatureCheckPlan(plan, (command) => {
visited.push(command.label);
return visited.length === 3 ? 7 : 0;
});
assert.equal(status, 7);
assert.deepEqual(
visited,
plan.commands.slice(0, 3).map((command) => command.label),
);
});
+207
View File
@@ -0,0 +1,207 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { readArrayRegistry, readObjectRegistry } from "./contracts.mts";
export interface FeatureCheckCommand {
label: string;
executable: string;
args: string[];
}
export interface FeatureCheckPlan {
kind: string;
types: ("integration" | "widget")[];
files: string[];
commands: FeatureCheckCommand[];
optionalValidations: string[];
}
const walkFiles = (directory: string): string[] => {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? walkFiles(path) : [path];
});
};
const directString = (value: string | undefined) => value?.match(/^"([^"]+)"/)?.[1];
const resolveSourceImport = (root: string, importer: string, importPath: string) => {
const base = relative(root, resolve(root, dirname(importer), importPath));
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) {
if (existsSync(join(root, candidate))) return candidate;
}
return null;
};
const integrationImplementation = (root: string, kind: string) => {
const creatorPath = "packages/integrations/src/base/creator.ts";
const creatorSource = readFileSync(join(root, creatorPath), "utf8");
const creator = readObjectRegistry(root, creatorPath, "integrationCreators")
.get(kind)
?.match(/[A-Za-z_$][\w$]*/)?.[0];
if (!creator) return null;
const escapedCreator = creator.replaceAll("$", "\\$");
const importPath = creatorSource.match(
new RegExp(`import \\{[^}]*\\b${escapedCreator}\\b[^}]*\\} from "([^"]+)"`),
)?.[1];
return importPath ? resolveSourceImport(root, creatorPath, importPath) : null;
};
const widgetImplementation = (root: string, kind: string) => {
const value = readObjectRegistry(root, "packages/widgets/src/manifest.ts", "moduleLoaders").get(kind);
const importPath = value?.match(/import\("([^"]+)"\)/)?.[1];
return importPath ? resolveSourceImport(root, "packages/widgets/src/manifest.ts", importPath) : null;
};
const specsNear = (root: string, implementation: string | null) => {
if (!implementation) return [];
return walkFiles(dirname(join(root, implementation)))
.filter((path) => /\.spec\.tsx?$/.test(path))
.map((path) => relative(root, path))
.toSorted();
};
const packageCommand = (label: string, packages: string[]): FeatureCheckCommand => ({
label,
executable: "pnpm",
args: [...packages.flatMap((name) => ["--filter", name]), "typecheck"],
});
export const createFeatureCheckPlan = (root: string, kind: string): FeatureCheckPlan => {
const integrationKinds = new Set(
readObjectRegistry(root, "packages/definitions/src/integration.ts", "integrationDefs").keys(),
);
const widgetKinds = new Set(readArrayRegistry(root, "packages/definitions/src/widget.ts", "widgetKinds"));
const types: FeatureCheckPlan["types"] = [];
if (integrationKinds.has(kind)) types.push("integration");
if (widgetKinds.has(kind)) types.push("widget");
if (types.length === 0) {
throw new Error(`Unknown feature kind ${JSON.stringify(kind)}. Use a key from integrationDefs or widgetKinds.`);
}
const files = new Set<string>(["packages/translation/src/lang/en.json"]);
const tests = new Set<string>();
const packages = new Set(["@homarr/definitions", "@homarr/translation"]);
if (types.includes("integration")) {
const implementation = integrationImplementation(root, kind);
if (implementation) files.add(implementation);
specsNear(root, implementation).forEach((path) => tests.add(path));
tests.add("packages/integrations/src/base/response-contract.spec.ts");
packages.add("@homarr/integrations");
for (const path of [
"packages/definitions/src/integration.ts",
"packages/definitions/src/docs/integration-doc-slugs.ts",
"packages/integrations/src/base/creator.ts",
"packages/integrations/src/index.ts",
])
files.add(path);
const slug = directString(
readObjectRegistry(root, "packages/definitions/src/docs/integration-doc-slugs.ts", "integrationDocSlugs").get(
kind,
),
);
if (slug) {
packages.add("@homarr/docs");
files.add(`apps/docs/docs/integrations/${slug}/index.ts`);
files.add(`apps/docs/docs/integrations/${slug}/index.mdx`);
}
}
if (types.includes("widget")) {
const implementation = widgetImplementation(root, kind);
if (implementation) {
const directory = dirname(join(root, implementation));
walkFiles(directory)
.filter((path) => /\.(?:ts|tsx|css)$/.test(path))
.map((path) => relative(root, path))
.forEach((path) => files.add(path));
}
specsNear(root, implementation).forEach((path) => tests.add(path));
tests.add("packages/widgets/src/manifest.spec.ts");
tests.add("packages/definitions/src/test/widget-integration-map.spec.ts");
packages.add("@homarr/widgets");
for (const path of [
"packages/definitions/src/widget.ts",
"packages/definitions/src/widget-integration-map.ts",
"packages/definitions/src/docs/widget-doc-slugs.ts",
"packages/widgets/src/manifest.ts",
"packages/widgets/src/catalog.ts",
"packages/widgets/src/index.tsx",
])
files.add(path);
const slug = directString(
readObjectRegistry(root, "packages/definitions/src/docs/widget-doc-slugs.ts", "widgetDocSlugs").get(kind),
);
if (slug) {
packages.add("@homarr/docs");
const metadataTs = `apps/docs/docs/widgets/${slug}/index.ts`;
files.add(existsSync(join(root, metadataTs)) ? metadataTs : `${metadataTs}x`);
files.add(`apps/docs/docs/widgets/${slug}/index.mdx`);
}
}
const sortedTests = [...tests].filter((path) => existsSync(join(root, path))).toSorted();
sortedTests.forEach((path) => files.add(path));
const sortedFiles = [...files].filter((path) => existsSync(join(root, path))).toSorted();
const lintFiles = sortedFiles.filter((path) => /\.(?:ts|tsx|mts)$/.test(path));
const sortedPackages = [...packages].toSorted();
const commands: FeatureCheckCommand[] = [
{
label: "Descriptor, docs, translation, and export contracts",
executable: "node",
args: ["--experimental-strip-types", "scripts/check-feature-contracts.mts"],
},
{ label: "Focused fixture and unit tests", executable: "pnpm", args: ["exec", "vitest", "run", ...sortedTests] },
packageCommand("Affected package typechecks", sortedPackages),
{ label: "Affected file lint", executable: "pnpm", args: ["exec", "oxlint", ...lintFiles] },
{ label: "Affected file formatting", executable: "pnpm", args: ["exec", "oxfmt", "--check", ...sortedFiles] },
];
return {
kind,
types,
files: sortedFiles,
commands,
optionalValidations: [
"LIVE SERVICE (optional, not run): exercise test-connection and feature requests against a disposable real service with non-production credentials.",
"CONTAINER/E2E (optional, not run): validate the supported service image and network path when the feature depends on container-specific behavior.",
...(types.includes("widget")
? [
"VISUAL (optional, not run): inspect shared states at /manage/tools/feature-workbench and the feature at compact and advanced sizes.",
]
: []),
],
};
};
const displayCommand = (command: FeatureCheckCommand) =>
[command.executable, ...command.args]
.map((part) => (/^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`))
.join(" ");
export const formatFeatureCheckPlan = (plan: FeatureCheckPlan) => {
const lines = [`Feature check plan for ${plan.kind} (${plan.types.join(" + ")}):`, "", "Affected files:"];
lines.push(...plan.files.map((path) => `- ${path}`), "", "Checks:");
lines.push(...plan.commands.map((command, index) => `${index + 1}. ${command.label}\n ${displayCommand(command)}`));
lines.push("", "Optional validation:", ...plan.optionalValidations.map((validation) => `- ${validation}`));
return lines.join("\n");
};
export type FeatureCommandRunner = (command: FeatureCheckCommand) => number;
export const executeFeatureCheckPlan = (
plan: FeatureCheckPlan,
runner: FeatureCommandRunner = (command) =>
spawnSync(command.executable, command.args, { stdio: "inherit" }).status ?? 1,
) => {
for (const command of plan.commands) {
console.log(`\n[check:feature] ${command.label}`);
const status = runner(command);
if (status !== 0) return status;
}
return 0;
};
+298
View File
@@ -0,0 +1,298 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
export interface ContractProblem {
message: string;
repair: string;
}
const read = (root: string, path: string) => readFileSync(join(root, path), "utf8");
const containerBody = (source: string, variable: string, opener: "{" | "[") => {
const declaration = source.indexOf(`const ${variable}`);
if (declaration < 0) throw new Error(`Could not find const ${variable}`);
const start = source.indexOf(opener, declaration);
if (start < 0) throw new Error(`Could not find ${opener} for ${variable}`);
const closer = opener === "{" ? "}" : "]";
let depth = 0;
let quote = "";
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = "";
continue;
}
if (character === '"' || character === "'" || character === "`") {
quote = character;
continue;
}
if (character === opener) depth += 1;
if (character === closer) depth -= 1;
if (depth === 0) return source.slice(start + 1, index);
}
throw new Error(`Could not find closing ${closer} for ${variable}`);
};
const objectEntries = (source: string, variable: string) => {
const body = containerBody(source, variable, "{");
const matches = [...body.matchAll(/^ (?:"([^"]+)"|([\w-]+))(?::\s|,\s*$)/gm)];
return new Map(
matches.map((match, index) => [
match[1] ?? match[2],
body.slice((match.index ?? 0) + match[0].length, matches[index + 1]?.index ?? body.length).trim(),
]),
);
};
const arrayStrings = (source: string, variable: string) =>
[...containerBody(source, variable, "[").matchAll(/^ "([^"]+)",?$/gm)].map((match) => match[1]);
export const readObjectRegistry = (root: string, path: string, variable: string) =>
objectEntries(read(root, path), variable);
export const readArrayRegistry = (root: string, path: string, variable: string) =>
arrayStrings(read(root, path), variable);
const compareKeys = (
problems: ContractProblem[],
expectedName: string,
expected: Iterable<string>,
actualName: string,
actual: Iterable<string>,
) => {
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
const missing = [...expectedSet].filter((key) => !actualSet.has(key));
const extra = [...actualSet].filter((key) => !expectedSet.has(key));
if (missing.length === 0 && extra.length === 0) return;
problems.push({
message: `${actualName} does not match ${expectedName} (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`,
repair: `Add or remove the named entries in ${actualName} so it has exactly the same keys as ${expectedName}.`,
});
};
const walkFiles = (directory: string): string[] => {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
if (entry.isDirectory() && [".git", ".turbo", "node_modules"].includes(entry.name)) return [];
const path = join(directory, entry.name);
return entry.isDirectory() ? walkFiles(path) : [path];
});
};
const directStringOrNull = (value: string | undefined) => {
if (!value) return undefined;
if (/^null,?$/.test(value)) return null;
return value.match(/^"([^"]+)"/)?.[1];
};
const documentationPath = (definition: string | undefined) => {
if (!definition) return undefined;
const value = definition.match(/^ documentationUrl: (.+),$/m)?.[1];
if (value === "null") return null;
return value?.match(/^createDocumentationLink\("([^"]+)"\)$/)?.[1];
};
const collectWidgetDefinitions = (root: string) => {
const definitions = new Map<string, { path: string; hasSupportedIntegrations: boolean }>();
for (const path of walkFiles(join(root, "packages/widgets/src"))) {
if (!/\/index\.tsx?$/.test(path)) continue;
const source = readFileSync(path, "utf8");
const kindConstants = new Map(
[...source.matchAll(/(?:export )?const (\w+) = "([^"]+)";/g)].map((match) => [match[1], match[2]]),
);
for (const match of source.matchAll(/createWidgetDefinition\(("([^"]+)"|(\w+)),\s*\{/g)) {
const body = source.slice(match.index, source.indexOf("}).withDynamicImport", match.index));
const kind = match[2] ?? kindConstants.get(match[3]);
if (!kind) continue;
definitions.set(kind, {
path: relative(root, path),
hasSupportedIntegrations: /\n supportedIntegrations:/.test(body),
});
}
}
return definitions;
};
const exportTargets = (value: unknown): string[] => {
if (typeof value === "string") return [value];
if (!value || typeof value !== "object") return [];
return Object.values(value).flatMap(exportTargets);
};
const escapeRegex = (value: string) => value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
const targetExists = (packageDirectory: string, target: string) => {
if (!target.startsWith("./")) return true;
const absoluteTarget = resolve(packageDirectory, target);
if (!target.includes("*")) return existsSync(absoluteTarget);
const wildcardIndex = absoluteTarget.indexOf("*");
const searchRoot = dirname(absoluteTarget.slice(0, wildcardIndex));
if (!existsSync(searchRoot)) return false;
const pattern = new RegExp(`^${escapeRegex(absoluteTarget).replaceAll("*", "[^/]+")}$`);
return walkFiles(searchRoot).some((path) => pattern.test(path));
};
export const checkPackageExports = (root: string): ContractProblem[] => {
const packageFiles = walkFiles(root).filter(
(path) => path.endsWith("package.json") && !path.includes("/node_modules/") && !path.includes("/.turbo/"),
);
const problems: ContractProblem[] = [];
for (const packageFile of packageFiles) {
const packageJson = JSON.parse(readFileSync(packageFile, "utf8")) as { exports?: unknown };
for (const target of exportTargets(packageJson.exports)) {
if (targetExists(dirname(packageFile), target)) continue;
problems.push({
message: `${relative(root, packageFile)} exports ${target}, but that target does not resolve`,
repair:
"Remove the stale export or add the intended source file; do not leave a public package path pointing at nothing.",
});
}
}
return problems;
};
export const checkFeatureContracts = (root: string): ContractProblem[] => {
const problems: ContractProblem[] = [];
const integrationDefinitions = objectEntries(
read(root, "packages/definitions/src/integration.ts"),
"integrationDefs",
);
const creatorKeys = objectEntries(
read(root, "packages/integrations/src/base/creator.ts"),
"integrationCreators",
).keys();
const integrationSlugs = objectEntries(
read(root, "packages/definitions/src/docs/integration-doc-slugs.ts"),
"integrationDocSlugs",
);
compareKeys(problems, "integrationDefs", integrationDefinitions.keys(), "integrationCreators", creatorKeys);
compareKeys(
problems,
"integrationDefs",
integrationDefinitions.keys(),
"integrationDocSlugs",
integrationSlugs.keys(),
);
for (const [kind, definition] of integrationDefinitions) {
const slug = directStringOrNull(integrationSlugs.get(kind));
const link = documentationPath(definition);
const expected = slug === null ? null : `/docs/integrations/${slug}`;
if (slug === undefined || link !== expected) {
problems.push({
message: `Integration ${kind} has docs slug ${String(slug)} but documentationUrl ${String(link)}`,
repair:
slug === null
? "Set documentationUrl to null."
: `Set documentationUrl to createDocumentationLink("${expected}").`,
});
}
if (slug !== null && slug !== undefined) {
for (const filename of ["index.ts", "index.mdx"]) {
const path = `apps/docs/docs/integrations/${slug}/${filename}`;
if (!existsSync(join(root, path)))
problems.push({
message: `Integration ${kind} points to missing ${path}`,
repair: `Add ${path} or remove the docs slug.`,
});
}
}
}
const widgetKinds = arrayStrings(read(root, "packages/definitions/src/widget.ts"), "widgetKinds");
const manifest = read(root, "packages/widgets/src/manifest.ts");
const definitions = collectWidgetDefinitions(root);
const widgetSlugs = objectEntries(read(root, "packages/definitions/src/docs/widget-doc-slugs.ts"), "widgetDocSlugs");
const support = objectEntries(
read(root, "packages/definitions/src/widget-integration-map.ts"),
"nativeFeatureCapabilities",
);
compareKeys(problems, "widgetKinds", widgetKinds, "moduleLoaders", objectEntries(manifest, "moduleLoaders").keys());
compareKeys(
problems,
"widgetKinds",
widgetKinds,
"componentLoaders",
objectEntries(manifest, "componentLoaders").keys(),
);
compareKeys(
problems,
"widgetKinds",
widgetKinds,
"widgetCatalogIcons",
objectEntries(read(root, "packages/widgets/src/catalog.ts"), "widgetCatalogIcons").keys(),
);
compareKeys(
problems,
"widgetKinds",
widgetKinds,
"widgetImports",
objectEntries(read(root, "packages/widgets/src/index.tsx"), "widgetImports").keys(),
);
compareKeys(problems, "widgetKinds", widgetKinds, "widget definitions", definitions.keys());
compareKeys(problems, "widgetKinds", widgetKinds, "widgetDocSlugs", widgetSlugs.keys());
const supportSource = read(root, "packages/definitions/src/widget-integration-map.ts");
if (!supportSource.includes("widgetIntegrationSupport = createWidgetIntegrationSupport()")) {
problems.push({
message: "widgetIntegrationSupport is not derived from nativeFeatureCapabilities",
repair: "Generate the forward widget support map from the canonical capability descriptor.",
});
}
if (!supportSource.includes("integrationWidgetSupport = createIntegrationWidgetSupport()")) {
problems.push({
message: "integrationWidgetSupport is not derived from nativeFeatureCapabilities",
repair: "Generate the reverse integration support map from the canonical capability descriptor.",
});
}
const translations = JSON.parse(read(root, "packages/translation/src/lang/en.json")) as {
widget?: Record<string, { name?: string; description?: string }>;
};
for (const kind of widgetKinds) {
const translation = translations.widget?.[kind];
if (!translation?.name?.trim() || !translation.description?.trim()) {
problems.push({
message: `Widget ${kind} is missing a non-empty English name or description`,
repair: `Add widget.${kind}.name and widget.${kind}.description to packages/translation/src/lang/en.json.`,
});
}
const slug = directStringOrNull(widgetSlugs.get(kind));
if (slug !== null && slug !== undefined) {
const metadataPath = `apps/docs/docs/widgets/${slug}/index.ts`;
if (!existsSync(join(root, metadataPath)) && !existsSync(join(root, `${metadataPath}x`)))
problems.push({
message: `Widget ${kind} points to missing ${metadataPath}(x)`,
repair: `Add ${metadataPath} or remove the docs slug.`,
});
const pagePath = `apps/docs/docs/widgets/${slug}/index.mdx`;
if (!existsSync(join(root, pagePath)))
problems.push({
message: `Widget ${kind} points to missing ${pagePath}`,
repair: `Add ${pagePath} or remove the docs slug.`,
});
}
const definition = definitions.get(kind);
if (definition?.hasSupportedIntegrations !== support.has(kind)) {
problems.push({
message: `Widget ${kind} integration support differs between ${definition?.path ?? "its definition"} and nativeFeatureCapabilities`,
repair:
"Declare supportedIntegrations in both the widget definition and nativeFeatureCapabilities, or in neither.",
});
}
}
for (const kind of support.keys()) {
if (!widgetKinds.includes(kind))
problems.push({
message: `nativeFeatureCapabilities contains unknown widget ${kind}`,
repair: "Remove it or add the widget kind first.",
});
}
problems.push(...checkPackageExports(root));
return problems;
};
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { resolve } from "node:path";
import { checkFeatureContracts } from "./contracts.mts";
import { planFeatureGeneration } from "./generate-feature.mts";
import "./check-feature.contract.mts";
const root = resolve(import.meta.dirname, "../..");
test("the checked-in feature contracts are consistent", () => {
assert.deepEqual(checkFeatureContracts(root), []);
});
test("paired generation plans every contract without writing", () => {
const changes = planFeatureGeneration(root, {
integration: {
kind: "exampleService",
name: "Example Service",
slug: "example-service",
description: "Example native integration.",
category: "miscellaneous",
secretKinds: ["apiKey"],
iconUrl: "https://example.com/icon.svg",
},
widget: {
kind: "exampleStatus",
name: "Example Status",
slug: "example-status",
description: "Example native widget.",
icon: "IconBox",
supportedIntegrations: ["exampleService"],
},
});
assert.equal(changes.length, 20);
assert.equal(new Set(changes.map((change) => change.path)).size, changes.length);
assert.ok(changes.some((change) => change.path.endsWith("example-service-integration.spec.ts")));
assert.ok(changes.some((change) => change.path.endsWith("example-status/definition.spec.ts")));
assert.ok(changes.some((change) => change.path === "apps/docs/docs/integrations/example-service/index.mdx"));
assert.ok(changes.some((change) => change.path === "apps/docs/docs/widgets/example-status/index.mdx"));
const integrationTest = changes.find((change) => change.path.endsWith("example-service-integration.spec.ts"));
assert.match(integrationTest?.content ?? "", /simulateResponseContractAsync/);
const widgetDefinition = changes.find((change) => change.path.endsWith("example-status/index.ts"));
assert.match(widgetDefinition?.content ?? "", /nativeFeatureCapabilities\.exampleStatus\.integrations/);
const capabilityDescriptor = changes.find(
(change) => change.path === "packages/definitions/src/widget-integration-map.ts",
);
assert.match(capabilityDescriptor?.content ?? "", /exampleStatus: \{ integrations: \["exampleService"\] \}/);
const translations = changes.find((change) => change.path === "packages/translation/src/lang/en.json");
assert.ok(translations);
const parsed = JSON.parse(translations.content) as { integration: object; widget: object };
assert.ok("exampleService" in parsed.integration);
assert.ok("exampleStatus" in parsed.widget);
});
test("generation stops before overwriting an existing feature", () => {
assert.throws(
() =>
planFeatureGeneration(root, {
integration: {
kind: "wud",
name: "Duplicate",
slug: "wud",
description: "Must not overwrite WUD.",
category: "miscellaneous",
secretKinds: [],
iconUrl: "https://example.com/icon.svg",
},
}),
/already exists/,
);
});
@@ -0,0 +1,454 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { mkdirSync } from "node:fs";
export interface IntegrationFeature {
kind: string;
name: string;
slug: string;
description: string;
category: string;
secretKinds: string[];
iconUrl: string;
}
export interface WidgetFeature {
kind: string;
name: string;
slug: string;
description: string;
icon: string;
supportedIntegrations: string[];
}
export interface FeatureRequest {
integration?: IntegrationFeature;
widget?: WidgetFeature;
}
export interface PlannedChange {
path: string;
content: string;
action: "create" | "update";
}
const pascalCase = (value: string) =>
value.replace(/(^|-)([a-z])/g, (_, _separator, letter: string) => letter.toUpperCase());
const identifier = (value: string) => value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
const key = (value: string) => (/^[A-Za-z_$][\w$]*$/.test(value) ? value : JSON.stringify(value));
const read = (root: string, path: string) => readFileSync(join(root, path), "utf8");
const containerRange = (source: string, marker: string, opener: "{" | "[") => {
const declaration = source.indexOf(marker);
if (declaration < 0) throw new Error(`Generator stopped: expected marker ${marker}`);
const start = source.indexOf(opener, declaration);
if (start < 0) throw new Error(`Generator stopped: expected ${opener} after ${marker}`);
const closer = opener === "{" ? "}" : "]";
let depth = 0;
let quote = "";
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = "";
continue;
}
if (character === '"' || character === "'" || character === "`") quote = character;
else if (character === opener) depth += 1;
else if (character === closer && --depth === 0) return { start, end: index };
}
throw new Error(`Generator stopped: could not find closing ${closer} for ${marker}`);
};
const appendToContainer = (source: string, marker: string, opener: "{" | "[", entry: string) => {
const { end } = containerRange(source, marker, opener);
return `${source.slice(0, end)}${entry}${source.slice(end)}`;
};
const insertBeforeUnique = (source: string, anchor: string, entry: string, path: string) => {
const first = source.indexOf(anchor);
if (first < 0 || source.indexOf(anchor, first + anchor.length) >= 0) {
throw new Error(
`Generator stopped: expected exactly one ${JSON.stringify(anchor)} in ${path}; update the generator anchor.`,
);
}
return `${source.slice(0, first)}${entry}${source.slice(first)}`;
};
const addJsonProperty = (source: string, property: "integration" | "widget", name: string, value: unknown) => {
const marker = `\n "${property}":`;
const { start, end } = containerRange(source, marker, "{");
const body = source.slice(start + 1, end);
if (new RegExp(`^ ${JSON.stringify(name)}:`, "m").test(body)) {
throw new Error(`Generator stopped: translation ${property}.${name} already exists.`);
}
const serialized = JSON.stringify(value, null, 2)
.split("\n")
.map((line, index) => (index === 0 ? line : ` ${line}`))
.join("\n");
const beforeClose = source.slice(0, end);
const trimmed = beforeClose.trimEnd();
return `${trimmed},\n ${JSON.stringify(name)}: ${serialized}\n ${source.slice(end)}`;
};
const addChange = (
changes: PlannedChange[],
root: string,
path: string,
content: string,
action: PlannedChange["action"],
) => {
if (action === "create" && existsSync(join(root, path)))
throw new Error(`Generator stopped: ${path} already exists.`);
changes.push({ path, content, action });
};
const validate = (request: FeatureRequest) => {
if (!request.integration && !request.widget)
throw new Error("Generator stopped: choose an integration, a widget, or both.");
for (const feature of [request.integration, request.widget]) {
if (!feature) continue;
if (!/^[a-z][A-Za-z0-9]*$/.test(feature.kind))
throw new Error(`Generator stopped: ${feature.kind} must be lower camel case.`);
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(feature.slug))
throw new Error(`Generator stopped: ${feature.slug} must be kebab-case.`);
}
if (request.widget && !/^Icon[A-Z][A-Za-z0-9]+$/.test(request.widget.icon)) {
throw new Error(`Generator stopped: ${request.widget.icon} must be a Tabler icon export such as IconBox.`);
}
};
const planIntegration = (root: string, feature: IntegrationFeature, changes: PlannedChange[]) => {
const definitionSource = read(root, "packages/definitions/src/integration.ts");
const categoryRange = containerRange(definitionSource, "const integrationCategories", "[");
const categories = new Set(
[...definitionSource.slice(categoryRange.start, categoryRange.end).matchAll(/"([^"]+)"/g)].map((match) => match[1]),
);
if (!categories.has(feature.category)) {
throw new Error(`Generator stopped: ${feature.category} is not a current integration category.`);
}
const secretObject = definitionSource.slice(
definitionSource.indexOf("const integrationSecretKindObject"),
definitionSource.indexOf("satisfies Record<string", definitionSource.indexOf("const integrationSecretKindObject")),
);
const validSecrets = new Set([...secretObject.matchAll(/^ (\w+):/gm)].map((match) => match[1]));
const unknownSecrets = feature.secretKinds.filter((kind) => !validSecrets.has(kind));
if (unknownSecrets.length > 0) {
throw new Error(`Generator stopped: unknown credential kind(s): ${unknownSecrets.join(", ")}.`);
}
const className = `${pascalCase(feature.slug)}Integration`;
const implementationPath = `packages/integrations/src/${feature.slug}/${feature.slug}-integration.ts`;
const testPath = `packages/integrations/src/${feature.slug}/test/${feature.slug}-integration.spec.ts`;
const parserName = `parse${pascalCase(feature.slug)}ResponseAsync`;
const implementation = `import { z } from "zod/v4";
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";
// Replace this minimal shape with the upstream service contract used by the feature.
const responseSchema = z.object({});
export const ${parserName} = async (response: { json: () => Promise<unknown> }) =>
responseSchema.parse(await response.json());
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);
await ${parserName}(response);
return { success: true };
}
}
`;
const test = `import { describe, expect, test } from "vitest";
import { simulateResponseContractAsync } from "../../base/response-contract";
import { ${className}, ${parserName} } from "../${feature.slug}-integration";
describe("${className}", () => {
test("exposes its public integration data", () => {
const integration = new ${className}({
id: "test",
name: "${feature.name}",
url: "http://localhost",
externalUrl: null,
decryptedSecrets: [],
});
expect(integration.publicIntegration.name).toBe("${feature.name}");
});
test("parses documented response fixtures", async () => {
const results = await simulateResponseContractAsync(${parserName}, [
{ name: "minimal valid response", payload: {}, expected: {} },
{ name: "invalid response", payload: null, rejects: true },
]);
expect(results.every((result) => result.passed), results).toBe(true);
});
});
`;
addChange(changes, root, implementationPath, implementation, "create");
addChange(changes, root, testPath, test, "create");
const definitionPath = "packages/definitions/src/integration.ts";
let definitions = definitionSource;
if (new RegExp(`^ ${feature.kind}:`, "m").test(definitions)) {
throw new Error(`Generator stopped: integration ${feature.kind} already exists.`);
}
definitions = appendToContainer(
definitions,
"const integrationDefs",
"{",
` ${key(feature.kind)}: {\n name: ${JSON.stringify(feature.name)},\n secretKinds: [${JSON.stringify(feature.secretKinds)}],\n iconUrl: ${JSON.stringify(feature.iconUrl)},\n category: [${JSON.stringify(feature.category)}],\n documentationUrl: createDocumentationLink("/docs/integrations/${feature.slug}"),\n },\n`,
);
addChange(changes, root, definitionPath, definitions, "update");
const creatorPath = "packages/integrations/src/base/creator.ts";
let creator = read(root, creatorPath);
creator = insertBeforeUnique(
creator,
'import type { Integration, IntegrationInput } from "./integration";',
`import { ${className} } from "../${feature.slug}/${feature.slug}-integration";\n`,
creatorPath,
);
creator = appendToContainer(creator, "const integrationCreators", "{", ` ${key(feature.kind)}: ${className},\n`);
addChange(changes, root, creatorPath, creator, "update");
const indexPath = "packages/integrations/src/index.ts";
const integrationIndex = insertBeforeUnique(
read(root, indexPath),
"// Types",
`export { ${className} } from "./${feature.slug}/${feature.slug}-integration";\n\n`,
indexPath,
);
addChange(changes, root, indexPath, integrationIndex, "update");
const slugPath = "packages/definitions/src/docs/integration-doc-slugs.ts";
addChange(
changes,
root,
slugPath,
appendToContainer(
read(root, slugPath),
"const integrationDocSlugs",
"{",
` ${key(feature.kind)}: "${feature.slug}",\n`,
),
"update",
);
const translationPath = "packages/translation/src/lang/en.json";
addChange(
changes,
root,
translationPath,
addJsonProperty(read(root, translationPath), "integration", feature.kind, {
name: feature.name,
description: feature.description,
}),
"update",
);
const docsBase = `apps/docs/docs/integrations/${feature.slug}`;
addChange(
changes,
root,
`${docsBase}/index.ts`,
`import type { IntegrationDefinition } from "@site/src/types";\n\nexport const ${identifier(feature.kind)}Integration: IntegrationDefinition = {\n name: ${JSON.stringify(feature.name)},\n description: ${JSON.stringify(feature.description)},\n iconUrl: ${JSON.stringify(feature.iconUrl)},\n path: "../../integrations/${feature.slug}",\n};\n`,
"create",
);
addChange(
changes,
root,
`${docsBase}/index.mdx`,
`---\ntitle: ${JSON.stringify(feature.name)}\ndescription: ${JSON.stringify(feature.description)}\nhide_title: true\n---\n\nimport { AddingIntegration } from "@site/src/components/integrations/adding";\nimport { IntegrationHeader } from "@site/src/components/integrations/header";\nimport { ${identifier(feature.kind)}Integration } from ".";\n\n<IntegrationHeader integration={${identifier(feature.kind)}Integration} categories={[${JSON.stringify(feature.category)}]} />\n\n${feature.description}\n\n### Adding the integration\n\n<AddingIntegration />\n\n### Contributor checklist\n\nDocument credentials, supported widgets, API version requirements, and a tested example URL before merging.\n`,
"create",
);
};
const planWidget = (root: string, feature: WidgetFeature, changes: PlannedChange[]) => {
if (read(root, "packages/definitions/src/widget.ts").includes(` "${feature.kind}",`)) {
throw new Error(`Generator stopped: widget ${feature.kind} already exists.`);
}
const widgetPath = `packages/widgets/src/${feature.slug}`;
const supportedLine =
feature.supportedIntegrations.length > 0
? `\n supportedIntegrations: [...nativeFeatureCapabilities.${feature.kind}.integrations],`
: "";
const capabilityImport =
feature.supportedIntegrations.length > 0
? 'import { nativeFeatureCapabilities } from "@homarr/definitions";\n\n'
: "";
addChange(
changes,
root,
`${widgetPath}/index.ts`,
`import { ${feature.icon} } from "@tabler/icons-react";\n\n${capabilityImport}import { createWidgetDefinition } from "../definition";\nimport { optionsBuilder } from "../options";\n\nexport const { definition, componentLoader } = createWidgetDefinition("${feature.kind}", {\n icon: ${feature.icon},${supportedLine}\n createOptions() {\n return optionsBuilder.from(() => ({}));\n },\n}).withDynamicImport(() => import("./component"));\n`,
"create",
);
addChange(
changes,
root,
`${widgetPath}/component.tsx`,
`"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n <Center h="100%">\n <Text>${feature.name}</Text>\n </Center>\n );\n}\n`,
"create",
);
addChange(
changes,
root,
`${widgetPath}/definition.spec.ts`,
`import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe("${feature.name} widget definition", () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`,
"create",
);
const kindsPath = "packages/definitions/src/widget.ts";
addChange(
changes,
root,
kindsPath,
appendToContainer(read(root, kindsPath), "const widgetKinds", "[", ` "${feature.kind}",\n`),
"update",
);
const manifestPath = "packages/widgets/src/manifest.ts";
let manifest = read(root, manifestPath);
manifest = appendToContainer(
manifest,
"const moduleLoaders",
"{",
` ${key(feature.kind)}: () => import("./${feature.slug}") as Promise<WidgetModule>,\n`,
);
manifest = appendToContainer(
manifest,
"const componentLoaders",
"{",
` ${key(feature.kind)}: () => import("./${feature.slug}/component") as Promise<WidgetComponentModule>,\n`,
);
addChange(changes, root, manifestPath, manifest, "update");
const catalogPath = "packages/widgets/src/catalog.ts";
let catalog = read(root, catalogPath);
const iconBlockEnd = catalog.indexOf('} from "@tabler/icons-react";');
if (iconBlockEnd < 0) throw new Error(`Generator stopped: expected Tabler icon import block in ${catalogPath}.`);
if (!catalog.includes(` ${feature.icon},`)) {
catalog = `${catalog.slice(0, iconBlockEnd)} ${feature.icon},\n${catalog.slice(iconBlockEnd)}`;
}
catalog = appendToContainer(catalog, "const widgetCatalogIcons", "{", ` ${key(feature.kind)}: ${feature.icon},\n`);
addChange(changes, root, catalogPath, catalog, "update");
const indexPath = "packages/widgets/src/index.tsx";
const importName = identifier(feature.kind);
let index = insertBeforeUnique(
read(root, indexPath),
"export type {\n NormalizedWidgetQuery,",
`import * as ${importName} from "./${feature.slug}";\n\n`,
indexPath,
);
index = appendToContainer(index, "const widgetImports", "{", ` ${key(feature.kind)}: ${importName},\n`);
addChange(changes, root, indexPath, index, "update");
const slugPath = "packages/definitions/src/docs/widget-doc-slugs.ts";
addChange(
changes,
root,
slugPath,
appendToContainer(
read(root, slugPath),
"const widgetDocSlugs",
"{",
` ${key(feature.kind)}: "${feature.slug}",\n`,
),
"update",
);
if (feature.supportedIntegrations.length > 0) {
const supportPath = "packages/definitions/src/widget-integration-map.ts";
addChange(
changes,
root,
supportPath,
appendToContainer(
read(root, supportPath),
"const nativeFeatureCapabilities",
"{",
` ${key(feature.kind)}: { integrations: ${JSON.stringify(feature.supportedIntegrations)} },\n`,
),
"update",
);
}
const translationPath = "packages/translation/src/lang/en.json";
addChange(
changes,
root,
translationPath,
addJsonProperty(read(root, translationPath), "widget", feature.kind, {
name: feature.name,
description: feature.description,
}),
"update",
);
const docsBase = `apps/docs/docs/widgets/${feature.slug}`;
addChange(
changes,
root,
`${docsBase}/index.ts`,
`import type { WidgetDefinition } from "@site/src/types";\nimport { ${feature.icon} } from "@tabler/icons-react";\n\nexport const ${identifier(feature.kind)}Widget: WidgetDefinition = {\n icon: ${feature.icon},\n name: ${JSON.stringify(feature.name)},\n description: ${JSON.stringify(feature.description)},\n path: "../../widgets/${feature.slug}",\n configuration: { items: [] },\n};\n`,
"create",
);
addChange(
changes,
root,
`${docsBase}/index.mdx`,
`---\ntitle: ${JSON.stringify(feature.name)}\ndescription: ${JSON.stringify(feature.description)}\nhide_title: true\n---\n\nimport { AddingWidget } from "@site/src/components/widgets/adding";\nimport { WidgetHeader } from "@site/src/components/widgets/header";\nimport { ${identifier(feature.kind)}Widget } from ".";\n\n<WidgetHeader widget={${identifier(feature.kind)}Widget} categories={["Other"]} />\n\n${feature.description}\n\n### Adding the widget\n\n<AddingWidget />\n\n### Contributor checklist\n\nDocument configuration, supported integrations, empty/loading/error states, and screenshots before merging.\n`,
"create",
);
};
export const planFeatureGeneration = (root: string, request: FeatureRequest): PlannedChange[] => {
validate(request);
const changes: PlannedChange[] = [];
if (request.integration) planIntegration(root, request.integration, changes);
if (request.widget) {
const knownIntegrations = new Set(
[...read(root, "packages/definitions/src/integration.ts").matchAll(/^ (\w+): \{/gm)].map((match) => match[1]),
);
if (request.integration) knownIntegrations.add(request.integration.kind);
const unknown = request.widget.supportedIntegrations.filter((kind) => !knownIntegrations.has(kind));
if (unknown.length > 0)
throw new Error(`Generator stopped: unknown supported integration(s): ${unknown.join(", ")}.`);
const widgetChanges: PlannedChange[] = [];
planWidget(root, request.widget, widgetChanges);
for (const change of widgetChanges) {
const existing = changes.find((candidate) => candidate.path === change.path);
if (existing && change.path === "packages/translation/src/lang/en.json") {
existing.content = addJsonProperty(existing.content, "widget", request.widget.kind, {
name: request.widget.name,
description: request.widget.description,
});
} else {
changes.push(change);
}
}
}
const duplicatePaths = changes.filter(
(change, index) => changes.findIndex((candidate) => candidate.path === change.path) !== index,
);
if (duplicatePaths.length > 0) {
throw new Error(
`Generator stopped: combined generation would update ${duplicatePaths[0].path} twice; merge planning is not safe yet.`,
);
}
return changes;
};
export const generateFeature = (root: string, request: FeatureRequest) => {
const changes = planFeatureGeneration(root, request);
for (const change of changes) {
mkdirSync(dirname(join(root, change.path)), { recursive: true });
writeFileSync(join(root, change.path), change.content);
}
return changes;
};