diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-card.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx
similarity index 97%
rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-card.tsx
rename to apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx
index b23fd148a..f90b1d40f 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-card.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-card.tsx
@@ -12,7 +12,7 @@ import type { RouterOutputs } from "@homarr/api";
import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
-import { integrationSecretIcons } from "./_integration-secret-icons";
+import { integrationSecretIcons } from "./integration-secret-icons";
dayjs.extend(relativeTime);
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts
similarity index 100%
rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-icons.ts
rename to apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-inputs.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-inputs.tsx
similarity index 92%
rename from apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-inputs.tsx
rename to apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-inputs.tsx
index f73e8516b..45f5b3156 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-secret-inputs.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-inputs.tsx
@@ -7,7 +7,7 @@ import { integrationSecretKindObject } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
-import { integrationSecretIcons } from "./_integration-secret-icons";
+import { integrationSecretIcons } from "./integration-secret-icons";
interface IntegrationSecretInputProps {
withAsterisk?: boolean;
@@ -50,7 +50,7 @@ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) =>
}
/>
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-test-connection.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-test-connection.tsx
deleted file mode 100644
index 2ae512edd..000000000
--- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-test-connection.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-"use client";
-
-import { useRef, useState } from "react";
-import { Alert, Anchor, Group, Loader } from "@mantine/core";
-import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
-
-import type { RouterInputs } from "@homarr/api";
-import { clientApi } from "@homarr/api/client";
-import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
-import { useI18n, useScopedI18n } from "@homarr/translation/client";
-
-interface UseTestConnectionDirtyProps {
- defaultDirty: boolean;
- initialFormValue: {
- url: string;
- secrets: { kind: string; value: string | null }[];
- };
-}
-
-export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
- const [isDirty, setIsDirty] = useState(defaultDirty);
- const prevFormValueRef = useRef(initialFormValue);
-
- return {
- onValuesChange: (values: typeof initialFormValue) => {
- if (isDirty) return;
-
- // If relevant values changed, set dirty
- if (
- prevFormValueRef.current.url !== values.url ||
- !prevFormValueRef.current.secrets
- .map((secret) => secret.value)
- .every((secretValue, index) => values.secrets[index]?.value === secretValue)
- ) {
- setIsDirty(true);
- return;
- }
-
- // If relevant values changed back to last tested, set not dirty
- setIsDirty(false);
- },
- isDirty,
- removeDirty: () => {
- prevFormValueRef.current = initialFormValue;
- setIsDirty(false);
- },
- };
-};
-
-interface TestConnectionProps {
- isDirty: boolean;
- removeDirty: () => void;
- integration: RouterInputs["integration"]["testConnection"] & { name: string };
-}
-
-export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
- const t = useScopedI18n("integration.testConnection");
- const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
-
- return (
-
- {
- await mutateAsync(integration, {
- onSuccess: () => {
- removeDirty();
- showSuccessNotification({
- title: t("notification.success.title"),
- message: t("notification.success.message"),
- });
- },
- onError: (error) => {
- if (error.data?.zodError?.fieldErrors.url) {
- showErrorNotification({
- title: t("notification.invalidUrl.title"),
- message: t("notification.invalidUrl.message"),
- });
- return;
- }
-
- if (error.message === "SECRETS_NOT_DEFINED") {
- showErrorNotification({
- title: t("notification.notAllSecretsProvided.title"),
- message: t("notification.notAllSecretsProvided.message"),
- });
- return;
- }
-
- showErrorNotification({
- title: t("notification.commonError.title"),
- message: t("notification.commonError.message"),
- });
- },
- });
- }}
- >
- {t("action")}
-
-
-
- );
-};
-
-interface TestConnectionIconProps {
- isDirty: boolean;
- isPending: boolean;
- isSuccess: boolean;
- isError: boolean;
- size: number;
-}
-
-const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
- if (isPending) return ;
- if (isDirty) return null;
- if (isSuccess) return ;
- if (isError) return ;
- return null;
-};
-
-export const TestConnectionNoticeAlert = () => {
- const t = useI18n();
- return (
- }>
- {t("integration.testConnection.alertNotice")}
-
- );
-};
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx
index c93826e92..847479ea3 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx
@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
+import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -15,9 +16,8 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
-import { SecretCard } from "../../_integration-secret-card";
-import { IntegrationSecretInput } from "../../_integration-secret-inputs";
-import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
+import { SecretCard } from "../../_components/secrets/integration-secret-card";
+import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"];
@@ -30,30 +30,23 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
) ?? getDefaultSecretKinds(integration.kind);
- const initialFormValues = {
- name: integration.name,
- url: integration.url,
- secrets: secretsKinds.map((kind) => ({
- kind,
- value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
- })),
- };
- const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
- defaultDirty: true,
- initialFormValue: initialFormValues,
- });
const router = useRouter();
const form = useZodForm(validation.integration.update.omit({ id: true }), {
- initialValues: initialFormValues,
- onValuesChange,
+ initialValues: {
+ name: integration.name,
+ url: integration.url,
+ secrets: secretsKinds.map((kind) => ({
+ kind,
+ value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
+ })),
+ },
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async (values: FormType) => {
- if (isDirty) return;
await mutateAsync(
{
id: integration.id,
@@ -71,7 +64,19 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
});
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
- onError: () => {
+ onError: (error) => {
+ const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
+
+ if (testConnectionError) {
+ showErrorNotification({
+ title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
+ message: testConnectionError.message
+ ? testConnectionError.message
+ : t(`integration.testConnection.notification.${testConnectionError.key}.message`),
+ });
+ return;
+ }
+
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
@@ -84,8 +89,6 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
return (
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx
index 524b2efb9..d7ab653e1 100644
--- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx
@@ -10,13 +10,13 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
+import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
-import { IntegrationSecretInput } from "../_integration-secret-inputs";
-import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
+import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps {
@@ -28,27 +28,20 @@ interface NewIntegrationFormProps {
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind);
- const initialFormValues = {
- name: searchParams.name ?? "",
- url: searchParams.url ?? "",
- secrets: secretKinds[0].map((kind) => ({
- kind,
- value: "",
- })),
- };
- const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
- defaultDirty: true,
- initialFormValue: initialFormValues,
- });
const router = useRouter();
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
- initialValues: initialFormValues,
- onValuesChange,
+ initialValues: {
+ name: searchParams.name ?? "",
+ url: searchParams.url ?? "",
+ secrets: secretKinds[0].map((kind) => ({
+ kind,
+ value: "",
+ })),
+ },
});
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmitAsync = async (values: FormType) => {
- if (isDirty) return;
await mutateAsync(
{
kind: searchParams.kind,
@@ -62,7 +55,19 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
});
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
- onError: () => {
+ onError: (error) => {
+ const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
+
+ if (testConnectionError) {
+ showErrorNotification({
+ title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
+ message: testConnectionError.message
+ ? testConnectionError.message
+ : t(`integration.testConnection.notification.${testConnectionError.key}.message`),
+ });
+ return;
+ }
+
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
@@ -75,8 +80,6 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
return (
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 239be6f09..ce71e53b8 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -4,7 +4,7 @@ import { dockerRouter } from "./router/docker/docker-router";
import { groupRouter } from "./router/group";
import { homeRouter } from "./router/home";
import { iconsRouter } from "./router/icons";
-import { integrationRouter } from "./router/integration";
+import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
diff --git a/packages/api/src/router/integration.ts b/packages/api/src/router/integration/integration-router.ts
similarity index 61%
rename from packages/api/src/router/integration.ts
rename to packages/api/src/router/integration/integration-router.ts
index c7b333673..611c288d1 100644
--- a/packages/api/src/router/integration.ts
+++ b/packages/api/src/router/integration/integration-router.ts
@@ -5,10 +5,11 @@ import type { Database } from "@homarr/db";
import { and, createId, eq } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions";
-import { getAllSecretKindOptions, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
+import { integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
import { validation } from "@homarr/validation";
-import { createTRPCRouter, publicProcedure } from "../trpc";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+import { testConnectionAsync } from "./integration-test-connection";
export const integrationRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
@@ -60,6 +61,14 @@ export const integrationRouter = createTRPCRouter({
};
}),
create: publicProcedure.input(validation.integration.create).mutation(async ({ ctx, input }) => {
+ await testConnectionAsync({
+ id: "new",
+ name: input.name,
+ url: input.url,
+ kind: input.kind,
+ secrets: input.secrets,
+ });
+
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
@@ -68,13 +77,14 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
});
- for (const secret of input.secrets) {
- await ctx.db.insert(integrationSecrets).values({
- kind: secret.kind,
- value: encryptSecret(secret.value),
- updatedAt: new Date(),
- integrationId,
- });
+ if (input.secrets.length >= 1) {
+ await ctx.db.insert(integrationSecrets).values(
+ input.secrets.map((secret) => ({
+ kind: secret.kind,
+ value: encryptSecret(secret.value),
+ integrationId,
+ })),
+ );
}
}),
update: publicProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
@@ -92,6 +102,17 @@ export const integrationRouter = createTRPCRouter({
});
}
+ await testConnectionAsync(
+ {
+ id: input.id,
+ name: input.name,
+ url: input.url,
+ kind: integration.kind,
+ secrets: input.secrets,
+ },
+ integration.secrets,
+ );
+
await ctx.db
.update(integrations)
.set({
@@ -100,15 +121,14 @@ export const integrationRouter = createTRPCRouter({
})
.where(eq(integrations.id, input.id));
- const decryptedSecrets = integration.secrets.map((secret) => ({
- ...secret,
- value: decryptSecret(secret.value),
- }));
-
const changedSecrets = input.secrets.filter(
(secret): secret is { kind: IntegrationSecretKind; value: string } =>
secret.value !== null && // only update secrets that have a value
- !decryptedSecrets.find((dSecret) => dSecret.kind === secret.kind && dSecret.value === secret.value),
+ !integration.secrets.find(
+ // Checked above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ (dbSecret) => dbSecret.kind === secret.kind && dbSecret.value === encryptSecret(secret.value!),
+ ),
);
if (changedSecrets.length > 0) {
@@ -118,7 +138,7 @@ export const integrationRouter = createTRPCRouter({
value: changedSecret.value,
kind: changedSecret.kind,
};
- if (!decryptedSecrets.some((secret) => secret.kind === changedSecret.kind)) {
+ if (!integration.secrets.some((secret) => secret.kind === changedSecret.kind)) {
await addSecretAsync(ctx.db, secretInput);
} else {
await updateSecretAsync(ctx.db, secretInput);
@@ -140,71 +160,6 @@ export const integrationRouter = createTRPCRouter({
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
}),
- testConnection: publicProcedure.input(validation.integration.testConnection).mutation(async ({ ctx, input }) => {
- const secrets = input.secrets.filter((secret): secret is { kind: IntegrationSecretKind; value: string } =>
- Boolean(secret.value),
- );
-
- // Find any matching secret kinds
- let secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
- secretKinds.every((secretKind) => secrets.some((secret) => secret.kind === secretKind)),
- );
-
- if (!secretKinds && input.id === null) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "SECRETS_NOT_DEFINED",
- });
- }
-
- if (!secretKinds && input.id !== null) {
- const integration = await ctx.db.query.integrations.findFirst({
- where: eq(integrations.id, input.id),
- with: {
- secrets: true,
- },
- });
- if (!integration) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "SECRETS_NOT_DEFINED",
- });
- }
- const decryptedSecrets = integration.secrets.map((secret) => ({
- ...secret,
- value: decryptSecret(secret.value),
- }));
-
- // Add secrets that are not defined in the input from the database
- for (const dbSecret of decryptedSecrets) {
- if (!secrets.find((secret) => secret.kind === dbSecret.kind)) {
- secrets.push({
- kind: dbSecret.kind,
- value: dbSecret.value,
- });
- }
- }
-
- secretKinds = getAllSecretKindOptions(input.kind).find((secretKinds) =>
- secretKinds.every((secretKind) => secrets.some((secret) => secret.kind === secretKind)),
- );
-
- if (!secretKinds) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "SECRETS_NOT_DEFINED",
- });
- }
- }
-
- // TODO: actually test the connection
- // Probably by calling a function on the integration class
- // getIntegration(input.kind).testConnection(secrets)
- // getIntegration(kind: IntegrationKind): Integration
- // interface Integration {
- // testConnection(): Promise;
- // }
- }),
});
interface UpdateSecretInput {
@@ -217,7 +172,6 @@ const updateSecretAsync = async (db: Database, input: UpdateSecretInput) => {
.update(integrationSecrets)
.set({
value: encryptSecret(input.value),
- updatedAt: new Date(),
})
.where(and(eq(integrationSecrets.integrationId, input.integrationId), eq(integrationSecrets.kind, input.kind)));
};
@@ -231,7 +185,6 @@ const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
value: encryptSecret(input.value),
- updatedAt: new Date(),
integrationId: input.integrationId,
});
};
diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts
new file mode 100644
index 000000000..ba8ffa2dd
--- /dev/null
+++ b/packages/api/src/router/integration/integration-test-connection.ts
@@ -0,0 +1,95 @@
+import { decryptSecret } from "@homarr/common";
+import type { Integration } from "@homarr/db/schema/sqlite";
+import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
+import { getAllSecretKindOptions } from "@homarr/definitions";
+import { integrationCreatorByKind, IntegrationTestConnectionError } from "@homarr/integrations";
+
+type FormIntegration = Integration & {
+ secrets: {
+ kind: IntegrationSecretKind;
+ value: string | null;
+ }[];
+};
+
+export const testConnectionAsync = async (
+ integration: FormIntegration,
+ dbSecrets: {
+ kind: IntegrationSecretKind;
+ value: `${string}.${string}`;
+ }[] = [],
+) => {
+ const formSecrets = integration.secrets
+ .filter((secret) => secret.value !== null)
+ .map((secret) => ({
+ ...secret,
+ // We ensured above that the value is not null
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ value: secret.value!,
+ source: "form" as const,
+ }));
+
+ const decryptedDbSecrets = dbSecrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ source: "db" as const,
+ }));
+
+ const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets];
+ const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets);
+
+ const filteredSecrets = secretKinds.map((kind) => {
+ const secrets = sourcedSecrets.filter((secret) => secret.kind === kind);
+ // Will never be undefined because of the check before
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ if (secrets.length === 1) return secrets[0]!;
+
+ // There will always be a matching secret because of the getSecretKindOption function
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return secrets.find((secret) => secret.source === "form") ?? secrets[0]!;
+ });
+
+ const integrationInstance = integrationCreatorByKind(integration.kind, {
+ id: integration.id,
+ name: integration.name,
+ url: integration.url,
+ decryptedSecrets: filteredSecrets,
+ });
+
+ await integrationInstance.testConnectionAsync();
+};
+
+interface SourcedIntegrationSecret {
+ kind: IntegrationSecretKind;
+ value: string;
+ source: "db" | "form";
+}
+
+const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedIntegrationSecret[]) => {
+ const matchingSecretKindOptions = getAllSecretKindOptions(kind).filter((secretKinds) =>
+ secretKinds.every((kind) => sourcedSecrets.some((secret) => secret.kind === kind)),
+ );
+
+ if (matchingSecretKindOptions.length === 0) {
+ throw new IntegrationTestConnectionError("secretNotDefined");
+ }
+
+ if (matchingSecretKindOptions.length === 1) {
+ // Will never be undefined because of the check above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return matchingSecretKindOptions[0]!;
+ }
+
+ const onlyFormSecretsKindOptions = matchingSecretKindOptions.filter((secretKinds) =>
+ sourcedSecrets.filter((secret) => secretKinds.includes(secret.kind)).every((secret) => secret.source === "form"),
+ );
+
+ if (onlyFormSecretsKindOptions.length >= 1) {
+ // Will never be undefined because of the check above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return onlyFormSecretsKindOptions[0]!;
+ }
+
+ // Will never be undefined because of the check above
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return matchingSecretKindOptions[0]!;
+};
diff --git a/packages/api/src/router/test/integration.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts
similarity index 60%
rename from packages/api/src/router/test/integration.spec.ts
rename to packages/api/src/router/test/integration/integration-router.spec.ts
index 5bbbcc4b4..711cfcb20 100644
--- a/packages/api/src/router/test/integration.spec.ts
+++ b/packages/api/src/router/test/integration/integration-router.spec.ts
@@ -7,12 +7,14 @@ import { createId } from "@homarr/db";
import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
-import type { RouterInputs } from "../..";
-import { integrationRouter } from "../integration";
-import { expectToBeDefined } from "./helper";
+import { integrationRouter } from "../../integration/integration-router";
+import { expectToBeDefined } from "../helper";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+vi.mock("../../integration/integration-test-connection", () => ({
+ testConnectionAsync: async () => await Promise.resolve(undefined),
+}));
describe("all should return all integrations", () => {
it("should return all integrations", async () => {
@@ -290,199 +292,3 @@ describe("delete should delete an integration", () => {
expect(dbSecrets.length).toBe(0);
});
});
-
-describe("testConnection should test the connection to an integration", () => {
- it.each([
- [
- "nzbGet" as const,
- [
- { kind: "username" as const, value: null },
- { kind: "password" as const, value: "Password123!" },
- ],
- ],
- [
- "nzbGet" as const,
- [
- { kind: "username" as const, value: "exampleUser" },
- { kind: "password" as const, value: null },
- ],
- ],
- ["sabNzbd" as const, [{ kind: "apiKey" as const, value: null }]],
- [
- "sabNzbd" as const,
- [
- { kind: "username" as const, value: "exampleUser" },
- { kind: "password" as const, value: "Password123!" },
- ],
- ],
- ])("should fail when a required secret is missing when creating %s integration", async (kind, secrets) => {
- const db = createDb();
- const caller = integrationRouter.createCaller({
- db,
- session: null,
- });
-
- const input: RouterInputs["integration"]["testConnection"] = {
- id: null,
- kind,
- url: `http://${kind}.local`,
- secrets,
- };
-
- const actAsync = async () => await caller.testConnection(input);
- await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
- });
-
- it.each([
- [
- "nzbGet" as const,
- [
- { kind: "username" as const, value: "exampleUser" },
- { kind: "password" as const, value: "Password123!" },
- ],
- ],
- ["sabNzbd" as const, [{ kind: "apiKey" as const, value: "1234567890" }]],
- ])(
- "should be successful when all required secrets are defined for creation of %s integration",
- async (kind, secrets) => {
- const db = createDb();
- const caller = integrationRouter.createCaller({
- db,
- session: null,
- });
-
- const input: RouterInputs["integration"]["testConnection"] = {
- id: null,
- kind,
- url: `http://${kind}.local`,
- secrets,
- };
-
- const actAsync = async () => await caller.testConnection(input);
- await expect(actAsync()).resolves.toBeUndefined();
- },
- );
-
- it("should be successful when all required secrets are defined for updating an nzbGet integration", async () => {
- const db = createDb();
- const caller = integrationRouter.createCaller({
- db,
- session: null,
- });
-
- const input: RouterInputs["integration"]["testConnection"] = {
- id: createId(),
- kind: "nzbGet",
- url: "http://nzbGet.local",
- secrets: [
- { kind: "username", value: "exampleUser" },
- { kind: "password", value: "Password123!" },
- ],
- };
-
- const actAsync = async () => await caller.testConnection(input);
- await expect(actAsync()).resolves.toBeUndefined();
- });
-
- it("should be successful when overriding one of the secrets for an existing nzbGet integration", async () => {
- const db = createDb();
- const caller = integrationRouter.createCaller({
- db,
- session: null,
- });
-
- const integrationId = createId();
- await db.insert(integrations).values({
- id: integrationId,
- name: "NZBGet",
- kind: "nzbGet",
- url: "http://nzbGet.local",
- });
-
- await db.insert(integrationSecrets).values([
- {
- kind: "username",
- value: encryptSecret("exampleUser"),
- integrationId,
- updatedAt: new Date(),
- },
- {
- kind: "password",
- value: encryptSecret("Password123!"),
- integrationId,
- updatedAt: new Date(),
- },
- ]);
-
- const input: RouterInputs["integration"]["testConnection"] = {
- id: integrationId,
- kind: "nzbGet",
- url: "http://nzbGet.local",
- secrets: [
- { kind: "username", value: "newUser" },
- { kind: "password", value: null },
- ],
- };
-
- const actAsync = async () => await caller.testConnection(input);
- await expect(actAsync()).resolves.toBeUndefined();
- });
-
- it("should fail when a required secret is missing for an existing nzbGet integration", async () => {
- const db = createDb();
- const caller = integrationRouter.createCaller({
- db,
- session: null,
- });
-
- const integrationId = createId();
- await db.insert(integrations).values({
- id: integrationId,
- name: "NZBGet",
- kind: "nzbGet",
- url: "http://nzbGet.local",
- });
-
- await db.insert(integrationSecrets).values([
- {
- kind: "username",
- value: encryptSecret("exampleUser"),
- integrationId,
- updatedAt: new Date(),
- },
- ]);
-
- const input: RouterInputs["integration"]["testConnection"] = {
- id: integrationId,
- kind: "nzbGet",
- url: "http://nzbGet.local",
- secrets: [
- { kind: "username", value: "newUser" },
- { kind: "apiKey", value: "1234567890" },
- ],
- };
-
- const actAsync = async () => await caller.testConnection(input);
- await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
- });
-
- it("should fail when the updating integration does not exist", async () => {
- const db = createDb();
- const caller = integrationRouter.createCaller({
- db,
- session: null,
- });
-
- const actAsync = async () =>
- await caller.testConnection({
- id: createId(),
- kind: "nzbGet",
- url: "http://nzbGet.local",
- secrets: [
- { kind: "username", value: null },
- { kind: "password", value: "Password123!" },
- ],
- });
- await expect(actAsync()).rejects.toThrow("SECRETS_NOT_DEFINED");
- });
-});
diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts
new file mode 100644
index 000000000..5118f526c
--- /dev/null
+++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts
@@ -0,0 +1,253 @@
+import { describe, expect, test, vi } from "vitest";
+
+import * as homarrDefinitions from "@homarr/definitions";
+import * as homarrIntegrations from "@homarr/integrations";
+
+import { testConnectionAsync } from "../../integration/integration-test-connection";
+
+vi.mock("@homarr/common", async (importActual) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
+ const actual = await importActual();
+
+ return {
+ ...actual,
+ decryptSecret: (value: string) => value.split(".")[0],
+ };
+});
+
+describe("testConnectionAsync should run test connection of integration", () => {
+ test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue({
+ testConnectionAsync: async () => await Promise.resolve(),
+ } as homarrIntegrations.PiHoleIntegration);
+ optionsSpy.mockReturnValue([["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: "secret",
+ },
+ ],
+ };
+
+ // Act
+ await testConnectionAsync(integration);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith("piHole", {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "secret",
+ }),
+ ],
+ });
+ });
+
+ test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue({
+ testConnectionAsync: async () => await Promise.resolve(),
+ } as homarrIntegrations.PiHoleIntegration);
+ optionsSpy.mockReturnValue([["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: null,
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "apiKey" as const,
+ value: "dbSecret.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith("piHole", {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "dbSecret",
+ }),
+ ],
+ });
+ });
+
+ test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue({
+ testConnectionAsync: async () => await Promise.resolve(),
+ } as homarrIntegrations.PiHoleIntegration);
+ optionsSpy.mockReturnValue([["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: "secret",
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "apiKey" as const,
+ value: "dbSecret.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith("piHole", {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "secret",
+ }),
+ ],
+ });
+ });
+
+ test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue({
+ testConnectionAsync: async () => await Promise.resolve(),
+ } as homarrIntegrations.PiHoleIntegration);
+ optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: "secret",
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "username" as const,
+ value: "dbUsername.encrypted" as const,
+ },
+ {
+ kind: "password" as const,
+ value: "dbPassword.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith("piHole", {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "apiKey",
+ value: "secret",
+ }),
+ ],
+ });
+ });
+
+ test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
+ // Arrange
+ const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind");
+ const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
+ factorySpy.mockReturnValue({
+ testConnectionAsync: async () => await Promise.resolve(),
+ } as homarrIntegrations.PiHoleIntegration);
+ optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
+
+ const integration = {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ kind: "piHole" as const,
+ secrets: [
+ {
+ kind: "apiKey" as const,
+ value: null,
+ },
+ ],
+ };
+
+ const dbSecrets = [
+ {
+ kind: "username" as const,
+ value: "dbUsername.encrypted" as const,
+ },
+ {
+ kind: "password" as const,
+ value: "dbPassword.encrypted" as const,
+ },
+ ];
+
+ // Act
+ await testConnectionAsync(integration, dbSecrets);
+
+ // Assert
+ expect(factorySpy).toHaveBeenCalledWith("piHole", {
+ id: "new",
+ name: "Pi Hole",
+ url: "http://pi.hole",
+ decryptedSecrets: [
+ expect.objectContaining({
+ kind: "username",
+ value: "dbUsername",
+ }),
+ expect.objectContaining({
+ kind: "password",
+ value: "dbPassword",
+ }),
+ ],
+ });
+ });
+});
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 0248720d3..3969b204a 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -10,6 +10,7 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { Session } from "@homarr/auth";
+import { FlattenError } from "@homarr/common";
import { db } from "@homarr/db";
import type { GroupPermissionKey } from "@homarr/definitions";
import { logger } from "@homarr/log";
@@ -52,6 +53,7 @@ const t = initTRPC.context().create({
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
+ error: error.cause instanceof FlattenError ? error.cause.flatten() : null,
},
}),
});
diff --git a/packages/common/src/error.ts b/packages/common/src/error.ts
index ddc5f93ea..96adb35e0 100644
--- a/packages/common/src/error.ts
+++ b/packages/common/src/error.ts
@@ -9,3 +9,16 @@ export const extractErrorMessage = (error: unknown) => {
return "Unknown error";
};
+
+export abstract class FlattenError extends Error {
+ constructor(
+ message: string,
+ private flattenResult: Record,
+ ) {
+ super(message);
+ }
+
+ public flatten(): Record {
+ return this.flattenResult;
+ }
+}
diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts
index f99fe099c..5c46343ff 100644
--- a/packages/common/src/url.ts
+++ b/packages/common/src/url.ts
@@ -1,5 +1,9 @@
export const appendPath = (url: URL | string, path: string) => {
const newUrl = new URL(url);
- newUrl.pathname += path;
+ newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
return newUrl;
};
+
+const removeTrailingSlash = (path: string) => {
+ return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
+};
diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts
index d53a9ec30..22692da00 100644
--- a/packages/db/schema/mysql.ts
+++ b/packages/db/schema/mysql.ts
@@ -141,7 +141,9 @@ export const integrationSecrets = mysqlTable(
{
kind: varchar("kind", { length: 16 }).$type().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(),
- updatedAt: timestamp("updated_at").notNull(),
+ updatedAt: timestamp("updated_at")
+ .$onUpdateFn(() => new Date())
+ .notNull(),
integrationId: varchar("integration_id", { length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index f7c3c87e5..ff913f264 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -144,7 +144,9 @@ export const integrationSecrets = sqliteTable(
{
kind: text("kind").$type().notNull(),
value: text("value").$type<`${string}.${string}`>().notNull(),
- updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp" })
+ .$onUpdateFn(() => new Date())
+ .notNull(),
integrationId: text("integration_id")
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 939b278e8..87b4f5ed9 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -4,6 +4,7 @@
"version": "0.1.0",
"exports": {
".": "./index.ts",
+ "./client": "./src/client.ts",
"./types": "./src/types.ts"
},
"typesVersions": {
@@ -25,7 +26,8 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
- "@homarr/common": "workspace:^0.1.0"
+ "@homarr/common": "workspace:^0.1.0",
+ "@homarr/translation": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts
new file mode 100644
index 000000000..50aee2be8
--- /dev/null
+++ b/packages/integrations/src/base/creator.ts
@@ -0,0 +1,16 @@
+import type { IntegrationKind } from "@homarr/definitions";
+
+import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
+import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
+import type { IntegrationInput } from "./integration";
+
+export const integrationCreatorByKind = (kind: IntegrationKind, integration: IntegrationInput) => {
+ switch (kind) {
+ case "piHole":
+ return new PiHoleIntegration(integration);
+ case "homeAssistant":
+ return new HomeAssistantIntegration(integration);
+ default:
+ throw new Error(`Unknown integration kind ${kind}`);
+ }
+};
diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts
index e8612736a..69b346e23 100644
--- a/packages/integrations/src/base/integration.ts
+++ b/packages/integrations/src/base/integration.ts
@@ -1,16 +1,25 @@
+import { extractErrorMessage } from "@homarr/common";
import type { IntegrationSecretKind } from "@homarr/definitions";
+import { logger } from "@homarr/log";
+import type { TranslationObject } from "@homarr/translation";
+import { z } from "@homarr/validation";
+import { IntegrationTestConnectionError } from "./test-connection-error";
import type { IntegrationSecret } from "./types";
+const causeSchema = z.object({
+ code: z.string(),
+});
+
+export interface IntegrationInput {
+ id: string;
+ name: string;
+ url: string;
+ decryptedSecrets: IntegrationSecret[];
+}
+
export abstract class Integration {
- constructor(
- protected integration: {
- id: string;
- name: string;
- url: string;
- decryptedSecrets: IntegrationSecret[];
- },
- ) {}
+ constructor(protected integration: IntegrationInput) {}
protected getSecretValue(kind: IntegrationSecretKind) {
const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
@@ -19,4 +28,87 @@ export abstract class Integration {
}
return secret.value;
}
+
+ /**
+ * Test the connection to the integration
+ * @throws {IntegrationTestConnectionError} if the connection fails
+ */
+ public abstract testConnectionAsync(): Promise;
+
+ protected async handleTestConnectionResponseAsync({
+ queryFunctionAsync,
+ handleResponseAsync,
+ }: {
+ queryFunctionAsync: () => Promise;
+ handleResponseAsync?: (response: Response) => Promise;
+ }): Promise {
+ const response = await queryFunctionAsync().catch((error) => {
+ if (error instanceof Error) {
+ const cause = causeSchema.safeParse(error.cause);
+ if (!cause.success) {
+ logger.error("Failed to test connection", error);
+ throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
+ }
+
+ if (cause.data.code === "ENOTFOUND") {
+ logger.error("Failed to test connection: Domain not found");
+ throw new IntegrationTestConnectionError("domainNotFound");
+ }
+
+ if (cause.data.code === "ECONNREFUSED") {
+ logger.error("Failed to test connection: Connection refused");
+ throw new IntegrationTestConnectionError("connectionRefused");
+ }
+
+ if (cause.data.code === "ECONNABORTED") {
+ logger.error("Failed to test connection: Connection aborted");
+ throw new IntegrationTestConnectionError("connectionAborted");
+ }
+ }
+
+ logger.error("Failed to test connection", error);
+
+ throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
+ });
+
+ if (response.status >= 400) {
+ logger.error(`Failed to test connection with status code ${response.status}`);
+
+ throwErrorByStatusCode(response.status);
+ }
+
+ await handleResponseAsync?.(response);
+ }
}
+
+export interface TestConnectionError {
+ key: Exclude;
+ message?: string;
+}
+export type TestConnectionResult =
+ | {
+ success: false;
+ error: TestConnectionError;
+ }
+ | {
+ success: true;
+ };
+
+const throwErrorByStatusCode = (statusCode: number) => {
+ switch (statusCode) {
+ case 400:
+ throw new IntegrationTestConnectionError("badRequest");
+ case 401:
+ throw new IntegrationTestConnectionError("unauthorized");
+ case 403:
+ throw new IntegrationTestConnectionError("forbidden");
+ case 404:
+ throw new IntegrationTestConnectionError("notFound");
+ case 500:
+ throw new IntegrationTestConnectionError("internalServerError");
+ case 503:
+ throw new IntegrationTestConnectionError("serviceUnavailable");
+ default:
+ throw new IntegrationTestConnectionError("commonError");
+ }
+};
diff --git a/packages/integrations/src/base/test-connection-error.ts b/packages/integrations/src/base/test-connection-error.ts
new file mode 100644
index 000000000..8a8067f0e
--- /dev/null
+++ b/packages/integrations/src/base/test-connection-error.ts
@@ -0,0 +1,26 @@
+import { FlattenError } from "@homarr/common";
+import { z } from "@homarr/validation";
+
+import type { TestConnectionError } from "./integration";
+
+export class IntegrationTestConnectionError extends FlattenError {
+ constructor(
+ public key: TestConnectionError["key"],
+ public detailMessage?: string,
+ ) {
+ super("Checking integration connection failed", { key, message: detailMessage });
+ }
+}
+
+const schema = z.object({
+ key: z.custom((value) => z.string().parse(value)),
+ message: z.string().optional(),
+});
+export const convertIntegrationTestConnectionError = (error: unknown) => {
+ const result = schema.safeParse(error);
+ if (!result.success) {
+ return;
+ }
+
+ return result.data;
+};
diff --git a/packages/integrations/src/client.ts b/packages/integrations/src/client.ts
new file mode 100644
index 000000000..d62df98bb
--- /dev/null
+++ b/packages/integrations/src/client.ts
@@ -0,0 +1 @@
+export { convertIntegrationTestConnectionError } from "./base/test-connection-error";
diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts
index 267b3b957..47f10ce3e 100644
--- a/packages/integrations/src/homeassistant/homeassistant-integration.ts
+++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts
@@ -5,13 +5,9 @@ import { Integration } from "../base/integration";
import { entityStateSchema } from "./homeassistant-types";
export class HomeAssistantIntegration extends Integration {
- async getEntityStateAsync(entityId: string) {
+ public async getEntityStateAsync(entityId: string) {
try {
- const response = await fetch(appendPath(this.integration.url, `/states/${entityId}`), {
- headers: {
- Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
- },
- });
+ const response = await this.getAsync(`/api/states/${entityId}`);
const body = (await response.json()) as unknown;
if (!response.ok) {
logger.warn(`Response did not indicate success`);
@@ -29,17 +25,12 @@ export class HomeAssistantIntegration extends Integration {
}
}
- async triggerAutomationAsync(entityId: string) {
+ public async triggerAutomationAsync(entityId: string) {
try {
- const response = await fetch(appendPath(this.integration.url, "/services/automation/trigger"), {
- headers: {
- Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
- },
- body: JSON.stringify({
- entity_id: entityId,
- }),
- method: "POST",
+ const response = await this.postAsync("/api/services/automation/trigger", {
+ entity_id: entityId,
});
+
return response.ok;
} catch (err) {
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
@@ -53,21 +44,61 @@ export class HomeAssistantIntegration extends Integration {
* @param entityId - The ID of the entity to toggle.
* @returns A boolean indicating whether the toggle action was successful.
*/
- async triggerToggleAsync(entityId: string) {
+ public async triggerToggleAsync(entityId: string) {
try {
- const response = await fetch(appendPath(this.integration.url, "/services/homeassistant/toggle"), {
- headers: {
- Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
- },
- body: JSON.stringify({
- entity_id: entityId,
- }),
- method: "POST",
+ const response = await this.postAsync("/api/services/homeassistant/toggle", {
+ entity_id: entityId,
});
+
return response.ok;
} catch (err) {
logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`);
return false;
}
}
+
+ public async testConnectionAsync(): Promise {
+ await super.handleTestConnectionResponseAsync({
+ queryFunctionAsync: async () => {
+ return await this.getAsync("/api/config");
+ },
+ });
+ }
+
+ /**
+ * Makes a GET request to the Home Assistant API.
+ * It includes the authorization header with the API key.
+ * @param path full path to the API endpoint
+ * @returns the response from the API
+ */
+ private async getAsync(path: `/api/${string}`) {
+ return await fetch(appendPath(this.integration.url, path), {
+ headers: this.getAuthHeaders(),
+ });
+ }
+
+ /**
+ * Makes a POST request to the Home Assistant API.
+ * It includes the authorization header with the API key.
+ * @param path full path to the API endpoint
+ * @param body the body of the request
+ * @returns the response from the API
+ */
+ private async postAsync(path: `/api/${string}`, body: Record) {
+ return await fetch(appendPath(this.integration.url, path), {
+ headers: this.getAuthHeaders(),
+ body: JSON.stringify(body),
+ method: "POST",
+ });
+ }
+
+ /**
+ * Returns the headers required for authorization.
+ * @returns the authorization headers
+ */
+ private getAuthHeaders() {
+ return {
+ Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
+ };
+ }
}
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index 1804d2124..fd241bae3 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -1,2 +1,7 @@
+// General integrations
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
+
+// Helpers
+export { IntegrationTestConnectionError } from "./base/test-connection-error";
+export { integrationCreatorByKind } from "./base/creator";
diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts
index 4b309c07b..c0c61a26c 100644
--- a/packages/integrations/src/pi-hole/pi-hole-integration.ts
+++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts
@@ -1,10 +1,11 @@
import { Integration } from "../base/integration";
+import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
import { summaryResponseSchema } from "./pi-hole-types";
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
- async getSummaryAsync(): Promise {
+ public async getSummaryAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`);
if (!response.ok) {
@@ -28,4 +29,24 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
dnsQueriesToday: result.data.dns_queries_today,
};
}
+
+ public async testConnectionAsync(): Promise {
+ const apiKey = super.getSecretValue("apiKey");
+
+ await super.handleTestConnectionResponseAsync({
+ queryFunctionAsync: async () => {
+ return await fetch(`${this.integration.url}/admin/api.php?status&auth=${apiKey}`);
+ },
+ handleResponseAsync: async (response) => {
+ try {
+ const result = (await response.json()) as unknown;
+ if (typeof result === "object" && result !== null && "status" in result) return;
+ } catch (error) {
+ throw new IntegrationTestConnectionError("invalidJson");
+ }
+
+ throw new IntegrationTestConnectionError("invalidCredentials");
+ },
+ });
+ }
}
diff --git a/packages/integrations/test/base.spec.ts b/packages/integrations/test/base.spec.ts
new file mode 100644
index 000000000..9ef1f3861
--- /dev/null
+++ b/packages/integrations/test/base.spec.ts
@@ -0,0 +1,249 @@
+import { describe, expect, test } from "vitest";
+
+import { IntegrationTestConnectionError } from "../src";
+import { Integration } from "../src/base/integration";
+
+type HandleResponseProps = Parameters[0];
+
+class BaseIntegrationMock extends Integration {
+ public async fakeTestConnectionAsync(props: HandleResponseProps): Promise {
+ await super.handleTestConnectionResponseAsync(props);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ public async testConnectionAsync(): Promise {}
+}
+
+describe("Base integration", () => {
+ describe("handleTestConnectionResponseAsync", () => {
+ test("With no cause error should throw IntegrationTestConnectionError with key commonError", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const errorMessage = "The error message";
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.reject(new Error(errorMessage));
+ },
+ };
+
+ // Act
+ const actPromise = integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actPromise).rejects.toHaveProperty("key", "commonError");
+ await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
+ });
+
+ test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key domainNotFound", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.reject(new Error("Error", { cause: { code: "ENOTFOUND" } }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "domainNotFound");
+ });
+
+ test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionRefused", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.reject(new Error("Error", { cause: { code: "ECONNREFUSED" } }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "connectionRefused");
+ });
+
+ test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionAborted", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.reject(new Error("Error", { cause: { code: "ECONNABORTED" } }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "connectionAborted");
+ });
+
+ test("With not handled cause error should throw IntegrationTestConnectionError with key commonError", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const errorMessage = "The error message";
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.reject(new Error(errorMessage));
+ },
+ };
+
+ // Act
+ const actPromise = integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actPromise).rejects.toHaveProperty("key", "commonError");
+ await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
+ });
+
+ test("With response status code 400 should throw IntegrationTestConnectionError with key badRequest", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 400 }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "badRequest");
+ });
+
+ test("With response status code 401 should throw IntegrationTestConnectionError with key unauthorized", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 401 }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "unauthorized");
+ });
+
+ test("With response status code 403 should throw IntegrationTestConnectionError with key forbidden", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 403 }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "forbidden");
+ });
+
+ test("With response status code 404 should throw IntegrationTestConnectionError with key notFound", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 404 }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "notFound");
+ });
+
+ test("With response status code 500 should throw IntegrationTestConnectionError with key internalServerError", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 500 }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "internalServerError");
+ });
+
+ test("With response status code 503 should throw IntegrationTestConnectionError with key serviceUnavailable", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 503 }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "serviceUnavailable");
+ });
+
+ test("With response status code 418 (or any other unhandled code) should throw IntegrationTestConnectionError with key commonError", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 418 }));
+ },
+ };
+
+ // Act
+ const actAsync = async () => await integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actAsync()).rejects.toHaveProperty("key", "commonError");
+ });
+
+ test("Errors from handleResponseAsync should be thrown", async () => {
+ // Arrange
+ const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
+
+ const errorMessage = "The error message";
+ const props: HandleResponseProps = {
+ async queryFunctionAsync() {
+ return await Promise.resolve(new Response(null, { status: 200 }));
+ },
+ async handleResponseAsync() {
+ return await Promise.reject(new IntegrationTestConnectionError("commonError", errorMessage));
+ },
+ };
+
+ // Act
+ const actPromise = integration.fakeTestConnectionAsync(props);
+
+ // Assert
+ await expect(actPromise).rejects.toHaveProperty("key", "commonError");
+ await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
+ });
+ });
+});
diff --git a/packages/integrations/test/home-assistant.spec.ts b/packages/integrations/test/home-assistant.spec.ts
new file mode 100644
index 000000000..76d8446e6
--- /dev/null
+++ b/packages/integrations/test/home-assistant.spec.ts
@@ -0,0 +1,81 @@
+import type { StartedTestContainer } from "testcontainers";
+import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
+import { beforeAll, describe, expect, test } from "vitest";
+
+import { HomeAssistantIntegration, IntegrationTestConnectionError } from "../src";
+
+const DEFAULT_API_KEY =
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkNjQwY2VjNDFjOGU0NGM5YmRlNWQ4ZmFjMjUzYWViZiIsImlhdCI6MTcxODQ3MTE1MSwiZXhwIjoyMDMzODMxMTUxfQ.uQCZ5FZTokipa6N27DtFhLHkwYEXU1LZr0fsVTryL2Q";
+const IMAGE_NAME = "ghcr.io/home-assistant/home-assistant:stable";
+
+describe("Home Assistant integration", () => {
+ beforeAll(async () => {
+ const containerRuntimeClient = await getContainerRuntimeClient();
+ await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME));
+ }, 100_000);
+
+ test("Test connection should work", async () => {
+ // Arrange
+ const startedContainer = await prepareHomeAssistantContainerAsync();
+ const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer);
+
+ // Act
+ const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
+
+ // Assert
+ await expect(actAsync()).resolves.not.toThrow();
+
+ // Cleanup
+ await startedContainer.stop();
+ }, 20_000); // Timeout of 20 seconds
+ test("Test connection should fail with wrong credentials", async () => {
+ // Arrange
+ const startedContainer = await prepareHomeAssistantContainerAsync();
+ const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer, "wrong-api-key");
+
+ // Act
+ const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow(IntegrationTestConnectionError);
+
+ // Cleanup
+ await startedContainer.stop();
+ }, 20_000); // Timeout of 20 seconds
+});
+
+const prepareHomeAssistantContainerAsync = async () => {
+ const homeAssistantContainer = createHomeAssistantContainer();
+ const startedContainer = await homeAssistantContainer.start();
+
+ await startedContainer.exec(["unzip", "-o", "/tmp/config.zip", "-d", "/config"]);
+ await startedContainer.restart();
+ return startedContainer;
+};
+
+const createHomeAssistantContainer = () => {
+ return new GenericContainer(IMAGE_NAME)
+ .withCopyFilesToContainer([
+ {
+ source: __dirname + "/volumes/home-assistant-config.zip",
+ target: "/tmp/config.zip",
+ },
+ ])
+ .withPrivilegedMode()
+ .withExposedPorts(8123)
+ .withWaitStrategy(Wait.forHttp("/", 8123));
+};
+
+const createHomeAssistantIntegration = (container: StartedTestContainer, apiKeyOverride?: string) => {
+ return new HomeAssistantIntegration({
+ id: "1",
+ decryptedSecrets: [
+ {
+ kind: "apiKey",
+ value: apiKeyOverride ?? DEFAULT_API_KEY,
+ },
+ ],
+ name: "Home assistant",
+ url: `http://${container.getHost()}:${container.getMappedPort(8123)}`,
+ });
+};
diff --git a/packages/integrations/test/pi-hole.spec.ts b/packages/integrations/test/pi-hole.spec.ts
index 829f8f891..23f8ef425 100644
--- a/packages/integrations/test/pi-hole.spec.ts
+++ b/packages/integrations/test/pi-hole.spec.ts
@@ -25,6 +25,36 @@ describe("Pi-hole integration", () => {
// Cleanup
await piholeContainer.stop();
}, 20_000); // Timeout of 20 seconds
+
+ test("testConnectionAsync should not throw", async () => {
+ // Arrange
+ const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
+ const piHoleIntegration = createPiHoleIntegration(piholeContainer, DEFAULT_API_KEY);
+
+ // Act
+ const actAsync = async () => await piHoleIntegration.testConnectionAsync();
+
+ // Assert
+ await expect(actAsync()).resolves.not.toThrow();
+
+ // Cleanup
+ await piholeContainer.stop();
+ }, 20_000); // Timeout of 20 seconds
+
+ test("testConnectionAsync should throw with wrong credentials", async () => {
+ // Arrange
+ const piholeContainer = await createPiHoleContainer(DEFAULT_PASSWORD).start();
+ const piHoleIntegration = createPiHoleIntegration(piholeContainer, "wrong-api-key");
+
+ // Act
+ const actAsync = async () => await piHoleIntegration.testConnectionAsync();
+
+ // Assert
+ await expect(actAsync()).rejects.toThrow();
+
+ // Cleanup
+ await piholeContainer.stop();
+ }, 20_000); // Timeout of 20 seconds
});
const createPiHoleContainer = (password: string) => {
diff --git a/packages/integrations/test/volumes/home-assistant-config.zip b/packages/integrations/test/volumes/home-assistant-config.zip
new file mode 100644
index 000000000..e66c5c76b
Binary files /dev/null and b/packages/integrations/test/volumes/home-assistant-config.zip differ
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index 8468fe44e..ef44dba64 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -389,7 +389,10 @@ export default {
create: "New integration",
},
testConnection: {
- action: "Test connection",
+ action: {
+ create: "Test connection and create",
+ edit: "Test connection and save",
+ },
alertNotice: "The Save button is enabled once a successful connection is established",
notification: {
success: {
@@ -400,7 +403,7 @@ export default {
title: "Invalid URL",
message: "The URL is invalid",
},
- notAllSecretsProvided: {
+ secretNotDefined: {
title: "Missing credentials",
message: "Not all credentials were provided",
},
@@ -412,6 +415,50 @@ export default {
title: "Connection failed",
message: "The connection could not be established",
},
+ badRequest: {
+ title: "Bad request",
+ message: "The request was malformed",
+ },
+ unauthorized: {
+ title: "Unauthorized",
+ message: "Probably wrong credentials",
+ },
+ forbidden: {
+ title: "Forbidden",
+ message: "Probably missing permissions",
+ },
+ notFound: {
+ title: "Not found",
+ message: "Probably wrong url or path",
+ },
+ internalServerError: {
+ title: "Internal server error",
+ message: "The server encountered an error",
+ },
+ serviceUnavailable: {
+ title: "Service unavailable",
+ message: "The server is currently unavailable",
+ },
+ connectionAborted: {
+ title: "Connection aborted",
+ message: "The connection was aborted",
+ },
+ domainNotFound: {
+ title: "Domain not found",
+ message: "The domain could not be found",
+ },
+ connectionRefused: {
+ title: "Connection refused",
+ message: "The connection was refused",
+ },
+ invalidJson: {
+ title: "Invalid JSON",
+ message: "The response was not valid JSON",
+ },
+ wrongPath: {
+ title: "Wrong path",
+ message: "The path is probably not correct",
+ },
},
},
secrets: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f24a19dc2..e07c70686 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -758,6 +758,9 @@ importers:
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log
+ '@homarr/translation':
+ specifier: workspace:^0.1.0
+ version: link:../translation
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
@@ -3171,20 +3174,20 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
- bare-events@2.3.1:
- resolution: {integrity: sha512-sJnSOTVESURZ61XgEleqmP255T6zTYwHPwE4r6SssIh0U9/uDvfpdoJYpVUerJJZH2fueO+CdT8ZT+OC/7aZDA==}
+ bare-events@2.4.2:
+ resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==}
bare-fs@2.3.1:
resolution: {integrity: sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==}
- bare-os@2.3.0:
- resolution: {integrity: sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==}
+ bare-os@2.4.0:
+ resolution: {integrity: sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==}
bare-path@2.1.3:
resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==}
- bare-stream@2.1.2:
- resolution: {integrity: sha512-az/7TFOh4Gk9Tqs1/xMFq5FuFoeZ9hZ3orsM2x69u8NXVUDXZnpdhG8mZY/Pv6DF954MGn+iIt4rFrG34eQsvg==}
+ bare-stream@2.1.3:
+ resolution: {integrity: sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -6794,7 +6797,7 @@ snapshots:
'@babel/highlight@7.23.4':
dependencies:
- '@babel/helper-validator-identifier': 7.22.20
+ '@babel/helper-validator-identifier': 7.24.6
chalk: 2.4.2
js-tokens: 4.0.0
@@ -8515,25 +8518,25 @@ snapshots:
balanced-match@1.0.2: {}
- bare-events@2.3.1:
+ bare-events@2.4.2:
optional: true
bare-fs@2.3.1:
dependencies:
- bare-events: 2.3.1
+ bare-events: 2.4.2
bare-path: 2.1.3
- bare-stream: 2.1.2
+ bare-stream: 2.1.3
optional: true
- bare-os@2.3.0:
+ bare-os@2.4.0:
optional: true
bare-path@2.1.3:
dependencies:
- bare-os: 2.3.0
+ bare-os: 2.4.0
optional: true
- bare-stream@2.1.2:
+ bare-stream@2.1.3:
dependencies:
streamx: 2.18.0
optional: true
@@ -11540,7 +11543,7 @@ snapshots:
queue-tick: 1.0.1
text-decoder: 1.1.0
optionalDependencies:
- bare-events: 2.3.1
+ bare-events: 2.4.2
string-width@4.2.3:
dependencies: