neoctl-web 0.1.0

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/server.mjs ADDED
@@ -0,0 +1,450 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { coreRuntimeInfo, createWebRuntime, loadNeoPlugins, runWebServer } from './core-runtime.mjs';
7
+ import { createWebPluginHost } from './plugins.mjs';
8
+ import { createWebPluginSettings } from './plugin-settings.mjs';
9
+ import { createWebToolSettings } from './tool-settings.mjs';
10
+ import { createWorkspaceRuntimeManager } from './runtime-workspaces.mjs';
11
+ import { installRuntimeRouterIdleCleanup } from './runtime-router-cleanup.mjs';
12
+ import { createCpaQuotaMonitor } from './cpa-quota.mjs';
13
+ import { createMemoryMonitor } from './memory-monitor.mjs';
14
+
15
+ installRuntimeRouterIdleCleanup();
16
+ console.log(`neo core: ${coreRuntimeInfo.source} ${coreRuntimeInfo.version} (${coreRuntimeInfo.location})`);
17
+ process.env.NEO_CORE_VERSION = coreRuntimeInfo.version;
18
+ process.env.NEO_CLIENT_REVISION ||= `${coreRuntimeInfo.version}-${Date.now().toString(36)}`;
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const root = path.resolve(process.env.DIST_DIR || path.join(__dirname, 'dist'));
22
+ const host = process.env.APP_HOST || '0.0.0.0';
23
+ const port = Number(process.env.APP_PORT || process.env.PORT || 5173);
24
+ const runtimeTarget = new URL(process.env.NEO_RUNTIME_TARGET || 'http://127.0.0.1:3101');
25
+ const dataRoot = path.resolve(process.env.NEO_WEB_DATA_DIR || path.join(process.cwd(), '.neoctl-web'));
26
+ const promptLibraryFile = path.resolve(process.env.NEO_PROMPT_LIBRARY_FILE || path.join(dataRoot, 'prompt-library.json'));
27
+ const uploadsDir = path.resolve(process.env.NEO_UPLOADS_DIR || path.join(dataRoot, 'uploads'));
28
+ const pluginDir = path.resolve(process.env.NEO_WEB_PLUGIN_DIR || path.join(__dirname, 'plugins'));
29
+ const pluginDataDir = path.resolve(process.env.NEO_WEB_PLUGIN_DATA_DIR || dataRoot);
30
+ const cpaConfigFile = path.resolve(process.env.NEO_CPA_CONFIG_FILE || path.join(dataRoot, 'cpa-config.json'));
31
+ const memoryMonitorFile = path.resolve(process.env.NEO_MEMORY_MONITOR_FILE || path.join(dataRoot, 'memory-monitor.json'));
32
+ const pluginSettingsFile = path.resolve(process.env.NEO_WEB_PLUGIN_SETTINGS_FILE || path.join(dataRoot, 'plugins.json'));
33
+ const toolSettingsFile = path.resolve(process.env.NEO_WEB_TOOL_SETTINGS_FILE || path.join(dataRoot, 'tools.json'));
34
+ const maxUploadBytes = Number(process.env.NEO_UPLOAD_MAX_BYTES || 25 * 1024 * 1024);
35
+ const pluginSettings = await createWebPluginSettings(pluginSettingsFile);
36
+ const toolSettings = await createWebToolSettings(toolSettingsFile);
37
+ const pluginEnv = process.env.NEO_WEB_PLUGINS;
38
+ const pluginResources = await loadNeoPlugins({ directories: pluginDir, appDataDir: pluginDataDir });
39
+ const pluginHost = createWebPluginHost({
40
+ plugins: pluginResources,
41
+ enabled: pluginEnv?.trim() ? pluginEnv : pluginSettings.globalEnabledIds(),
42
+ locked: Boolean(pluginEnv?.trim()),
43
+ settings: pluginSettings,
44
+ });
45
+ const embedRuntime = process.env.NEO_EMBED_RUNTIME !== 'false';
46
+ const cpaQuotaMonitor = createCpaQuotaMonitor({ configFile: cpaConfigFile });
47
+ const memoryMonitor = createMemoryMonitor({
48
+ storageFile: memoryMonitorFile,
49
+ sampleMs: process.env.NEO_MEMORY_SAMPLE_MS,
50
+ retentionMs: process.env.NEO_MEMORY_RETENTION_MS,
51
+ publicSamples: process.env.NEO_MEMORY_PUBLIC_SAMPLES,
52
+ maxPersistedSamples: process.env.NEO_MEMORY_MAX_PERSISTED_SAMPLES,
53
+ maxPersistedBytes: process.env.NEO_MEMORY_MAX_PERSISTED_BYTES,
54
+ });
55
+ const workspaceRuntime = createWorkspaceRuntimeManager({
56
+ projectRoot: process.cwd(),
57
+ workspaceRoot: path.resolve(process.env.NEO_WORKSPACE_ROOT || path.join(process.cwd(), 'workspace')),
58
+ createRuntime: (runtimeOptions) => createWebRuntime({
59
+ ...runtimeOptions,
60
+ ...pluginHost.runtimePlugins(runtimeOptions.sessionId),
61
+ globalToolOverrides: toolSettings.globalOverrides(),
62
+ sessionToolOverrides: toolSettings.sessionOverrides(runtimeOptions.sessionId),
63
+ persistGlobalToolOverrides: (overrides) => toolSettings.setGlobalOverrides(overrides),
64
+ persistSessionToolOverrides: (sessionId, overrides) => toolSettings.setSessionOverrides(sessionId, overrides),
65
+ resolveSessionToolOverrides: (sessionId) => toolSettings.sessionOverrides(sessionId),
66
+ }),
67
+ });
68
+
69
+ const DEFAULT_APP_PROMPT_LIBRARY = [];
70
+
71
+ const mime = {
72
+ '.html': 'text/html; charset=utf-8',
73
+ '.js': 'text/javascript; charset=utf-8',
74
+ '.mjs': 'text/javascript; charset=utf-8',
75
+ '.css': 'text/css; charset=utf-8',
76
+ '.json': 'application/json; charset=utf-8',
77
+ '.svg': 'image/svg+xml',
78
+ '.png': 'image/png',
79
+ '.jpg': 'image/jpeg',
80
+ '.jpeg': 'image/jpeg',
81
+ '.webp': 'image/webp',
82
+ '.ico': 'image/x-icon',
83
+ };
84
+
85
+ function shouldProxy(pathname) {
86
+ return pathname === '/events' || pathname.startsWith('/api/') || pathname === '/api' || pathname.startsWith('/vendor/');
87
+ }
88
+
89
+ const server = http.createServer((req, res) => {
90
+ void routeRequest(req, res);
91
+ });
92
+
93
+ server.keepAliveTimeout = 70_000;
94
+ server.headersTimeout = 75_000;
95
+ if (embedRuntime) await startEmbeddedRuntime();
96
+ await cpaQuotaMonitor.start();
97
+ await memoryMonitor.start();
98
+ await new Promise((resolve, reject) => {
99
+ server.once('error', reject);
100
+ server.listen(port, host, () => {
101
+ server.off('error', reject);
102
+ console.log(`neo web listening on http://${host}:${port}, dist=${root}, runtime=${runtimeTarget.href}`);
103
+ resolve();
104
+ });
105
+ });
106
+
107
+ async function startEmbeddedRuntime() {
108
+ const runtimeHost = runtimeTarget.hostname || '127.0.0.1';
109
+ const runtimePort = runtimeTarget.port || '3101';
110
+ await runWebServer(['--host', runtimeHost, '--port', runtimePort], {
111
+ createRuntime: workspaceRuntime.createRuntime,
112
+ createRepl: workspaceRuntime.createRepl,
113
+ });
114
+ }
115
+
116
+ async function routeRequest(req, res) {
117
+ const url = new URL(req.url || '/', 'http://localhost');
118
+ try {
119
+ if (await pluginHost.route(req, res, url, { readJsonBody, sendJson })) return;
120
+ if (req.method === 'GET' && url.pathname === '/api/prompt-library') {
121
+ return sendJson(res, { items: await readPromptLibrary() });
122
+ }
123
+ if (req.method === 'GET' && url.pathname === '/api/cpa-quota') {
124
+ return sendJson(res, cpaQuotaMonitor.getPublicState());
125
+ }
126
+ if (req.method === 'GET' && url.pathname === '/api/memory') {
127
+ return sendJson(res, memoryMonitor.getPublicState());
128
+ }
129
+ if (req.method === 'POST' && url.pathname === '/api/cpa-config') {
130
+ const body = await readJsonBody(req);
131
+ const current = cpaQuotaMonitor.getPublicState();
132
+ const password = body?.preservePassword && current.config.hasPassword
133
+ ? undefined
134
+ : String(body?.password || '');
135
+ return sendJson(res, { ok: true, ...(await cpaQuotaMonitor.updateConfig({
136
+ url: body?.url,
137
+ password,
138
+ preservePassword: body?.preservePassword,
139
+ })) });
140
+ }
141
+ if (req.method === 'POST' && url.pathname === '/api/prompt-library') {
142
+ const body = await readJsonBody(req);
143
+ const item = normalizePromptItem(body?.item);
144
+ if (!item) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'invalid prompt item' }, 400);
145
+ const items = await readPromptLibrary();
146
+ const index = items.findIndex((entry) => entry.id === item.id);
147
+ if (index >= 0) items.splice(index, 1, item);
148
+ else items.unshift(item);
149
+ await writePromptLibrary(items);
150
+ return sendJson(res, { ok: true, item, items });
151
+ }
152
+ if (req.method === 'POST' && url.pathname === '/api/prompt-library/delete') {
153
+ const body = await readJsonBody(req);
154
+ const id = String(body?.id || '').trim();
155
+ if (!id) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'missing prompt id' }, 400);
156
+ const items = await readPromptLibrary();
157
+ const nextItems = items.filter((entry) => entry.id !== id);
158
+ await writePromptLibrary(nextItems);
159
+ return sendJson(res, { ok: true, items: nextItems });
160
+ }
161
+ if (req.method === 'POST' && url.pathname === '/api/prompt-library/reorder') {
162
+ const body = await readJsonBody(req);
163
+ const ids = Array.isArray(body?.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
164
+ if (!ids.length || new Set(ids).size !== ids.length) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'invalid prompt order' }, 400);
165
+ const items = await readPromptLibrary();
166
+ const byId = new Map(items.map((item) => [item.id, item]));
167
+ if (ids.some((id) => !byId.has(id))) return sendJson(res, { errorCode: 'PROMPT_INVALID', error: 'prompt order contains unknown id' }, 400);
168
+ const ordered = ids.map((id) => byId.get(id));
169
+ const included = new Set(ids);
170
+ ordered.push(...items.filter((item) => !included.has(item.id)));
171
+ await writePromptLibrary(ordered);
172
+ return sendJson(res, { ok: true, items: ordered });
173
+ }
174
+ if (req.method === 'GET' && url.pathname.startsWith('/api/uploads/')) {
175
+ const storedName = decodeURIComponent(url.pathname.slice('/api/uploads/'.length));
176
+ return serveUploadedFile(res, storedName);
177
+ }
178
+ if (req.method === 'GET' && url.pathname.startsWith('/api/local-images/')) {
179
+ const encodedPath = decodeURIComponent(url.pathname.slice('/api/local-images/'.length));
180
+ return serveLocalImage(res, encodedPath);
181
+ }
182
+ if (req.method === 'POST' && url.pathname === '/api/uploads') {
183
+ const body = await readJsonBody(req);
184
+ const file = await storeUploadedFile(body);
185
+ return sendJson(res, { ok: true, file });
186
+ }
187
+ if (shouldProxy(url.pathname)) {
188
+ return proxy(req, res);
189
+ }
190
+ return serveStatic(res, url);
191
+ } catch (error) {
192
+ sendJson(res, { errorCode: 'WEB_REQUEST_FAILED', error: error instanceof Error ? error.message : String(error) }, 500);
193
+ }
194
+ }
195
+
196
+ async function proxy(req, res) {
197
+ const target = new URL(req.url || '/', runtimeTarget);
198
+ target.protocol = runtimeTarget.protocol;
199
+ target.hostname = runtimeTarget.hostname;
200
+ target.port = runtimeTarget.port;
201
+
202
+ const method = req.method || 'GET';
203
+ const requestBody = method === 'GET' || method === 'HEAD' ? undefined : await readRequestBody(req);
204
+ const requestHeaders = new Headers();
205
+ for (const [key, value] of Object.entries(req.headers)) {
206
+ if (value === undefined) continue;
207
+ if (['host', 'content-length', 'connection', 'expect'].includes(key.toLowerCase())) continue;
208
+ requestHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
209
+ }
210
+
211
+ let upstream;
212
+ try {
213
+ upstream = await fetch(target, {
214
+ method,
215
+ headers: requestHeaders,
216
+ body: requestBody,
217
+ });
218
+
219
+ const responseHeaders = {};
220
+ for (const [key, value] of upstream.headers.entries()) {
221
+ if (['connection', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())) continue;
222
+ responseHeaders[key] = value;
223
+ }
224
+ res.writeHead(upstream.status, responseHeaders);
225
+ if (!upstream.body) {
226
+ res.end();
227
+ return;
228
+ }
229
+
230
+ res.on('close', () => {
231
+ upstream.body?.cancel().catch(() => {});
232
+ });
233
+
234
+ for await (const chunk of upstream.body) {
235
+ if (res.destroyed) break;
236
+ if (!res.write(chunk) && !await waitForDrainOrClose(res)) break;
237
+ }
238
+ if (!res.writableEnded) res.end();
239
+ } catch (error) {
240
+ const isTerminatedSocket =
241
+ error?.name === 'TypeError' &&
242
+ error?.message === 'terminated' &&
243
+ error?.cause?.code === 'UND_ERR_SOCKET';
244
+ if (error?.name === 'AbortError' || isTerminatedSocket || res.destroyed) return;
245
+ throw error;
246
+ }
247
+ }
248
+
249
+ function waitForDrainOrClose(res) {
250
+ if (res.destroyed) return Promise.resolve(false);
251
+ return new Promise((resolve) => {
252
+ const cleanup = () => {
253
+ res.off('drain', onDrain);
254
+ res.off('close', onClose);
255
+ res.off('error', onClose);
256
+ };
257
+ const onDrain = () => {
258
+ cleanup();
259
+ resolve(true);
260
+ };
261
+ const onClose = () => {
262
+ cleanup();
263
+ resolve(false);
264
+ };
265
+ res.once('drain', onDrain);
266
+ res.once('close', onClose);
267
+ res.once('error', onClose);
268
+ });
269
+ }
270
+
271
+ async function serveStatic(res, url) {
272
+ let pathname = decodeURIComponent(url.pathname);
273
+ if (pathname === '/') pathname = '/index.html';
274
+ let filePath = path.resolve(root, `.${pathname}`);
275
+ if (!filePath.startsWith(root + path.sep) && filePath !== root) {
276
+ res.writeHead(403);
277
+ res.end('forbidden');
278
+ return;
279
+ }
280
+ try {
281
+ const stat = await fsp.stat(filePath);
282
+ if (stat.isDirectory()) filePath = path.join(filePath, 'index.html');
283
+ } catch {
284
+ filePath = path.join(root, 'index.html');
285
+ }
286
+
287
+ try {
288
+ const body = await fsp.readFile(filePath);
289
+ const ext = path.extname(filePath).toLowerCase();
290
+ const cache = filePath.includes(`${path.sep}assets${path.sep}`) ? 'public, max-age=31536000, immutable' : 'no-store';
291
+ res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream', 'Cache-Control': cache });
292
+ res.end(body);
293
+ } catch {
294
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
295
+ res.end('not found');
296
+ }
297
+ }
298
+
299
+ async function readPromptLibrary() {
300
+ try {
301
+ const raw = await fsp.readFile(promptLibraryFile, 'utf8');
302
+ const parsed = JSON.parse(raw);
303
+ if (!Array.isArray(parsed)) throw new Error('prompt library file must contain an array');
304
+ return parsed.map(normalizePromptItem).filter(Boolean);
305
+ } catch (error) {
306
+ if (error?.code !== 'ENOENT') throw error;
307
+ const items = DEFAULT_APP_PROMPT_LIBRARY.map(clonePromptItem);
308
+ await writePromptLibrary(items);
309
+ return items;
310
+ }
311
+ }
312
+
313
+ async function writePromptLibrary(items) {
314
+ await fsp.mkdir(path.dirname(promptLibraryFile), { recursive: true });
315
+ await fsp.writeFile(promptLibraryFile, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
316
+ }
317
+
318
+ function normalizePromptItem(item) {
319
+ if (!item || typeof item !== 'object') return null;
320
+ const title = String(item.title || '').trim();
321
+ const content = String(item.content || '').trim();
322
+ if (!title || !content) return null;
323
+ return {
324
+ id: String(item.id || createPromptId()).trim(),
325
+ title,
326
+ content,
327
+ usage: String(item.usage || '').trim(),
328
+ };
329
+ }
330
+
331
+ function clonePromptItem(item) {
332
+ return { ...item };
333
+ }
334
+
335
+ function createPromptId() {
336
+ return `prompt-${Math.random().toString(36).slice(2, 10)}`;
337
+ }
338
+
339
+ async function storeUploadedFile(payload) {
340
+ const name = sanitizeUploadName(payload?.name);
341
+ if (!name) throw new Error('invalid upload name');
342
+ const data = String(payload?.data || '').trim();
343
+ if (!data) throw new Error('missing upload data');
344
+ const buffer = Buffer.from(data, 'base64');
345
+ if (!buffer.length) throw new Error('empty upload data');
346
+ if (buffer.length > maxUploadBytes) throw new Error(`upload too large: max ${maxUploadBytes} bytes`);
347
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
348
+ const random = Math.random().toString(36).slice(2, 8);
349
+ const storedName = `${stamp}-${random}-${name}`;
350
+ await fsp.mkdir(uploadsDir, { recursive: true });
351
+ const absolutePath = path.join(uploadsDir, storedName);
352
+ await fsp.writeFile(absolutePath, buffer);
353
+ return {
354
+ id: `upload-${random}`,
355
+ name,
356
+ storedName,
357
+ size: buffer.length,
358
+ mimeType: normalizeMimeType(payload?.mimeType),
359
+ absolutePath,
360
+ relativePath: path.relative(__dirname, absolutePath) || storedName,
361
+ url: `/api/uploads/${encodeURIComponent(storedName)}`,
362
+ };
363
+ }
364
+
365
+ async function serveUploadedFile(res, storedName) {
366
+ const safeName = path.basename(String(storedName || '').trim());
367
+ if (!safeName || safeName !== storedName) {
368
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
369
+ res.end('invalid upload name');
370
+ return;
371
+ }
372
+ const uploadRoot = path.resolve(uploadsDir);
373
+ const filePath = path.resolve(uploadRoot, safeName);
374
+ if (!filePath.startsWith(uploadRoot + path.sep)) {
375
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
376
+ res.end('forbidden');
377
+ return;
378
+ }
379
+ try {
380
+ const body = await fsp.readFile(filePath);
381
+ res.writeHead(200, {
382
+ 'Content-Type': mime[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
383
+ 'Cache-Control': 'public, max-age=31536000, immutable',
384
+ });
385
+ res.end(body);
386
+ } catch {
387
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
388
+ res.end('not found');
389
+ }
390
+ }
391
+
392
+ async function serveLocalImage(res, encodedPath) {
393
+ let filePath = '';
394
+ try {
395
+ filePath = Buffer.from(encodedPath, 'base64url').toString('utf8');
396
+ } catch {
397
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
398
+ res.end('invalid image path');
399
+ return;
400
+ }
401
+ const absolutePath = path.resolve(filePath);
402
+ const contentType = mime[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream';
403
+ if (!contentType.startsWith('image/')) {
404
+ res.writeHead(415, { 'Content-Type': 'text/plain; charset=utf-8' });
405
+ res.end('not an image');
406
+ return;
407
+ }
408
+ try {
409
+ const fileStat = await fsp.stat(absolutePath);
410
+ if (!fileStat.isFile()) throw new Error('not a file');
411
+ res.writeHead(200, {
412
+ 'Content-Type': contentType,
413
+ 'Content-Length': String(fileStat.size),
414
+ 'Cache-Control': 'no-store',
415
+ });
416
+ fs.createReadStream(absolutePath)
417
+ .on('error', () => res.destroy())
418
+ .pipe(res);
419
+ } catch {
420
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
421
+ res.end('not found');
422
+ }
423
+ }
424
+
425
+ function sanitizeUploadName(value) {
426
+ const base = path.basename(String(value || '').trim()).replace(/[<>:"/\\|?*\u0000-\u001f]+/g, '-');
427
+ return base.replace(/\s+/g, ' ').trim().slice(0, 180);
428
+ }
429
+
430
+ function normalizeMimeType(value) {
431
+ const mimeType = String(value || '').trim();
432
+ return mimeType || 'application/octet-stream';
433
+ }
434
+
435
+ async function readJsonBody(req) {
436
+ const body = await readRequestBody(req);
437
+ if (!body.length) return {};
438
+ return JSON.parse(body.toString('utf8'));
439
+ }
440
+
441
+ async function readRequestBody(req) {
442
+ const chunks = [];
443
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
444
+ return Buffer.concat(chunks);
445
+ }
446
+
447
+ function sendJson(res, value, status = 200) {
448
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
449
+ res.end(JSON.stringify(value));
450
+ }
@@ -0,0 +1,80 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function createWebToolSettings(storageFile) {
5
+ let state = await readState(storageFile);
6
+ let writeQueue = Promise.resolve();
7
+
8
+ function snapshot() {
9
+ return structuredClone(state);
10
+ }
11
+
12
+ async function update(next) {
13
+ state = next;
14
+ writeQueue = writeQueue.catch(() => undefined).then(() => writeState(storageFile, state));
15
+ await writeQueue;
16
+ }
17
+
18
+ return {
19
+ snapshot,
20
+ globalOverrides() {
21
+ return { ...state.global };
22
+ },
23
+ sessionOverrides(sessionId) {
24
+ const value = state.sessions[String(sessionId || '')];
25
+ return value && typeof value === 'object' ? { ...value } : {};
26
+ },
27
+ async setGlobalOverrides(overrides) {
28
+ await update({ ...state, global: normalizeOverrides(overrides) });
29
+ },
30
+ async setSessionOverrides(sessionId, overrides) {
31
+ const id = String(sessionId || '').trim();
32
+ if (!id) throw new Error('session id is required');
33
+ const sessions = { ...state.sessions };
34
+ const normalized = normalizeOverrides(overrides);
35
+ if (Object.keys(normalized).length) sessions[id] = normalized;
36
+ else delete sessions[id];
37
+ await update({ ...state, sessions });
38
+ },
39
+ };
40
+ }
41
+
42
+ function normalizeOverrides(value) {
43
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
44
+ return Object.fromEntries(
45
+ Object.entries(value)
46
+ .filter(([name, enabled]) => String(name).trim() && typeof enabled === 'boolean')
47
+ .map(([name, enabled]) => [String(name).trim(), enabled])
48
+ .sort(([left], [right]) => left.localeCompare(right)),
49
+ );
50
+ }
51
+
52
+ async function readState(storageFile) {
53
+ if (!storageFile) return emptyState();
54
+ try {
55
+ const parsed = JSON.parse(await fsp.readFile(storageFile, 'utf8'));
56
+ const sessions = parsed?.sessions && typeof parsed.sessions === 'object' && !Array.isArray(parsed.sessions)
57
+ ? Object.fromEntries(Object.entries(parsed.sessions).map(([id, value]) => [id, normalizeOverrides(value)]))
58
+ : {};
59
+ return {
60
+ version: 1,
61
+ global: normalizeOverrides(parsed?.global),
62
+ sessions,
63
+ };
64
+ } catch (error) {
65
+ if (error?.code !== 'ENOENT') console.warn(`failed to read web tool settings: ${error.message || error}`);
66
+ return emptyState();
67
+ }
68
+ }
69
+
70
+ function emptyState() {
71
+ return { version: 1, global: {}, sessions: {} };
72
+ }
73
+
74
+ async function writeState(storageFile, state) {
75
+ if (!storageFile) return;
76
+ await fsp.mkdir(path.dirname(storageFile), { recursive: true });
77
+ const temporary = `${storageFile}.${process.pid}.tmp`;
78
+ await fsp.writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
79
+ await fsp.rename(temporary, storageFile);
80
+ }