mslxdff 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.js CHANGED
@@ -1,125 +1,309 @@
1
- import { timingSafeEqual, createHash } from "node:crypto";
2
- import { injectReasoningContent, normalizeModel } from "./reasoning.js";
3
-
4
- export const errMsg = (err) => String(err?.message || err);
5
-
6
- export function createRouter({ token, upstream, models }) {
7
- return async function router(req, res) {
8
- const method = req.method || "GET";
9
- const path = (req.url || "").split("?")[0];
10
-
11
- const route = ROUTES.find((r) => r.method === method && r.path === path);
12
- if (!route) return notFound(res);
13
-
14
- if (route.requiresAuth && !authorized(req, token)) {
15
- res.statusCode = 401;
16
- res.setHeader("WWW-Authenticate", "Bearer");
17
- return json(res, 401, { error: "Unauthorized" });
18
- }
19
-
20
- await route.handler({ req, res, upstream, models });
21
- };
22
- }
23
-
24
- function authorized(req, token) {
25
- const header = req.headers["authorization"] || "";
26
- const match = /^Bearer (.+)$/.exec(header);
27
- if (!match) return false;
28
- const digests = (s) => createHash("sha256").update(s).digest();
29
- return timingSafeEqual(digests(match[1]), digests(token));
30
- }
31
-
32
- function json(res, status, body) {
33
- res.statusCode = status;
34
- res.setHeader("Content-Type", "application/json");
35
- res.end(JSON.stringify(body));
36
- }
37
-
38
- function notFound(res) {
39
- return json(res, 404, { error: "Not Found" });
40
- }
41
-
42
- function readBody(req) {
43
- return new Promise((resolve, reject) => {
44
- let data = "";
45
- req.on("data", (c) => (data += c));
46
- req.on("end", () => {
47
- try {
48
- resolve(data ? JSON.parse(data) : {});
49
- } catch (err) {
50
- reject(err);
51
- }
52
- });
53
- req.on("error", reject);
54
- });
55
- }
56
-
57
- const ROUTES = [
58
- {
59
- method: "GET",
60
- path: "/health",
61
- handler: ({ res }) => json(res, 200, { status: "ok" }),
62
- },
63
- {
64
- method: "POST",
65
- path: "/v1/chat/completions",
66
- requiresAuth: true,
67
- handler: async ({ req, res, upstream }) => {
68
- let body;
69
- try {
70
- body = await readBody(req);
71
- } catch {
72
- return json(res, 400, { error: "Invalid JSON body" });
73
- }
74
-
75
- const model = normalizeModel(body.model || "");
76
- const forwarded = { ...injectReasoningContent(model, body), model };
77
- let upRes;
78
- try {
79
- upRes = await upstream.chat(forwarded);
80
- } catch (err) {
81
- return json(res, 502, { error: errMsg(err) });
82
- }
83
-
84
- const contentType = upRes.headers.get("content-type") || "";
85
- const isStream = Boolean(body.stream) || contentType.includes("text/event-stream");
86
- res.statusCode = upRes.status;
87
-
88
- if (isStream) {
89
- res.setHeader("Content-Type", "text/event-stream");
90
- res.setHeader("Cache-Control", "no-cache");
91
- res.setHeader("Connection", "keep-alive");
92
- if (upRes.body) {
93
- for await (const chunk of upRes.body) {
94
- res.write(chunk);
95
- }
96
- }
97
- res.end();
98
- return;
99
- }
100
-
101
- const text = await upRes.text();
102
- try {
103
- json(res, upRes.status, JSON.parse(text));
104
- } catch {
105
- res.statusCode = upRes.status;
106
- res.setHeader("Content-Type", contentType || "text/plain");
107
- res.end(text);
108
- }
109
- },
110
- },
111
- {
112
- method: "GET",
113
- path: "/v1/models",
114
- requiresAuth: true,
115
- handler: async ({ res, models }) => {
116
- if (!models) return json(res, 501, { error: "Models service not configured" });
117
- try {
118
- const data = await models.get();
119
- json(res, 200, data);
120
- } catch (err) {
121
- json(res, 502, { error: errMsg(err) });
122
- }
123
- },
124
- },
125
- ];
1
+ import { timingSafeEqual, createHash } from "node:crypto";
2
+ import { injectReasoningContent, normalizeModel } from "./reasoning.js";
3
+ import { isAutoModel } from "./auto.js";
4
+ import { DEFAULT_MAX_HOPS } from "./peers.js";
5
+
6
+ export const errMsg = (err) => String(err?.message || err);
7
+
8
+ export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans }) {
9
+ return async function router(req, res) {
10
+ const method = req.method || "GET";
11
+ const path = (req.url || "").split("?")[0];
12
+
13
+ const route = ROUTES.find((r) => r.method === method && r.path === path);
14
+ if (!route) return notFound(res);
15
+
16
+ if (route.requiresAuth && !authorized(req, token)) {
17
+ res.statusCode = 401;
18
+ res.setHeader("WWW-Authenticate", "Bearer");
19
+ return json(res, 401, { error: "Unauthorized" });
20
+ }
21
+
22
+ await route.handler({ req, res, upstream, models, auto, logs, peers, maxHops, groups, bans, token });
23
+ };
24
+ }
25
+
26
+ function clientIp(req) {
27
+ const fwd = req.headers["x-forwarded-for"];
28
+ const head = typeof fwd === "string" ? fwd.split(",")[0].trim() : "";
29
+ const raw = String(head || req.socket.remoteAddress || "");
30
+ return raw.replace(/^::ffff:/, "").replace(/^::1$/, "127.0.0.1") || null;
31
+ }
32
+
33
+ function authorized(req, token) {
34
+ const header = req.headers["authorization"] || "";
35
+ const match = /^Bearer (.+)$/.exec(header);
36
+ if (!match) return false;
37
+ const digests = (s) => createHash("sha256").update(s).digest();
38
+ return timingSafeEqual(digests(match[1]), digests(token));
39
+ }
40
+
41
+ function json(res, status, body) {
42
+ res.statusCode = status;
43
+ res.setHeader("Content-Type", "application/json");
44
+ res.end(JSON.stringify(body));
45
+ }
46
+
47
+ function notFound(res) {
48
+ return json(res, 404, { error: "Not Found" });
49
+ }
50
+
51
+ function readBody(req) {
52
+ return new Promise((resolve, reject) => {
53
+ let data = "";
54
+ req.on("data", (c) => (data += c));
55
+ req.on("end", () => {
56
+ try {
57
+ resolve(data ? JSON.parse(data) : {});
58
+ } catch (err) {
59
+ reject(err);
60
+ }
61
+ });
62
+ req.on("error", reject);
63
+ });
64
+ }
65
+
66
+ async function relay(res, upRes, body) {
67
+ const contentType = upRes.headers.get("content-type") || "";
68
+ const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
69
+ res.statusCode = upRes.status;
70
+
71
+ if (isStream) {
72
+ res.setHeader("Content-Type", "text/event-stream");
73
+ res.setHeader("Cache-Control", "no-cache");
74
+ res.setHeader("Connection", "keep-alive");
75
+ if (upRes.body) {
76
+ for await (const chunk of upRes.body) {
77
+ res.write(chunk);
78
+ }
79
+ }
80
+ res.end();
81
+ return;
82
+ }
83
+
84
+ const text = await upRes.text();
85
+ try {
86
+ json(res, upRes.status, JSON.parse(text));
87
+ } catch {
88
+ res.statusCode = upRes.status;
89
+ res.setHeader("Content-Type", contentType || "text/plain");
90
+ res.end(text);
91
+ }
92
+ }
93
+
94
+ const PEER_TIMEOUT_MS = 30_000;
95
+
96
+ function parseHops(header) {
97
+ const n = Number(header);
98
+ return Number.isInteger(n) && n >= 0 ? n : 0;
99
+ }
100
+
101
+ async function forwardToPeer(peer, body, model, hops) {
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
104
+ try {
105
+ return await fetch(`${peer.url}/v1/chat/completions`, {
106
+ method: "POST",
107
+ headers: {
108
+ "Content-Type": "application/json",
109
+ "Authorization": `Bearer ${peer.token}`,
110
+ "x-mslxdff-hops": String(hops + 1),
111
+ "x-mslxdff-model-lock": model,
112
+ "Accept": "text/event-stream",
113
+ },
114
+ body: JSON.stringify({ ...body, model }),
115
+ signal: controller.signal,
116
+ });
117
+ } catch (err) {
118
+ return err;
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+
124
+ const ROUTES = [
125
+ {
126
+ method: "GET",
127
+ path: "/health",
128
+ handler: ({ res }) => json(res, 200, { status: "ok" }),
129
+ },
130
+ {
131
+ method: "POST",
132
+ path: "/v1/chat/completions",
133
+ requiresAuth: true,
134
+ handler: async ({ req, res, upstream, auto, logs, peers, maxHops }) => {
135
+ let body;
136
+ try {
137
+ body = await readBody(req);
138
+ } catch {
139
+ return json(res, 400, { error: "Invalid JSON body" });
140
+ }
141
+
142
+ const startedAt = Date.now();
143
+ const hops = parseHops(req.headers["x-mslxdff-hops"]);
144
+ const lockModel = req.headers["x-mslxdff-model-lock"] || "";
145
+ const requested = normalizeModel(lockModel || body.model || "");
146
+ const useAuto = isAutoModel(requested);
147
+
148
+ let order;
149
+ if (lockModel) {
150
+ order = [requested];
151
+ } else if (useAuto) {
152
+ order = auto ? await auto.candidates() : [""];
153
+ } else {
154
+ order = auto ? await auto.candidatesFor(requested) : [requested];
155
+ }
156
+ if (!order.length) order = [""];
157
+ const canFallback = order.length > 1;
158
+ const canForwardPeers = Boolean(peers) && hops < maxHops;
159
+
160
+ const logCall = (model, status) =>
161
+ logs?.appendCall({ model, auto: useAuto, status, durationMs: Date.now() - startedAt, stream: Boolean(body.stream) });
162
+ const logError = (model, status, message) =>
163
+ logs?.appendError({ model, auto: useAuto, status, message });
164
+
165
+ let lastErr = null;
166
+ for (const model of order) {
167
+ let upRes = null;
168
+ const forwarded = { ...injectReasoningContent(model, body), model };
169
+ try {
170
+ upRes = await upstream.chat(forwarded);
171
+ } catch (err) {
172
+ if (auto) await auto.recordError(model);
173
+ lastErr = { model, upstream: null, status: 502, message: errMsg(err) };
174
+ logError(model, 502, errMsg(err));
175
+ }
176
+ if (upRes && upRes.status >= 400) {
177
+ if (auto) await auto.recordError(model);
178
+ lastErr = { model, upstream: upRes, status: upRes.status, message: null };
179
+ logError(model, upRes.status, `upstream ${upRes.status}`);
180
+ upRes = null;
181
+ }
182
+ if (upRes) {
183
+ logCall(model, upRes.status);
184
+ return relay(res, upRes, body);
185
+ }
186
+
187
+ // local failed for this model: try the same model on peers, round-robin over available ones
188
+ if (canForwardPeers) {
189
+ const count = peers.available().length;
190
+ for (let i = 0; i < count; i++) {
191
+ const peer = peers.next();
192
+ if (!peer) break;
193
+ let peerRes = await forwardToPeer(peer, body, model, hops);
194
+ if (peerRes instanceof Error || peerRes.status >= 400) {
195
+ await peers.recordError(peer.url);
196
+ logError(model, peerRes instanceof Error ? 502 : peerRes.status,
197
+ peerRes instanceof Error ? errMsg(peerRes) : `peer ${peerRes.status}`);
198
+ continue;
199
+ }
200
+ logCall(model, peerRes.status);
201
+ return relay(res, peerRes, body);
202
+ }
203
+ }
204
+
205
+ if (canFallback) continue;
206
+ logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
207
+ if (lastErr?.upstream) return relay(res, lastErr.upstream, body);
208
+ return json(res, 502, { error: lastErr?.message || "all auto models failed" });
209
+ }
210
+
211
+ logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
212
+ if (lastErr?.upstream) return relay(res, lastErr.upstream, body);
213
+ return json(res, 502, { error: lastErr?.message || "all auto models failed" });
214
+ },
215
+ },
216
+ {
217
+ method: "POST",
218
+ path: "/v1/groups/join",
219
+ requiresAuth: false,
220
+ handler: async ({ req, res, groups, token, bans }) => {
221
+ if (!groups) return json(res, 501, { error: "Groups service not configured" });
222
+ const ip = clientIp(req);
223
+ const banned = bans?.isBanned(ip);
224
+ if (banned) {
225
+ return json(res, 403, { error: `banned until ${new Date(banned.until).toISOString()}` });
226
+ }
227
+ let body;
228
+ try {
229
+ body = await readBody(req);
230
+ } catch {
231
+ return json(res, 400, { error: "Invalid JSON body" });
232
+ }
233
+ if (!body?.name) return json(res, 400, { error: "group name is required" });
234
+
235
+ const fail = (msg) => {
236
+ try {
237
+ if (bans) {
238
+ const b = bans.recordFailure(ip);
239
+ if (b) console.error(`${ip} banned (${bans.threshold} failed joins)`);
240
+ }
241
+ } catch {
242
+ // ban bookkeeping must never break the join endpoint
243
+ }
244
+ return json(res, 403, { error: msg });
245
+ };
246
+
247
+ // Already-registered members re-register (sync) using their bearer token;
248
+ // new members must present the group name (which IS the password).
249
+ if (!body.key) {
250
+ const auth = /^Bearer (.+)$/.exec(req.headers["authorization"] || "");
251
+ const hit = auth && groups.membersForToken(body.name, auth[1]);
252
+ if (!hit) return fail("invalid member token");
253
+ try {
254
+ const refreshed = groups.upsertMember(body.name, {
255
+ memberName: body.memberName,
256
+ url: body.url,
257
+ token: body.token,
258
+ });
259
+ return json(res, 200, { object: "group", name: body.name, members: refreshed });
260
+ } catch (err) {
261
+ return json(res, 400, { error: errMsg(err) });
262
+ }
263
+ }
264
+
265
+ try {
266
+ const youPort = Number(body.myPort);
267
+ const youUrl = Number.isInteger(youPort) && youPort > 0 ? `http://${ip}:${youPort}` : "";
268
+ const memberUrl = String(body.url || youUrl);
269
+ if (!memberUrl) throw new Error("member url is required");
270
+ const members = groups.addMember(body.name, {
271
+ key: body.key,
272
+ memberName: body.memberName,
273
+ url: memberUrl,
274
+ token: body.token,
275
+ });
276
+ // 5 wrong passwords bans the source IP (48h) — see createBansService.
277
+ if (bans) bans.clear(ip);
278
+ // First join seeds the leader's own entry using the addr the joiner saw,
279
+ // so -creategroup needs no address argument.
280
+ if (!members.leader) {
281
+ const leaderUrl = String(body.leaderUrl || "").replace(/\/+$/, "");
282
+ if (leaderUrl) {
283
+ groups.upsertMember(body.name, { memberName: "leader", url: leaderUrl, token });
284
+ Object.assign(members, { leader: { url: leaderUrl, token } });
285
+ }
286
+ }
287
+ // Tell the joiner the url we registered them under (source IP + their port),
288
+ // so they can exclude themselves from their own peer list.
289
+ json(res, 200, { object: "group", name: body.name, members, you: { url: memberUrl } });
290
+ } catch (err) {
291
+ return fail(errMsg(err));
292
+ }
293
+ },
294
+ },
295
+ {
296
+ method: "GET",
297
+ path: "/v1/models",
298
+ requiresAuth: true,
299
+ handler: async ({ res, models }) => {
300
+ if (!models) return json(res, 501, { error: "Models service not configured" });
301
+ try {
302
+ const data = await models.get();
303
+ json(res, 200, data);
304
+ } catch (err) {
305
+ json(res, 502, { error: errMsg(err) });
306
+ }
307
+ },
308
+ },
309
+ ];
package/src/server.js CHANGED
@@ -1,27 +1,37 @@
1
- import { createServer as httpCreateServer } from "node:http";
2
-
3
- export function startServer({ router }, port = Number(process.env.PORT) || 8080) {
4
- const server = httpCreateServer((req, res) => {
5
- router(req, res).catch((err) => {
6
- res.statusCode = 500;
7
- res.setHeader("Content-Type", "application/json");
8
- res.end(JSON.stringify({ error: String(err?.message || err) }));
9
- });
10
- });
11
-
12
- const ready = () =>
13
- new Promise((resolve, reject) => {
14
- server.on("error", reject);
15
- server.listen(port, resolve);
16
- });
17
-
18
- const close = () =>
19
- new Promise((resolve) => {
20
- server.close(resolve);
21
- });
22
-
23
- process.on("SIGINT", close);
24
- process.on("SIGTERM", close);
25
-
26
- return { server, ready, close };
27
- }
1
+ import { createServer as httpCreateServer } from "node:http";
2
+ import { DEFAULT_PORT, getPort } from "./state.js";
3
+
4
+ export function startServer({ router }, port = resolvePort()) {
5
+ const server = httpCreateServer((req, res) => {
6
+ router(req, res).catch((err) => {
7
+ res.statusCode = 500;
8
+ res.setHeader("Content-Type", "application/json");
9
+ res.end(JSON.stringify({ error: String(err?.message || err) }));
10
+ });
11
+ });
12
+
13
+ const ready = () =>
14
+ new Promise((resolve, reject) => {
15
+ server.on("error", reject);
16
+ server.listen(port, resolve);
17
+ });
18
+
19
+ const close = () =>
20
+ new Promise((resolve) => {
21
+ server.close(resolve);
22
+ });
23
+
24
+ process.on("SIGINT", close);
25
+ process.on("SIGTERM", close);
26
+
27
+ return { server, ready, close };
28
+ }
29
+
30
+ export function resolvePort() {
31
+ const persisted = getPort();
32
+ if (persisted) return persisted;
33
+ const env = Number(process.env.PORT);
34
+ // 0 = OS-assigned ephemeral port (valid; used by tests/containers)
35
+ if (Number.isInteger(env) && env >= 0) return env;
36
+ return DEFAULT_PORT;
37
+ }
package/src/state.js CHANGED
@@ -1,39 +1,119 @@
1
- import { randomBytes } from "node:crypto";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import os from "node:os";
5
-
6
- export function defaultStateFile() {
7
- return process.env.MSLXDFREE_STATE_FILE ||
8
- join(os.homedir(), ".config", "mslxdfree", "state.json");
9
- }
10
-
11
- export function generateToken() {
12
- return randomBytes(32).toString("hex");
13
- }
14
-
15
- export async function loadToken({ file = defaultStateFile() } = {}) {
16
- try {
17
- const saved = JSON.parse(readFileSync(file, "utf8"));
18
- if (typeof saved.token === "string" && saved.token.length > 0) {
19
- return { token: saved.token, created: false };
20
- }
21
- } catch {
22
- // missing or unreadable generate fresh below
23
- }
24
- return { token: writeToken(file), created: true };
25
- }
26
-
27
- export async function refreshToken({ file = defaultStateFile() } = {}) {
28
- return writeToken(file);
29
- }
30
-
31
- function writeToken(file) {
32
- const state = {
33
- token: generateToken(),
34
- createdAt: new Date().toISOString(),
35
- };
36
- mkdirSync(dirname(file), { recursive: true });
37
- writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
38
- return state.token;
39
- }
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import os from "node:os";
5
+
6
+ export const DEFAULT_PORT = 8989;
7
+
8
+ export function defaultStateFile() {
9
+ return process.env.MSLXDFF_STATE_FILE ||
10
+ join(os.homedir(), ".config", "mslxdff", "state.json");
11
+ }
12
+
13
+ export function generateToken() {
14
+ return randomBytes(32).toString("hex");
15
+ }
16
+
17
+ export async function loadToken({ file = defaultStateFile() } = {}) {
18
+ const state = readState(file);
19
+ if (typeof state.token === "string" && state.token.length > 0) {
20
+ return { token: state.token, created: false };
21
+ }
22
+ return { token: writeState(file, { token: generateToken(), createdAt: new Date().toISOString() }).token, created: true };
23
+ }
24
+
25
+ export async function refreshToken({ file = defaultStateFile() } = {}) {
26
+ return writeState(file, { token: generateToken(), createdAt: new Date().toISOString() }).token;
27
+ }
28
+
29
+ export function setPort(port, { file = defaultStateFile() } = {}) {
30
+ const state = readState(file);
31
+ writeState(file, { ...state, port: Number(port) });
32
+ }
33
+
34
+ export function getPort({ file = defaultStateFile() } = {}) {
35
+ const port = readState(file).port;
36
+ return typeof port === "number" && Number.isInteger(port) && port > 0 ? port : null;
37
+ }
38
+
39
+ export function loadModelErrors({ file = defaultStateFile() } = {}) {
40
+ const errors = readState(file).modelErrors;
41
+ return errors && typeof errors === "object" && !Array.isArray(errors) ? errors : {};
42
+ }
43
+
44
+ export function saveModelErrors(errors, { file = defaultStateFile() } = {}) {
45
+ const state = readState(file);
46
+ writeState(file, { ...state, modelErrors: errors });
47
+ return errors;
48
+ }
49
+
50
+ export function loadPeers({ file = defaultStateFile() } = {}) {
51
+ const peers = readState(file).peers;
52
+ return Array.isArray(peers) ? peers : [];
53
+ }
54
+
55
+ export function savePeers(peers, { file = defaultStateFile() } = {}) {
56
+ const state = readState(file);
57
+ writeState(file, { ...state, peers });
58
+ return peers;
59
+ }
60
+
61
+ export function loadPeerErrors({ file = defaultStateFile() } = {}) {
62
+ const errors = readState(file).peerErrors;
63
+ return errors && typeof errors === "object" && !Array.isArray(errors) ? errors : {};
64
+ }
65
+
66
+ export function savePeerErrors(errors, { file = defaultStateFile() } = {}) {
67
+ const state = readState(file);
68
+ writeState(file, { ...state, peerErrors: errors });
69
+ return errors;
70
+ }
71
+
72
+ export function loadGroups({ file = defaultStateFile() } = {}) {
73
+ const groups = readState(file).groups;
74
+ return groups && typeof groups === "object" && !Array.isArray(groups) ? groups : {};
75
+ }
76
+
77
+ export function loadGroupsJoined({ file = defaultStateFile() } = {}) {
78
+ const joined = readState(file).groupsJoined;
79
+ return Array.isArray(joined) ? joined : [];
80
+ }
81
+
82
+ export function saveGroupsJoined(joined, { file = defaultStateFile() } = {}) {
83
+ const state = readState(file);
84
+ writeState(file, { ...state, groupsJoined: joined });
85
+ return joined;
86
+ }
87
+
88
+ export function loadBans({ file = defaultStateFile() } = {}) {
89
+ const bans = readState(file).bans;
90
+ return bans && typeof bans === "object" && !Array.isArray(bans) ? bans : {};
91
+ }
92
+
93
+ export function saveBans(bans, { file = defaultStateFile() } = {}) {
94
+ const state = readState(file);
95
+ writeState(file, { ...state, bans });
96
+ return bans;
97
+ }
98
+
99
+ export function saveGroups(groups, { file = defaultStateFile() } = {}) {
100
+ const state = readState(file);
101
+ writeState(file, { ...state, groups });
102
+ return groups;
103
+ }
104
+
105
+ function readState(file) {
106
+ try {
107
+ const saved = JSON.parse(readFileSync(file, "utf8"));
108
+ return typeof saved === "object" && saved !== null ? saved : {};
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
113
+
114
+ function writeState(file, patch) {
115
+ const merged = { ...readState(file), ...patch };
116
+ mkdirSync(dirname(file), { recursive: true });
117
+ writeFileSync(file, JSON.stringify(merged, null, 2), { mode: 0o600 });
118
+ return merged;
119
+ }