feat(custom-widgets): add extensible widget platform
This commit is contained in:
1 parent
6cca757493
commit
8f152f1929
317 files changed
+32326
-1788
No files matched your search
@@ -15,7 +15,7 @@
|
||||
".": "./index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --define:import.meta.url=__filename --outfile=cli.cjs --external:bcrypt --external:cpu-features --external:esbuild --loader:.html=text --loader:.node=text",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
@@ -25,11 +25,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "catalog:",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/custom-widgets": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "catalog:"
|
||||
"@homarr/widget-sdk": "workspace:^0.1.0",
|
||||
"@trpc/client": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"superjson": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { readFile, stat, mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { command, positional, string } from "@drizzle-team/brocli";
|
||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import {
|
||||
customWidgetArchiveSchema,
|
||||
widgetCollectionConnectionsSchema,
|
||||
widgetCollectionEntrySchema,
|
||||
widgetCollectionKeySchema,
|
||||
widgetCollectionManifestSchema,
|
||||
WIDGET_COLLECTION_MAX_BYTES,
|
||||
} from "@homarr/custom-widgets/package";
|
||||
|
||||
const archiveInput = () => ({ file: positional("path").required().desc("Collection archive JSON") });
|
||||
const projectSchema = z.strictObject({
|
||||
manifest: widgetCollectionManifestSchema,
|
||||
connections: widgetCollectionConnectionsSchema.default({}),
|
||||
widgets: z
|
||||
.array(widgetCollectionEntrySchema.omit({ archive: true }).extend({ archive: z.string().min(1) }))
|
||||
.min(1)
|
||||
.max(32),
|
||||
});
|
||||
|
||||
async function readJson(path: string): Promise<unknown> {
|
||||
if ((await stat(path)).size > WIDGET_COLLECTION_MAX_BYTES) throw new Error("Collection input exceeds 128 MiB");
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
}
|
||||
|
||||
const pack = command({
|
||||
name: "pack",
|
||||
desc: "Combine compiled widget archives and shared connection requirements without executing code",
|
||||
options: {
|
||||
file: positional("path").required().desc("Collection manifest JSON with member archive file paths"),
|
||||
output: string("output").alias("o").required().desc("New output collection archive JSON"),
|
||||
},
|
||||
handler: async ({ file, output }) => {
|
||||
const { createWidgetCollectionArchive } = await import("@homarr/custom-widgets/package/server");
|
||||
const path = resolve(file);
|
||||
const project = projectSchema.parse(await readJson(path));
|
||||
const widgets = [];
|
||||
let totalBytes = (await stat(path)).size;
|
||||
for (const entry of project.widgets) {
|
||||
try {
|
||||
const memberPath = resolve(dirname(path), entry.archive);
|
||||
totalBytes += (await stat(memberPath)).size;
|
||||
if (totalBytes > WIDGET_COLLECTION_MAX_BYTES) throw new Error("Combined collection inputs exceed 128 MiB");
|
||||
const archive = customWidgetArchiveSchema.parse(await readJson(memberPath));
|
||||
widgets.push({ ...entry, archive });
|
||||
} catch (error) {
|
||||
throw new Error(`Widget '${entry.key}': ${error instanceof Error ? error.message : "Invalid archive"}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
const archive = createWidgetCollectionArchive({ ...project, format: "homarr-widget-collection-v1", widgets });
|
||||
const target = resolve(output);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, JSON.stringify(archive, null, 2), { flag: "wx", mode: 0o600 });
|
||||
console.log(`Packed ${archive.widgets.length} widgets: ${target}`);
|
||||
},
|
||||
});
|
||||
|
||||
const validate = command({
|
||||
name: "validate",
|
||||
desc: "Check collection structure, connection mapping and every artifact checksum",
|
||||
options: archiveInput(),
|
||||
handler: async ({ file }) => {
|
||||
const { loadWidgetCollectionArchive } = await import("@homarr/custom-widgets/package/server");
|
||||
const archive = await loadWidgetCollectionArchive(resolve(file));
|
||||
console.log(
|
||||
`Valid collection: ${archive.manifest.id}@${archive.manifest.version} (${archive.widgets.length} widgets)`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const install = command({
|
||||
name: "import",
|
||||
desc: "Import every collection member as a disabled draft in an installed Homarr",
|
||||
options: {
|
||||
...archiveInput(),
|
||||
url: string("url").desc("Homarr URL; defaults to HOMARR_URL"),
|
||||
keyEnv: string("api-key-env")
|
||||
.default("HOMARR_API_KEY")
|
||||
.desc("Environment variable containing an admin Homarr API key"),
|
||||
bindings: string("bindings").desc(
|
||||
"Local JSON mapping collection connection slots to existing Homarr connection IDs",
|
||||
),
|
||||
},
|
||||
handler: async ({ file, url, keyEnv, bindings }) => {
|
||||
const base = new URL(url ?? process.env.HOMARR_URL ?? "http://localhost:7575");
|
||||
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
|
||||
throw new Error("Use an HTTP(S) Homarr URL without embedded credentials");
|
||||
const apiKey = process.env[keyEnv];
|
||||
if (!apiKey) throw new Error(`Set ${keyEnv} to an admin API key from your Homarr instance`);
|
||||
const { loadWidgetCollectionArchive } = await import("@homarr/custom-widgets/package/server");
|
||||
const archive = await loadWidgetCollectionArchive(resolve(file));
|
||||
const mapping = z
|
||||
.record(widgetCollectionKeySchema, z.string().min(1))
|
||||
.parse(bindings ? await readJson(resolve(bindings)) : {});
|
||||
const client = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
url: new URL("api/trpc", `${base.href.replace(/\/$/u, "")}/`).href,
|
||||
transformer: superjson,
|
||||
headers: { ApiKey: apiKey },
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.any([AbortSignal.timeout(300_000), ...(init?.signal ? [init.signal] : [])]),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const installed = await client.customWidget.package.importCollection.mutate({ archive, bindings: mapping });
|
||||
console.log(`Imported ${installed.entries.length} disabled widgets. Collection import: ${installed.importId}`);
|
||||
for (const entry of installed.entries)
|
||||
console.log(
|
||||
`${entry.key}: ${new URL(entry.managementPath.replace(/^\//u, ""), `${base.href.replace(/\/$/u, "")}/`).href}`,
|
||||
);
|
||||
console.log("Review each package and explicitly trust it before previewing or activating it.");
|
||||
},
|
||||
});
|
||||
|
||||
export const widgetCollectionCommand = command({
|
||||
name: "collection",
|
||||
desc: "Package and import several independent dashboard widgets with shared local connections",
|
||||
subcommands: [pack, validate, install],
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { command, positional, string } from "@drizzle-team/brocli";
|
||||
|
||||
import { customWidgetPackageSchema } from "@homarr/custom-widgets/package";
|
||||
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
|
||||
|
||||
export const widgetInitCommand = command({
|
||||
name: "init",
|
||||
aliases: ["scaffold"],
|
||||
desc: "Create an editable local widget project from a reference package",
|
||||
options: {
|
||||
directory: positional("directory").required().desc("New project directory (must not already exist)"),
|
||||
example: string("example")
|
||||
.default("clock")
|
||||
.desc(`Reference package: ${Object.keys(widgetReferencePackages).join(", ")}`),
|
||||
id: string("id").desc("Package identity; a unique local identity is generated by default"),
|
||||
name: string("name").desc("Widget display name"),
|
||||
},
|
||||
handler: async ({ directory, example, id, name }) => {
|
||||
const reference = widgetReferencePackages[example as keyof typeof widgetReferencePackages];
|
||||
if (!reference)
|
||||
throw new Error(`Unknown example '${example}'. Choose ${Object.keys(widgetReferencePackages).join(", ")}`);
|
||||
const source = customWidgetPackageSchema.parse({
|
||||
...reference,
|
||||
manifest: {
|
||||
...reference.manifest,
|
||||
id: id ?? `local.${randomUUID()}`,
|
||||
name: name ?? reference.manifest.name,
|
||||
version: "1.0.0",
|
||||
},
|
||||
});
|
||||
const { writeCustomWidgetProject } = await import("@homarr/custom-widgets/package/server");
|
||||
const path = await writeCustomWidgetProject(source, directory);
|
||||
console.log(`Created ${source.manifest.id}: ${path}`);
|
||||
console.log(`Edit homarr-widget.json and the source files, then run: homarr widgets validate ${directory}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { boolean, command, positional, string } from "@drizzle-team/brocli";
|
||||
import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
|
||||
interface ProjectConnection {
|
||||
url: string;
|
||||
installationId: string;
|
||||
previewId?: string;
|
||||
}
|
||||
|
||||
export const widgetPreviewCommand = command({
|
||||
name: "preview",
|
||||
desc: "Preview local source using an installed Homarr and its owner-bound connections",
|
||||
options: {
|
||||
file: positional("path").required().desc("Widget project directory or package JSON"),
|
||||
url: string("url").desc("Homarr URL; defaults to HOMARR_URL"),
|
||||
keyEnv: string("api-key-env").default("HOMARR_API_KEY").desc("Variable holding an admin Homarr API key"),
|
||||
installationId: string("installation-id").desc(
|
||||
"Existing local installation; otherwise reuse this project's preview installation",
|
||||
),
|
||||
bindings: string("bindings").desc("JSON file mapping named connections to Homarr connection IDs"),
|
||||
options: string("options").desc("JSON file containing preview option values"),
|
||||
scenario: string("scenario").desc("Named synthetic preview scenario declared in the package"),
|
||||
trust: boolean("trust").desc("Trust this source to execute with Homarr's privileges"),
|
||||
liveActions: boolean("live-actions").desc("Enable real actions in this preview session"),
|
||||
saveDraft: boolean("save-draft").desc("Also replace the selected installation's saved source draft"),
|
||||
},
|
||||
handler: async ({
|
||||
file,
|
||||
url,
|
||||
keyEnv,
|
||||
installationId,
|
||||
bindings,
|
||||
options,
|
||||
scenario,
|
||||
trust,
|
||||
liveActions,
|
||||
saveDraft,
|
||||
}) => {
|
||||
if (trust !== true) throw new Error("Pass --trust to preview this code with Homarr's privileges");
|
||||
const base = new URL(url ?? process.env.HOMARR_URL ?? "http://localhost:7575");
|
||||
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
|
||||
throw new Error("Use an HTTP(S) Homarr URL without embedded credentials");
|
||||
const apiKey = process.env[keyEnv];
|
||||
if (!apiKey) throw new Error(`Set ${keyEnv} to an admin API key from your Homarr instance`);
|
||||
const { loadCustomWidgetProject } = await import("@homarr/custom-widgets/package/server");
|
||||
const project = await loadCustomWidgetProject(file);
|
||||
const statePath = join(project.projectDirectory, ".homarr-widget.local.json");
|
||||
const previous = await readProjectConnection(statePath);
|
||||
const client = createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
url: new URL("api/trpc", `${base.href.replace(/\/$/u, "")}/`).href,
|
||||
transformer: superjson,
|
||||
headers: { ApiKey: apiKey },
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.any([AbortSignal.timeout(300_000), ...(init?.signal ? [init.signal] : [])]),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
let id = installationId;
|
||||
if (!id && previous?.url === base.href) id = previous.installationId;
|
||||
if (!id || saveDraft)
|
||||
id = (
|
||||
await client.customWidget.package.saveDraft.mutate({
|
||||
id,
|
||||
name: project.source.manifest.name,
|
||||
source: project.source,
|
||||
})
|
||||
).id;
|
||||
const preview = await client.customWidget.package.preview.mutate({
|
||||
id,
|
||||
source: project.source,
|
||||
trusted: true,
|
||||
liveActions: liveActions === true,
|
||||
bindings: await readJsonRecord<string>(bindings),
|
||||
options: await readJsonRecord(options),
|
||||
scenario,
|
||||
});
|
||||
await writeFile(statePath, JSON.stringify({ url: base.href, installationId: id, previewId: preview.previewId }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
if (previous?.previewId && previous.url === base.href)
|
||||
await client.customWidget.package.discardPreview.mutate({ previewId: previous.previewId }).catch(() => undefined);
|
||||
console.log(
|
||||
`Preview: ${new URL(`manage/custom-widgets/packages/preview/${preview.previewId}`, `${base.href.replace(/\/$/u, "")}/`).href}`,
|
||||
);
|
||||
console.log(`Installation: ${id}. Open the preview while signed in as the API key's owner.`);
|
||||
},
|
||||
});
|
||||
|
||||
async function readProjectConnection(path: string): Promise<ProjectConnection | null> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"url" in value &&
|
||||
"installationId" in value &&
|
||||
typeof value.url === "string" &&
|
||||
typeof value.installationId === "string"
|
||||
)
|
||||
return value as ProjectConnection;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJsonRecord<T = unknown>(path?: string): Promise<Record<string, T>> {
|
||||
if (!path) return {};
|
||||
const value: unknown = JSON.parse(await readFile(resolve(path), "utf8"));
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw new Error("Preview bindings/options must be JSON objects");
|
||||
return value as Record<string, T>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { command, number, positional, string } from "@drizzle-team/brocli";
|
||||
|
||||
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
|
||||
|
||||
export const widgetPublishCommand = command({
|
||||
name: "publish",
|
||||
desc: "Publish the exact packed archive as an immutable authenticated Workshop release",
|
||||
options: {
|
||||
file: positional("archive").required().desc("Packed homarr-widget archive JSON"),
|
||||
url: string("url").desc("Workshop API URL; defaults to WORKSHOP_API_URL or https://homarr.dev"),
|
||||
tokenEnv: string("token-env").default("HOMARR_WORKSHOP_TOKEN").desc("Variable holding a Workshop account token"),
|
||||
submissionId: string("submission-id").desc("Owned Workshop listing to update; omit for a new package"),
|
||||
revision: number("expected-revision").int().min(1).desc("Current listing revision, required for updates"),
|
||||
forkedFrom: string("forked-from").desc("Original immutable release ID when publishing a fork"),
|
||||
changelog: string("changelog").desc("Release changes (up to 2000 characters)"),
|
||||
},
|
||||
handler: async ({ file, url, tokenEnv, submissionId, revision, forkedFrom, changelog }) => {
|
||||
const archive = customWidgetArchiveSchema.parse(JSON.parse(await readFile(resolve(file), "utf8")));
|
||||
const { assertWidgetArtifactIntegrity } = await import("@homarr/custom-widgets/package/server");
|
||||
assertWidgetArtifactIntegrity(archive.artifact, archive.source);
|
||||
const token = process.env[tokenEnv];
|
||||
if (!token) throw new Error(`Set ${tokenEnv} to a Workshop account token before publishing`);
|
||||
if (submissionId && !revision) throw new Error("Updating a listing requires --expected-revision");
|
||||
const base = new URL(url ?? process.env.WORKSHOP_API_URL ?? "https://homarr.dev");
|
||||
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
|
||||
throw new Error("Use an HTTP(S) Workshop URL without embedded credentials");
|
||||
const response = await fetch(new URL("api/workshop/package-releases", `${base.href.replace(/\/$/u, "")}/`), {
|
||||
method: "POST",
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
headers: { authorization: token, "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...archive, submissionId, expectedRevision: revision, forkedFrom, changelog }),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Workshop rejected the release (HTTP ${response.status}); check account ownership, revision and version`,
|
||||
);
|
||||
const result: unknown = await response.json();
|
||||
if (!result || typeof result !== "object" || !("id" in result) || typeof result.id !== "string")
|
||||
throw new Error("Workshop returned an invalid release");
|
||||
console.log(`Published ${archive.source.manifest.id}@${archive.source.manifest.version}, release ${result.id}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { boolean, command, number, positional, string } from "@drizzle-team/brocli";
|
||||
|
||||
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
|
||||
|
||||
export const widgetRunnerCommand = command({
|
||||
name: "serve",
|
||||
desc: "Run one installed trusted widget archive on this machine without registry access",
|
||||
options: {
|
||||
file: positional("file").required().desc("Trusted widget archive JSON"),
|
||||
trust: boolean("trust").desc("Allow the package to execute with this process's operating system privileges"),
|
||||
host: string("host").default("127.0.0.1").desc("Interface to listen on"),
|
||||
port: number("port").int().min(0).max(65535).default(8787).desc("HTTP port"),
|
||||
tokenEnv: string("token-env")
|
||||
.default("HOMARR_WIDGET_RUNNER_TOKEN")
|
||||
.desc("Environment variable containing the runner bearer token"),
|
||||
},
|
||||
handler: async ({ file, trust, host, port, tokenEnv }) => {
|
||||
if (trust !== true) throw new Error("Pass --trust to execute this package with this machine's privileges");
|
||||
const token = process.env[tokenEnv];
|
||||
if (!token) throw new Error(`Set ${tokenEnv} to a random token with at least 32 characters`);
|
||||
const archive = customWidgetArchiveSchema.parse(JSON.parse(await readFile(resolve(file), "utf8")));
|
||||
const { assertWidgetArtifactIntegrity, startWidgetPackageRunner } =
|
||||
await import("@homarr/custom-widgets/package/server");
|
||||
assertWidgetArtifactIntegrity(archive.artifact, archive.source);
|
||||
const runner = await startWidgetPackageRunner({
|
||||
artifact: archive.artifact,
|
||||
rootDirectory: resolve(dirname(file), ".homarr-widgets"),
|
||||
token,
|
||||
host,
|
||||
port,
|
||||
});
|
||||
console.log(
|
||||
`Serving ${archive.source.manifest.id}@${archive.source.manifest.version} at ${JSON.stringify(runner.address)}`,
|
||||
);
|
||||
await new Promise<void>((done, reject) => {
|
||||
const stop = () => {
|
||||
process.removeListener("SIGINT", stop);
|
||||
process.removeListener("SIGTERM", stop);
|
||||
void runner.close().then(done, reject);
|
||||
};
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { boolean, command, positional, string } from "@drizzle-team/brocli";
|
||||
|
||||
import { customWidgetArchiveSchema } from "@homarr/custom-widgets/package";
|
||||
import { widgetRunnerCommand } from "./widget-runner";
|
||||
import { widgetInitCommand } from "./widget-init";
|
||||
import { widgetPreviewCommand } from "./widget-preview";
|
||||
import { widgetPublishCommand } from "./widget-publish";
|
||||
import { widgetCollectionCommand } from "./widget-collection";
|
||||
|
||||
const inputOptions = () => ({
|
||||
file: positional("path").required().desc("Widget project directory, package JSON, or archive"),
|
||||
});
|
||||
const buildOptions = () => ({
|
||||
...inputOptions(),
|
||||
output: string("output").alias("o").desc("Output JSON file (must not already exist)"),
|
||||
trust: boolean("trust").desc("Trust the package and allow installing its exact dependencies"),
|
||||
});
|
||||
|
||||
const validate = command({
|
||||
name: "validate",
|
||||
desc: "Validate a widget package or offline archive without executing its code",
|
||||
options: inputOptions(),
|
||||
handler: async ({ file }) => {
|
||||
const { loadCustomWidgetProject } = await import("@homarr/custom-widgets/package/server");
|
||||
const { source, archive } = await loadCustomWidgetProject(file);
|
||||
if (archive) {
|
||||
console.log(`Valid archive: ${source.manifest.id}@${source.manifest.version}`);
|
||||
return;
|
||||
}
|
||||
console.log(`Valid package: ${source.manifest.id}@${source.manifest.version}`);
|
||||
},
|
||||
});
|
||||
|
||||
const build = command({
|
||||
name: "build",
|
||||
desc: "Compile a widget package to an immutable browser/server artifact",
|
||||
options: buildOptions(),
|
||||
handler: async ({ file, output, trust }) => {
|
||||
const { compileCustomWidgetPackage, loadCustomWidgetProject } =
|
||||
await import("@homarr/custom-widgets/package/server");
|
||||
const project = await loadCustomWidgetProject(file);
|
||||
const artifact =
|
||||
project.archive?.artifact ??
|
||||
(await compileCustomWidgetPackage(project.source, {
|
||||
rootDirectory: join(project.projectDirectory, ".homarr-widgets"),
|
||||
allowDependencyInstall: trust === true,
|
||||
}));
|
||||
const target = resolve(output ?? join(project.projectDirectory, "dist", "widget.artifact.json"));
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, JSON.stringify(artifact, null, 2), { flag: "wx", mode: 0o600 });
|
||||
console.log(`Built ${artifact.digest}: ${target}`);
|
||||
},
|
||||
});
|
||||
|
||||
const pack = command({
|
||||
name: "pack",
|
||||
desc: "Package source, assets, dependency lock and compiled code for offline installation",
|
||||
options: buildOptions(),
|
||||
handler: async ({ file, output, trust }) => {
|
||||
const { compileCustomWidgetPackage, loadCustomWidgetProject } =
|
||||
await import("@homarr/custom-widgets/package/server");
|
||||
const project = await loadCustomWidgetProject(file);
|
||||
const source = project.source;
|
||||
const artifact =
|
||||
project.archive?.artifact ??
|
||||
(await compileCustomWidgetPackage(source, {
|
||||
rootDirectory: join(project.projectDirectory, ".homarr-widgets"),
|
||||
allowDependencyInstall: trust === true,
|
||||
}));
|
||||
const archive = customWidgetArchiveSchema.parse({ format: "homarr-widget-archive-v3", source, artifact });
|
||||
const target = resolve(output ?? join(project.projectDirectory, "dist", "widget.homarr-widget.json"));
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, JSON.stringify(archive, null, 2), { flag: "wx", mode: 0o600 });
|
||||
console.log(`Packed ${source.manifest.id}@${source.manifest.version}: ${target}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const widgetsRoot = command({
|
||||
name: "widgets",
|
||||
desc: "Validate, build and package trusted custom widgets",
|
||||
subcommands: [
|
||||
widgetInitCommand,
|
||||
validate,
|
||||
build,
|
||||
pack,
|
||||
widgetPreviewCommand,
|
||||
widgetPublishCommand,
|
||||
widgetRunnerCommand,
|
||||
widgetCollectionCommand,
|
||||
],
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { run } from "@drizzle-team/brocli";
|
||||
import type { Command } from "@drizzle-team/brocli";
|
||||
|
||||
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
|
||||
import { widgetsRoot } from "./commands/widgets";
|
||||
|
||||
const harnessCommandNames = new Set(["harness", "local-harness"]);
|
||||
|
||||
@@ -16,6 +17,8 @@ async function main(): Promise<void> {
|
||||
|
||||
if (harnessInvocation) {
|
||||
commands = [harnessRoot];
|
||||
} else if (process.argv[2] === "widgets") {
|
||||
commands = [widgetsRoot];
|
||||
} else {
|
||||
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
|
||||
await Promise.all([
|
||||
@@ -25,7 +28,7 @@ async function main(): Promise<void> {
|
||||
import("./commands/reset-password"),
|
||||
import("./commands/users"),
|
||||
]);
|
||||
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
|
||||
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot, widgetsRoot];
|
||||
}
|
||||
|
||||
let argSource = process.argv;
|
||||
|
||||
Reference in new issue
Block a user