mirror of
https://github.com/gethomepage/homepage.git
synced 2026-08-31 01:15:53 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5873f8e7d5 | |||
| 6099837373 | |||
| f0bd255adb | |||
| 21bf7ed8e2 | |||
| 728c6fbe18 | |||
| 6a96289717 | |||
| fa47a2d86c | |||
| cc22d04429 | |||
| a414f376b9 | |||
| bdd77342d8 | |||
| 63d2172ef7 | |||
| 85e4e439bc | |||
| 36aa241689 | |||
| 0933ba015a | |||
| c57117daae |
@@ -49,6 +49,23 @@ environment:
|
||||
HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32"
|
||||
```
|
||||
|
||||
## Connecting Claude Desktop
|
||||
|
||||
Claude Desktop can use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to communicate with the homepage MCP endpoint in `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"homepage": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://localhost:3000/api/mcp", "--header", "X-Homepage-MCP-Token: your-token"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The **Add custom connector** option will not work: remote connectors authenticate with OAuth and cannot supply `HOMEPAGE_MCP_TOKEN`.
|
||||
|
||||
## Read-only by default
|
||||
|
||||
The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with:
|
||||
|
||||
@@ -65,6 +65,24 @@ export default defineConfig([
|
||||
allowElseIf: true,
|
||||
},
|
||||
],
|
||||
|
||||
// Keep the pre-eslint-plugin-react-hooks v6 lint policy until rules are adopted incrementally
|
||||
"react-hooks/config": "off",
|
||||
"react-hooks/error-boundaries": "off",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-hooks/gating": "off",
|
||||
"react-hooks/globals": "off",
|
||||
"react-hooks/immutability": "off",
|
||||
"react-hooks/incompatible-library": "off",
|
||||
"react-hooks/preserve-manual-memoization": "off",
|
||||
"react-hooks/purity": "off",
|
||||
"react-hooks/refs": "off",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/set-state-in-render": "off",
|
||||
"react-hooks/static-components": "off",
|
||||
"react-hooks/unsupported-syntax": "off",
|
||||
"react-hooks/use-memo": "off",
|
||||
},
|
||||
},
|
||||
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "2.1.0",
|
||||
"version": "2.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@kubernetes/client-node": "^2.0.0",
|
||||
"classnames": "^2.5.1",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dockerode": "^5.0.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"gamedig": "^5.3.3",
|
||||
"i18next": "^26.3.6",
|
||||
"ical.js": "^2.2.1",
|
||||
"js-yaml": "^4.3.1",
|
||||
"js-yaml": "^5.3.0",
|
||||
"json-rpc-2.0": "^1.7.1",
|
||||
"luxon": "^3.7.2",
|
||||
"memory-cache": "^0.2.0",
|
||||
@@ -39,7 +39,7 @@
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "^3.1.2",
|
||||
"swr": "^2.4.2",
|
||||
"swr": "^2.5.1",
|
||||
"systeminformation": "^5.33.1",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"urbackup-server-api": "^0.92.2",
|
||||
@@ -53,7 +53,7 @@
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^9.25.1",
|
||||
@@ -63,7 +63,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"postcss": "^8.5.26",
|
||||
"prettier": "^3.8.4",
|
||||
|
||||
Generated
+504
-294
File diff suppressed because it is too large
Load Diff
@@ -472,7 +472,7 @@
|
||||
"users": "Usuarios",
|
||||
"loginsLast24H": "Inicios de sesión (24h)",
|
||||
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
"authorizationsLast24H": "Autenticaciones (24 h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -15,6 +15,12 @@ export default async function handler(req, res) {
|
||||
return res.status(404).end("Not Found");
|
||||
}
|
||||
|
||||
// Method check precedes auth so CORS preflights aren't answered with a 401.
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).end("Method Not Allowed");
|
||||
}
|
||||
|
||||
const tokenError = mcpTokenConfigError();
|
||||
if (tokenError) {
|
||||
createLogger("mcp").error(tokenError);
|
||||
@@ -25,11 +31,6 @@ export default async function handler(req, res) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).end("Method Not Allowed");
|
||||
}
|
||||
|
||||
const response = handleMcpRequest(req.body);
|
||||
if (!response) {
|
||||
return res.status(202).end();
|
||||
|
||||
@@ -214,4 +214,38 @@ describe("pages/api/mcp", () => {
|
||||
expect(res.status).toHaveBeenCalledWith(405);
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
||||
});
|
||||
|
||||
it("answers unauthenticated CORS preflights with 405 rather than 401", async () => {
|
||||
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||
process.env.HOMEPAGE_MCP_TOKEN = "mcp-tok-0123456789abcdefghijklmnopqrstuv";
|
||||
const handler = await loadHandler();
|
||||
const res = mockResponse();
|
||||
|
||||
// a preflight never carries the Authorization header the browser strips
|
||||
await handler(
|
||||
{
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "https://claude.ai",
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "authorization,content-type",
|
||||
},
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(405);
|
||||
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
||||
expect(getServerSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still returns 404 for non-POST requests while disabled", async () => {
|
||||
delete process.env.HOMEPAGE_MCP_ENABLED;
|
||||
const handler = await loadHandler();
|
||||
const res = mockResponse();
|
||||
|
||||
await handler({ method: "OPTIONS", headers: {} }, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
||||
import {
|
||||
cleanServiceGroups,
|
||||
@@ -12,6 +10,7 @@ import {
|
||||
servicesFromKubernetes,
|
||||
} from "utils/config/service-helpers";
|
||||
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
|
||||
/**
|
||||
* Compares services by weight then by name.
|
||||
@@ -30,7 +29,7 @@ export async function bookmarksResponse() {
|
||||
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
|
||||
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
|
||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||
const bookmarks = yaml.load(fileContents);
|
||||
const bookmarks = loadYaml(fileContents);
|
||||
|
||||
if (!bookmarks) return [];
|
||||
|
||||
|
||||
@@ -30,10 +30,7 @@ vi.mock("fs", () => ({
|
||||
promises: fs,
|
||||
}));
|
||||
|
||||
vi.mock("js-yaml", () => ({
|
||||
default: yaml,
|
||||
...yaml,
|
||||
}));
|
||||
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||
|
||||
vi.mock("utils/config/config", () => config);
|
||||
vi.mock("utils/config/widget-helpers", () => widgetHelpers);
|
||||
|
||||
@@ -13,7 +13,7 @@ const { fs, yaml } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("fs", () => fs);
|
||||
vi.mock("js-yaml", () => ({ default: yaml, ...yaml }));
|
||||
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||
|
||||
describe("utils/config/config checkAndCopyConfig", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import yaml from "js-yaml";
|
||||
import cache from "memory-cache";
|
||||
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
|
||||
const cacheKey = "homepageEnvironmentVariables";
|
||||
const homepageVarPrefix = "HOMEPAGE_VAR_";
|
||||
const homepageFilePrefix = "HOMEPAGE_FILE_";
|
||||
@@ -42,7 +43,7 @@ export default function checkAndCopyConfig(config) {
|
||||
}
|
||||
|
||||
try {
|
||||
yaml.load(readFileSync(configYaml, "utf8"));
|
||||
loadYaml(readFileSync(configYaml, "utf8"));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return { ...e, config };
|
||||
@@ -85,7 +86,7 @@ export function getSettings() {
|
||||
const settingsYaml = join(CONF_DIR, "settings.yaml");
|
||||
const rawFileContents = readFileSync(settingsYaml, "utf8");
|
||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||
const initialSettings = yaml.load(fileContents) ?? {};
|
||||
const initialSettings = loadYaml(fileContents) ?? {};
|
||||
|
||||
if (initialSettings.layout) {
|
||||
// support yaml list but old spec was object so convert to that
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
|
||||
export function getDefaultDockerArgs(platform = process.platform) {
|
||||
if (platform !== "win32" && platform !== "darwin") {
|
||||
@@ -19,7 +18,7 @@ export default function getDockerArguments(server) {
|
||||
const configFile = path.join(CONF_DIR, "docker.yaml");
|
||||
const rawConfigData = readFileSync(configFile, "utf8");
|
||||
const configData = substituteEnvironmentVars(rawConfigData);
|
||||
const servers = yaml.load(configData);
|
||||
const servers = loadYaml(configData);
|
||||
|
||||
if (!server) {
|
||||
return getDefaultDockerArgs();
|
||||
|
||||
@@ -21,10 +21,7 @@ vi.mock("fs", () => ({
|
||||
readFileSync: fs.readFileSync,
|
||||
}));
|
||||
|
||||
vi.mock("js-yaml", () => ({
|
||||
default: yaml,
|
||||
...yaml,
|
||||
}));
|
||||
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
default: checkAndCopyConfig,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { isIPv6 } from "net";
|
||||
import path from "path";
|
||||
|
||||
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
|
||||
export function getKubernetes() {
|
||||
checkAndCopyConfig("kubernetes.yaml");
|
||||
const configFile = path.join(CONF_DIR, "kubernetes.yaml");
|
||||
const rawConfigData = readFileSync(configFile, "utf8");
|
||||
const configData = substituteEnvironmentVars(rawConfigData);
|
||||
return yaml.load(configData);
|
||||
return loadYaml(configData);
|
||||
}
|
||||
|
||||
export const getKubeConfig = () => {
|
||||
@@ -21,6 +22,10 @@ export const getKubeConfig = () => {
|
||||
switch (config?.mode) {
|
||||
case "cluster":
|
||||
kc.loadFromCluster();
|
||||
// node < 26 can't match an IPv6 host against an IP SAN, so verify against the DNS SAN instead (nodejs/node#64032)
|
||||
if (isIPv6(process.env.KUBERNETES_SERVICE_HOST ?? "")) {
|
||||
kc.clusters = kc.clusters.map((cluster) => ({ ...cluster, tlsServerName: "kubernetes.default.svc" }));
|
||||
}
|
||||
break;
|
||||
case "default":
|
||||
kc.loadFromDefault();
|
||||
|
||||
@@ -32,10 +32,7 @@ vi.mock("fs", () => ({
|
||||
readFileSync: fs.readFileSync,
|
||||
}));
|
||||
|
||||
vi.mock("js-yaml", () => ({
|
||||
default: yaml,
|
||||
...yaml,
|
||||
}));
|
||||
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
default: checkAndCopyConfig,
|
||||
@@ -46,6 +43,7 @@ vi.mock("@kubernetes/client-node", () => ({
|
||||
ApiextensionsV1Api: class ApiextensionsV1Api {},
|
||||
KubeConfig: class KubeConfig {
|
||||
loadFromCluster() {
|
||||
this.clusters = [{ name: "inCluster", server: "https://host:443", skipTLSVerify: false }];
|
||||
return kube.loadFromCluster();
|
||||
}
|
||||
loadFromDefault() {
|
||||
@@ -88,6 +86,27 @@ describe("utils/config/kubernetes", () => {
|
||||
expect(kc2).not.toBeNull();
|
||||
});
|
||||
|
||||
it("getKubeConfig sets tlsServerName when the in-cluster host is IPv6", () => {
|
||||
vi.stubEnv("KUBERNETES_SERVICE_HOST", "fd00:1018:2000::1");
|
||||
yaml.load.mockReturnValueOnce({ mode: "cluster" });
|
||||
|
||||
expect(getKubeConfig().clusters[0]).toMatchObject({
|
||||
name: "inCluster",
|
||||
tlsServerName: "kubernetes.default.svc",
|
||||
});
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("getKubeConfig leaves tlsServerName unset for non-IPv6 in-cluster hosts", () => {
|
||||
vi.stubEnv("KUBERNETES_SERVICE_HOST", "10.43.0.1");
|
||||
yaml.load.mockReturnValueOnce({ mode: "cluster" });
|
||||
|
||||
expect(getKubeConfig().clusters[0].tlsServerName).toBeUndefined();
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("checkCRD returns true when the CRD exists", async () => {
|
||||
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
|
||||
const logger = { error: vi.fn() };
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
|
||||
export function getProxmoxConfig() {
|
||||
checkAndCopyConfig("proxmox.yaml");
|
||||
const configFile = path.join(CONF_DIR, "proxmox.yaml");
|
||||
const rawConfigData = readFileSync(configFile, "utf8");
|
||||
const configData = substituteEnvironmentVars(rawConfigData);
|
||||
return yaml.load(configData);
|
||||
return loadYaml(configData);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
|
||||
readFileSync: fs.readFileSync,
|
||||
}));
|
||||
|
||||
vi.mock("js-yaml", () => ({
|
||||
default: yaml,
|
||||
...yaml,
|
||||
}));
|
||||
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
default: checkAndCopyConfig,
|
||||
|
||||
@@ -2,12 +2,12 @@ import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import Docker from "dockerode";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
||||
import getDockerArguments from "utils/config/docker";
|
||||
import { getKubeConfig } from "utils/config/kubernetes";
|
||||
import * as shvl from "utils/config/shvl";
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
import kubernetes from "utils/kubernetes/export";
|
||||
import createLogger from "utils/logger";
|
||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||
@@ -56,7 +56,7 @@ export async function servicesFromConfig() {
|
||||
const servicesYaml = path.join(CONF_DIR, "services.yaml");
|
||||
const rawFileContents = await fs.readFile(servicesYaml, "utf8");
|
||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||
const services = yaml.load(fileContents);
|
||||
const services = loadYaml(fileContents);
|
||||
return parseServicesToGroups(services);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function servicesFromDocker() {
|
||||
const dockerYaml = path.join(CONF_DIR, "docker.yaml");
|
||||
const rawDockerFileContents = await fs.readFile(dockerYaml, "utf8");
|
||||
const dockerFileContents = substituteEnvironmentVars(rawDockerFileContents);
|
||||
const servers = yaml.load(dockerFileContents);
|
||||
const servers = loadYaml(dockerFileContents);
|
||||
|
||||
if (!servers) {
|
||||
return [];
|
||||
|
||||
@@ -70,10 +70,7 @@ vi.mock("fs", () => ({
|
||||
promises: fs,
|
||||
}));
|
||||
|
||||
vi.mock("js-yaml", () => ({
|
||||
default: yaml,
|
||||
...yaml,
|
||||
}));
|
||||
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||
|
||||
vi.mock("utils/config/config", () => config);
|
||||
vi.mock("dockerode", () => ({ default: Docker }));
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
|
||||
export async function widgetsFromConfig() {
|
||||
checkAndCopyConfig("widgets.yaml");
|
||||
@@ -11,7 +10,7 @@ export async function widgetsFromConfig() {
|
||||
const widgetsYaml = path.join(CONF_DIR, "widgets.yaml");
|
||||
const rawFileContents = await fs.readFile(widgetsYaml, "utf8");
|
||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||
const widgets = yaml.load(fileContents);
|
||||
const widgets = loadYaml(fileContents);
|
||||
|
||||
if (!widgets) return [];
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
|
||||
promises: fs,
|
||||
}));
|
||||
|
||||
vi.mock("js-yaml", () => ({
|
||||
default: yaml,
|
||||
...yaml,
|
||||
}));
|
||||
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||
|
||||
vi.mock("utils/config/config", () => config);
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const EMPTY_DOCUMENT_ERROR = "expected a document, but the input is empty";
|
||||
const DEFAULT_SCHEMA = yaml.CORE_SCHEMA.withTags(yaml.timestampTag, yaml.mergeTag, yaml.legacyMapTag);
|
||||
|
||||
export function loadYaml(input, options) {
|
||||
try {
|
||||
return yaml.load(input, { schema: DEFAULT_SCHEMA, ...options });
|
||||
} catch (error) {
|
||||
// js-yaml v4 returned undefined for empty and comment-only documents.
|
||||
if (error?.reason === EMPTY_DOCUMENT_ERROR) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadYaml } from "./yaml";
|
||||
|
||||
describe("utils/config/yaml", () => {
|
||||
it.each(["", " \n", "# comment only\n"])("loads an empty document from %j as undefined", (input) => {
|
||||
expect(loadYaml(input)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("loads a populated document", () => {
|
||||
expect(loadYaml("title: Homepage\n")).toEqual({ title: "Homepage" });
|
||||
});
|
||||
|
||||
it("preserves v4 merge key behavior", () => {
|
||||
expect(loadYaml("defaults: &defaults\n href: https://example.com\nservice:\n <<: *defaults\n")).toEqual({
|
||||
defaults: { href: "https://example.com" },
|
||||
service: { href: "https://example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves v4 timestamp behavior without enabling YAML 1.1 booleans", () => {
|
||||
expect(loadYaml("date: 2026-08-21\nenabled: yes\n")).toEqual({
|
||||
date: new Date("2026-08-21T00:00:00.000Z"),
|
||||
enabled: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves v4 handling of non-string keys", () => {
|
||||
// an unsubstituted {{HOMEPAGE_VAR_*}} parses as a flow mapping key
|
||||
expect(loadYaml("- Plex:\n widget:\n key: {{HOMEPAGE_VAR_PLEX_KEY}}\n")).toEqual([
|
||||
{ Plex: { widget: { key: { "[object Object]": null } } } },
|
||||
]);
|
||||
expect(loadYaml("- Backups:\n - 2024-01-01:\n href: http://x\n")).toEqual([
|
||||
{ Backups: [{ [String(new Date("2024-01-01T00:00:00.000Z"))]: { href: "http://x" } }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still rejects invalid YAML", () => {
|
||||
expect(() => loadYaml("value: [\n")).toThrow();
|
||||
});
|
||||
});
|
||||
+6
-13
@@ -18,23 +18,16 @@ function combineMessageAndSplat() {
|
||||
}
|
||||
|
||||
function messageFormatter(logInfo) {
|
||||
if (logInfo.label) {
|
||||
if (logInfo.stack) {
|
||||
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.stack}`;
|
||||
}
|
||||
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.message}`;
|
||||
}
|
||||
|
||||
if (logInfo.stack) {
|
||||
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.stack}`;
|
||||
}
|
||||
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.message}`;
|
||||
const label = logInfo.label ? `<${logInfo.label}> ` : "";
|
||||
// e.g. fetch errors say nothing useful without the cause
|
||||
const cause = logInfo.cause ? `\ncaused by: ${logInfo.cause.stack ?? logInfo.cause}` : "";
|
||||
return `[${logInfo.timestamp}] ${logInfo.level}: ${label}${logInfo.stack || logInfo.message}${cause}`;
|
||||
}
|
||||
|
||||
function getConsoleLogger() {
|
||||
return new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.errors({ stack: true, cause: true }),
|
||||
combineMessageAndSplat(),
|
||||
winston.format.timestamp(),
|
||||
winston.format.colorize(),
|
||||
@@ -51,7 +44,7 @@ function getFileLogger() {
|
||||
|
||||
return new winston.transports.File({
|
||||
format: winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.errors({ stack: true, cause: true }),
|
||||
combineMessageAndSplat(),
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(messageFormatter),
|
||||
|
||||
@@ -170,6 +170,16 @@ describe("utils/logger", () => {
|
||||
});
|
||||
expect(plainMsg).toBe("[t] info: hello");
|
||||
|
||||
const causeMsg = formatter({
|
||||
timestamp: "t",
|
||||
level: "error",
|
||||
label: "x",
|
||||
stack: "STACK",
|
||||
message: "fetch failed",
|
||||
cause: { stack: "CAUSE STACK" },
|
||||
});
|
||||
expect(causeMsg).toBe("[t] error: <x> STACK\ncaused by: CAUSE STACK");
|
||||
|
||||
const out = splat.transform(
|
||||
{
|
||||
message: "Hello %s",
|
||||
|
||||
@@ -2,9 +2,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { join } from "path";
|
||||
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
import { CONF_DIR } from "utils/config/config";
|
||||
import { loadYaml } from "utils/config/yaml";
|
||||
|
||||
const PROTOCOL_VERSION = "2025-11-25";
|
||||
const SERVER_INFO = {
|
||||
@@ -118,7 +119,7 @@ function readConfig(file) {
|
||||
}
|
||||
|
||||
function parseYamlConfig(file) {
|
||||
const parsed = yaml.load(readConfig(file) || "");
|
||||
const parsed = loadYaml(readConfig(file) || "");
|
||||
return parsed ?? [];
|
||||
}
|
||||
|
||||
@@ -128,7 +129,7 @@ function validateYaml(file, content) {
|
||||
}
|
||||
|
||||
try {
|
||||
yaml.load(content || "");
|
||||
loadYaml(content || "");
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user