-
-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathconfig-resolve.js
More file actions
476 lines (438 loc) · 25.8 KB
/
Copy pathconfig-resolve.js
File metadata and controls
476 lines (438 loc) · 25.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
// Settings catalog + precedence resolver for the Settings UI (issue #40).
//
// This module is deliberately pure: it takes plain objects for every
// configuration source and returns what the UI renders. No fs, no require of
// server.js, no side effects — so the precedence rules can be unit-tested
// without booting a server.
//
// ── The precedence this file encodes is NOT invented ──────────────────────────
// It is what server.js actually does, read off these lines:
//
// Citations name a SYMBOL, never a line. An earlier revision of this file cited
// line numbers and every one of them was wrong within a few commits — a citation
// that rots silently is worse than none, because it is still believed.
//
// env-backed settings
// server.js the .env loader at the top of the file — `if (k && !(k in process.env))`.
// A variable already present in the real process environment is NEVER
// overwritten by .env. So:
// process env > .env file > hardcoded default
// server.js:PORT `process.env.PORT || 3000` — '' falls through
// server.js:_logLevel `process.env.LOG_LEVEL || 'info'`
// → an empty value behaves as unset, hence EMPTY_IS_UNSET.
//
// config.json-backed settings — TWO different chains, and they disagree:
// server.js:loadMergedConfig `l.lang || g.lang || 'en'`
// → local config.json > ~/.claude/config.json > default
// server.js:loadConfig reads CONFIG_PATH only
// → local config.json > default. The global file is
// never consulted, so a value set there is IGNORED.
// `terminal.*`, `externalAgents` and `slashCommands` live
// on this second chain.
//
// workdir
// server.js — `session.workdir || WORKDIR`, at every CLI spawn site
// → the workdir of the project registered in
// data/projects.json wins over WORKDIR for that session.
//
// Anything a source defines but the server never reads is emitted with
// `ignored: true` instead of being hidden — surfacing the inconsistency is the
// point of the feature.
'use strict';
const chatDefaults = require('./chat-defaults');
const editorLinks = require('./editor-links');
// Sources, most-significant first. Ordering here IS the precedence contract;
// resolve() walks candidates in the order it builds them, not in this order,
// but the UI uses these ids for badges and the tests pin both.
const SOURCES = ['project', 'process-env', 'dotenv', 'config-local', 'config-global', 'default'];
const SECTIONS = ['engine', 'defaults', 'agents', 'workspace', 'mcp', 'server', 'security', 'data', 'ui', 'advanced'];
// Keys whose value must never reach the browser. The explicit flag in the
// catalog is authoritative; this pattern is a second net so that a key added
// later with an obviously-secret name is masked even if the flag is forgotten.
const SECRET_NAME_RE = /(SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|CREDENTIAL|PRIVATE_?KEY)/i;
/** Repo convention (server.js, /api/remote-hosts): a set secret is '***', an unset one ''. */
function maskSecret(value) {
return (value === undefined || value === null || value === '') ? '' : '***';
}
function isSecretKey(key, def) {
if (def && def.secret === true) return true;
return SECRET_NAME_RE.test(String(key || ''));
}
/** The settings catalog.
* backing: 'env' — read from process.env at startup
* 'config' — read from config.json
* 'collection' — a keyed collection in config.json (counted, not edited here)
* merge: 'merged' — loadMergedConfig(): local overrides global
* 'local' — loadConfig(): local only, global silently ignored
* falsyFallsThrough — loadMergedConfig() resolves this key with `||`, not `??`,
* so an empty string in a config file does NOT reach the runtime.
* Without the flag the UI would report `""` as effective while the
* server is quietly running on the next source down.
* readOnly — the form refuses to write it (the raw-file tabs still can)
* restart — takes effect only after a server restart
*/
const SETTINGS = [
// ── AI models & engine ────────────────────────────────────────────────────
{ key: 'defaultEngine', section: 'engine', backing: 'config', merge: 'merged', path: 'defaultEngine',
type: 'enum', choices: ['api', 'subscription'], def: 'api', falsyFallsThrough: true,
src: 'server.js:loadMergedConfig' },
{ key: 'ANTHROPIC_BASE_URL', section: 'engine', backing: 'env', type: 'string', def: '', restart: true,
src: '.env.example / claude-cli.js env passthrough' },
// -- Defaults a NEW chat opens on (issue #58) ------------------------------
// Each one is what the toolbar preselects; a project may pin its own value on
// top, and that override lives in data/projects.json, not here. The choices
// are not restated -- chat-defaults.js owns them, and a second copy would rot.
{ key: 'chatDefaults.mode', section: 'defaults', backing: 'config', merge: 'merged', path: 'chatDefaults.mode',
type: 'enum', choices: chatDefaults.CHOICES.mode, def: chatDefaults.BUILTIN.mode, falsyFallsThrough: true,
src: 'chat-defaults.js:resolveChatDefaults' },
{ key: 'chatDefaults.agent', section: 'defaults', backing: 'config', merge: 'merged', path: 'chatDefaults.agent',
type: 'enum', choices: chatDefaults.CHOICES.agent, def: chatDefaults.BUILTIN.agent, falsyFallsThrough: true,
src: 'chat-defaults.js:resolveChatDefaults' },
{ key: 'chatDefaults.model', section: 'defaults', backing: 'config', merge: 'merged', path: 'chatDefaults.model',
type: 'enum', choices: chatDefaults.CHOICES.model, def: chatDefaults.BUILTIN.model, falsyFallsThrough: true,
src: 'chat-defaults.js:resolveChatDefaults' },
{ key: 'chatDefaults.effort', section: 'defaults', backing: 'config', merge: 'merged', path: 'chatDefaults.effort',
type: 'enum', choices: chatDefaults.CHOICES.effort, def: chatDefaults.BUILTIN.effort, falsyFallsThrough: true,
src: 'chat-defaults.js:resolveChatDefaults' },
{ key: 'chatDefaults.turns', section: 'defaults', backing: 'config', merge: 'merged', path: 'chatDefaults.turns',
type: 'number', int: true, def: chatDefaults.BUILTIN.turns,
min: chatDefaults.TURNS_MIN, max: chatDefaults.TURNS_MAX,
falsyFallsThrough: true,
src: 'chat-defaults.js:resolveChatDefaults' },
{ key: 'ANTHROPIC_API_KEY', section: 'engine', backing: 'env', type: 'string', def: '', secret: true,
readOnly: true, restart: true, src: '.env.example' },
{ key: 'ANTHROPIC_AUTH_TOKEN', section: 'engine', backing: 'env', type: 'string', def: '', secret: true,
readOnly: true, restart: true, src: '.env.example' },
{ key: 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS', section: 'engine', backing: 'env', type: 'string', def: '',
restart: true, src: '.env.example' },
// ── Agents & run limits ───────────────────────────────────────────────────
{ key: 'CLAUDE_IDLE_TIMEOUT_MS', section: 'agents', backing: 'env', type: 'number', def: 600000,
aliases: ['CLAUDE_TIMEOUT_MS'], restart: true, src: 'claude-cli.js:IDLE_TIMEOUT_MS' },
{ key: 'CLAUDE_HARD_CAP_MS', section: 'agents', backing: 'env', type: 'number', def: 0, restart: true,
src: 'claude-cli.js:HARD_CAP_MS' },
{ key: 'CLAUDE_PROMPT_GRACE_MS', section: 'agents', backing: 'env', type: 'number', def: 300000, restart: true,
src: 'claude-interactive.js:AWAIT_GRACE_MS' },
{ key: 'CLAUDE_STARTUP_PROMPT_WAIT_MS', section: 'agents', backing: 'env', type: 'number', def: 90000,
restart: true, src: 'claude-interactive.js:SPAWN_PROMPT_WAIT_MS' },
{ key: 'TASK_DISCONNECT_TIMEOUT_MS', section: 'agents', backing: 'env', type: 'number', def: 1800000,
restart: true, src: 'server.js:TASK_DISCONNECT_TIMEOUT_MS' },
{ key: 'MAX_TASK_WORKERS', section: 'agents', backing: 'env', type: 'number', def: 5, restart: true,
src: 'server.js:MAX_TASK_WORKERS' },
{ key: 'MULTI_AGENT_MAX_TURNS_CAP', section: 'agents', backing: 'env', type: 'number', def: 200, restart: true,
src: 'server.js:MULTI_AGENT_MAX_TURNS_CAP' },
// ── Workspace ─────────────────────────────────────────────────────────────
{ key: 'WORKDIR', section: 'workspace', backing: 'env', type: 'path', def: '', restart: true,
projectOverride: true, src: 'server.js:WORKDIR' },
{ key: 'APP_DIR', section: 'workspace', backing: 'env', type: 'path', def: '', readOnly: true, restart: true,
src: 'server.js:APP_DIR' },
{ key: 'recentProjectsCount', section: 'workspace', backing: 'config', merge: 'merged',
path: 'recentProjectsCount', type: 'number', def: 5, src: 'server.js:loadMergedConfig' },
// ── MCP, skills, commands (collections) ───────────────────────────────────
{ key: 'mcpServers', section: 'mcp', backing: 'collection', merge: 'merged', path: 'mcpServers',
readOnly: true, src: 'server.js:loadMergedConfig' },
{ key: 'skills', section: 'mcp', backing: 'collection', merge: 'merged', path: 'skills',
readOnly: true, src: 'server.js:loadMergedConfig' },
{ key: 'slashCommands', section: 'mcp', backing: 'collection', merge: 'local', path: 'slashCommands',
readOnly: true, src: 'server.js:loadMergedConfig' },
{ key: 'externalAgents', section: 'mcp', backing: 'collection', merge: 'local', path: 'externalAgents',
readOnly: true, src: 'server.js:loadConfig' },
// ── Server / network ──────────────────────────────────────────────────────
{ key: 'PORT', section: 'server', backing: 'env', type: 'number', def: 3000, restart: true, src: 'server.js:PORT' },
{ key: 'HOST', section: 'server', backing: 'env', type: 'string', def: '127.0.0.1', restart: true,
src: 'server.js:HOST' },
{ key: 'TRUST_PROXY', section: 'server', backing: 'env', type: 'bool', def: false, restart: true,
src: 'server.js:TRUST_PROXY_ENV' },
// Desktop builds pin HOST to loopback regardless of what HOST says, so it has to
// be visible here — otherwise the page reports an effective HOST the server ignores.
{ key: 'CCS_DESKTOP', section: 'server', backing: 'env', type: 'bool', def: false, readOnly: true,
restart: true, overrides: ['HOST'], src: 'server.js:HOST' },
{ key: 'CCS_ALLOWED_ORIGINS', section: 'server', backing: 'env', type: 'string', def: '', restart: true,
src: 'server.js:ALLOWED_ORIGINS' },
// ── Security ──────────────────────────────────────────────────────────────
{ key: 'SESSION_SECRET', section: 'security', backing: 'env', type: 'string', def: '', secret: true,
readOnly: true, restart: true, src: 'auth.js:setupUser' },
{ key: 'terminal.enabled', section: 'security', backing: 'config', merge: 'local', path: 'terminal.enabled',
type: 'bool', def: false, src: 'server.js:loadConfig' },
{ key: 'terminal.idleTimeoutMin', section: 'security', backing: 'config', merge: 'local',
path: 'terminal.idleTimeoutMin', type: 'number', def: 30, src: 'server.js:startTerminalReaper' },
{ key: 'terminal.maxLive', section: 'security', backing: 'config', merge: 'local', path: 'terminal.maxLive',
type: 'number', def: 3, src: 'server.js:startTerminalReaper' },
{ key: 'CCS_SSH_HOST_KEY_POLICY', section: 'security', backing: 'env', type: 'string', def: '', restart: true,
src: 'claude-ssh.js:makeHostVerifier' },
{ key: 'tunnel', section: 'security', backing: 'config', merge: 'local', path: 'tunnel',
readOnly: true, src: 'server.js:TunnelManager' },
// ── Data retention ────────────────────────────────────────────────────────
{ key: 'SESSION_TTL_DAYS', section: 'data', backing: 'env', type: 'number', def: 30, restart: true,
src: 'server.js:SESSION_TTL_DAYS' },
{ key: 'CLEANUP_INTERVAL_HOURS', section: 'data', backing: 'env', type: 'number', def: 24, restart: true,
src: 'server.js:CLEANUP_INTERVAL_HOURS' },
{ key: 'CCS_INTERRUPT_FILE_TTL_MS', section: 'data', backing: 'env', type: 'number', def: 1800000,
restart: true, src: 'server.js:INTERRUPT_FILE_TTL_MS' },
// Remote (SSH) import and exec caps. Two modules read CCS_REMOTE_EXEC_TIMEOUT_MS
// with the SAME default — listed once, cited at both.
{ key: 'CCS_REMOTE_IMPORT_MAX_BYTES', section: 'data', backing: 'env', type: 'number', def: 33554432,
restart: true, src: 'server.js:REMOTE_IMPORT_MAX_BYTES' },
{ key: 'CCS_REMOTE_IMPORT_MAX_TOTAL', section: 'data', backing: 'env', type: 'number', def: 134217728,
restart: true, src: 'server.js:REMOTE_IMPORT_MAX_TOTAL' },
{ key: 'CCS_REMOTE_EXEC_TIMEOUT_MS', section: 'data', backing: 'env', type: 'number', def: 30000,
restart: true, src: 'server.js:REMOTE_EXEC_TIMEOUT_MS + claude-ssh.js:REMOTE_EXEC_TIMEOUT_MS' },
{ key: 'CCS_REMOTE_EXEC_MAX_BYTES', section: 'data', backing: 'env', type: 'number', def: 67108864,
restart: true, src: 'claude-ssh.js:REMOTE_EXEC_MAX_BYTES' },
// ── Interface ─────────────────────────────────────────────────────────────
{ key: 'lang', section: 'ui', backing: 'config', merge: 'merged', path: 'lang', type: 'enum',
choices: ['uk', 'en', 'ru', 'fr', 'he'], def: 'en', falsyFallsThrough: true,
src: 'server.js:loadMergedConfig' },
// Which desktop editor the "Open in workspace" action targets (issue #63). A fixed
// list rather than a free-text binary name: the value becomes both a URI scheme and
// an argv[0], and neither is somewhere to accept arbitrary text. editor-links.js
// owns the catalog, so the choices are not restated here.
{ key: 'editor', section: 'ui', backing: 'config', merge: 'merged', path: 'editor', type: 'enum',
choices: editorLinks.EDITOR_IDS, def: editorLinks.DEFAULT_EDITOR, falsyFallsThrough: true,
src: 'editor-links.js:editorFor' },
// ── Advanced ──────────────────────────────────────────────────────────────
{ key: 'LOG_LEVEL', section: 'advanced', backing: 'env', type: 'enum',
choices: ['error', 'warn', 'info', 'debug'], def: 'info', restart: true, src: 'server.js:_logLevel' },
{ key: 'NODE_ENV', section: 'advanced', backing: 'env', type: 'string', def: 'development', restart: true,
src: 'server.js:_isProd' },
];
const BY_KEY = new Map(SETTINGS.map(s => [s.key, s]));
function getSetting(key) { return BY_KEY.get(key) || null; }
function getPath(obj, dotted) {
if (!obj || typeof obj !== 'object') return undefined;
let cur = obj;
for (const part of String(dotted).split('.')) {
if (cur === null || typeof cur !== 'object' || !(part in cur)) return undefined;
cur = cur[part];
}
return cur;
}
/** Env values are read as `process.env.X || fallback` all over server.js, so an
* empty string behaves exactly like an unset variable. Mirror that. */
function envIsUnset(v) { return v === undefined || v === null || v === ''; }
function sizeOf(v) {
if (Array.isArray(v)) return v.length;
if (v && typeof v === 'object') return Object.keys(v).length;
return 0;
}
/** Build the ordered candidate list for one setting. Index 0 is the winner
* unless it is `ignored`. */
function candidatesFor(def, sources) {
const s = sources || {};
const out = [];
if (def.backing === 'env') {
// A registered project's workdir beats the env for that session.
if (def.projectOverride && s.project && s.project.workdir) {
out.push({ source: 'project', value: s.project.workdir, label: s.project.name || s.project.id || '' });
}
for (const name of [def.key, ...(def.aliases || [])]) {
const pv = getPath(s.processEnv, name);
if (!envIsUnset(pv)) out.push({ source: 'process-env', value: pv, via: name });
const dv = getPath(s.dotenv, name);
if (!envIsUnset(dv)) out.push({ source: 'dotenv', value: dv, via: name });
}
} else if (def.backing === 'config' || def.backing === 'collection') {
// `||` in loadMergedConfig() means a falsy value is skipped, not honoured.
// Show it struck through — the same treatment a merge:'local' global gets —
// rather than reporting a value the server never uses.
const dead = (v) => def.falsyFallsThrough && !v;
const lv = getPath(s.localConfig, def.path);
if (lv !== undefined) out.push({ source: 'config-local', value: lv, ...(dead(lv) ? { ignored: true } : {}) });
const gv = getPath(s.globalConfig, def.path);
if (gv !== undefined && dead(gv)) out.push({ source: 'config-global', value: gv, ignored: true });
else if (gv !== undefined) {
// merge:'local' means loadConfig() never opens ~/.claude/config.json, so a
// value living there does nothing. Show it, struck through, instead of
// pretending the file is not there.
if (def.merge === 'merged') out.push({ source: 'config-global', value: gv });
else out.push({ source: 'config-global', value: gv, ignored: true });
}
}
// Two defaults (WORKDIR, APP_DIR) are computed from __dirname at boot and
// cannot be written into a static catalog. The caller passes them in.
const rd = s.runtimeDefaults && Object.prototype.hasOwnProperty.call(s.runtimeDefaults, def.key)
? s.runtimeDefaults[def.key] : undefined;
const dflt = rd !== undefined ? rd : def.def;
out.push({ source: 'default', value: dflt === undefined ? null : dflt });
return out;
}
/** Resolve one setting into what the UI renders. Secrets are masked here — the
* raw value never leaves this function. */
function resolveSetting(def, sources) {
const cands = candidatesFor(def, sources);
const live = cands.filter(c => !c.ignored);
const winner = live[0];
const secret = isSecretKey(def.key, def);
const isCollection = def.backing === 'collection';
const view = cands.map(c => {
const entry = { source: c.source, ignored: !!c.ignored };
if (c.via && c.via !== def.key) entry.via = c.via;
if (c.label) entry.label = c.label;
if (isCollection) entry.count = sizeOf(c.value);
else if (secret) entry.value = maskSecret(c.value);
else entry.value = c.value;
return entry;
});
// A source lost to a higher-precedence one → the user is looking at a value
// that is not the one they last edited. That is the case worth a badge.
const overriddenBy = live.length > 1 && live[1].source !== 'default' ? live[1].source : null;
const shadowedDotenv = live.some(c => c.source === 'dotenv') && winner.source === 'process-env';
const res = {
key: def.key,
section: def.section,
backing: def.backing,
type: def.type || 'string',
merge: def.merge || null,
secret,
readOnly: !!def.readOnly || isCollection,
restart: !!def.restart,
choices: def.choices || null,
codeRef: def.src || '',
sources: view,
effectiveSource: winner.source,
modified: winner.source !== 'default',
overriddenBy,
shadowedDotenv,
// A "reset to default" only makes sense when there is something in a file we
// own to remove. If PORT is exported by the shell, deleting the .env line
// changes nothing — offering the button there would be a lie.
resettable: !def.readOnly && !secret && !isCollection && cands.some(c =>
c.source === (def.backing === 'env' ? 'dotenv' : 'config-local')),
ignoredSources: cands.filter(c => c.ignored).map(c => c.source),
};
if (isCollection) {
// Effective collection size = what loadMergedConfig()/loadConfig() ends up with.
const l = getPath(sources && sources.localConfig, def.path);
const g = getPath(sources && sources.globalConfig, def.path);
if (def.merge === 'merged' && !Array.isArray(l) && !Array.isArray(g)) {
res.count = Object.keys({ ...(g || {}), ...(l || {}) }).length;
} else {
res.count = sizeOf(l !== undefined ? l : (def.merge === 'merged' ? g : undefined));
}
res.effective = null;
} else {
res.effective = secret ? maskSecret(winner.value) : winner.value;
if (secret) res.isSet = !envIsUnset(winner.value);
}
return res;
}
function resolveAll(sources) {
return SETTINGS.map(def => resolveSetting(def, sources));
}
/** Parse a .env file body into { key: value } using the exact rules of the
* loader at the top of server.js (first occurrence wins, `#` comments, quote strip). */
function parseDotenv(text) {
const out = {};
for (const line of String(text || '').split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
const k = t.slice(0, eq).trim();
const v = t.slice(eq + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
if (k && !(k in out)) out[k] = v;
}
return out;
}
/** Rewrite one KEY in a .env body. The loader takes the FIRST uncommented
* occurrence, so appending next to an existing active line would be a silent
* no-op — replace in place, and only append when the key is absent.
* Commented `# KEY=` lines are documentation and are left alone. */
function setDotenvValue(text, key, value) {
const body = String(text || '');
const lines = body.split('\n');
const re = new RegExp('^\\s*' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=');
for (let i = 0; i < lines.length; i++) {
if (re.test(lines[i])) { lines[i] = `${key}=${value}`; return lines.join('\n'); }
}
const sep = body.length === 0 || body.endsWith('\n') ? '' : '\n';
return body + sep + `${key}=${value}\n`;
}
/** Remove every ACTIVE `KEY=` line from a .env body. Commented `# KEY=` lines are
* documentation and stay — deleting them would silently erase the hint that the
* variable exists at all. */
function unsetDotenvValue(text, key) {
const re = new RegExp('^\\s*' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=');
const kept = String(text || '').split('\n').filter(l => !re.test(l));
return kept.join('\n');
}
/** Delete a dotted path, then drop the parent objects it leaves empty — an
* orphaned `"terminal": {}` in config.json reads as a setting that is still
* configured. */
function deletePath(obj, dotted) {
const parts = String(dotted).split('.');
const chain = [obj];
let cur = obj;
for (const part of parts.slice(0, -1)) {
if (!cur || typeof cur !== 'object' || !(part in cur)) return false;
cur = cur[part];
chain.push(cur);
}
if (!cur || typeof cur !== 'object') return false;
const leaf = parts[parts.length - 1];
if (!(leaf in cur)) return false;
delete cur[leaf];
for (let i = chain.length - 1; i > 0; i--) {
if (Object.keys(chain[i]).length) break;
delete chain[i - 1][parts[i - 1]];
}
return true;
}
/** Validate + normalise a value the browser sent for `key`.
* Returns { ok, value } or { ok:false, error }. */
function formWritable(def) {
if (!def) return { ok: false, error: 'unknown_setting' };
// Secret first: it is the more specific reason (every secret is also readOnly),
// and it is what the UI must tell the user.
if (isSecretKey(def.key, def)) return { ok: false, error: 'secret_not_editable' };
if (def.readOnly || def.backing === 'collection') return { ok: false, error: 'read_only' };
return { ok: true };
}
function coerceValue(def, raw) {
const w = formWritable(def);
if (!w.ok) return w;
switch (def.type) {
case 'bool': {
if (typeof raw === 'boolean') return { ok: true, value: raw };
if (raw === 'true' || raw === 'false') return { ok: true, value: raw === 'true' };
return { ok: false, error: 'expected_bool' };
}
case 'number': {
// `int` settings parse with Number (so '12px' is refused, not read as 12)
// and are truncated AFTER the range check, matching chat-defaults.coerce
// exactly. Without `int` the old parseInt behaviour is untouched, so every
// setting that shipped before keeps accepting what it accepted before.
const n = typeof raw === 'number' ? raw
: (def.int ? Number(String(raw).trim()) : parseInt(String(raw).trim(), 10));
if (!Number.isFinite(n) || (def.int && String(raw).trim() === '')) {
return { ok: false, error: 'expected_number' };
}
// A bound is only checked when the catalog declares one, so the settings
// that never had bounds keep accepting what they accepted before.
if (def.min !== undefined && n < def.min) return { ok: false, error: 'out_of_range' };
if (def.max !== undefined && n > def.max) return { ok: false, error: 'out_of_range' };
return { ok: true, value: def.int ? Math.trunc(n) : n };
}
case 'enum': {
const v = String(raw);
if (!(def.choices || []).includes(v)) return { ok: false, error: 'invalid_choice' };
return { ok: true, value: v };
}
default: {
if (typeof raw !== 'string' && typeof raw !== 'number' && typeof raw !== 'boolean') {
return { ok: false, error: 'expected_scalar' };
}
const v = String(raw);
// A .env value is a single line by construction; a newline would inject
// an extra variable into the file.
if (/[\r\n]/.test(v)) return { ok: false, error: 'newline_not_allowed' };
return { ok: true, value: v };
}
}
}
module.exports = {
SOURCES, SECTIONS, SETTINGS,
getSetting, getPath, maskSecret, isSecretKey,
candidatesFor, resolveSetting, resolveAll,
parseDotenv, setDotenvValue, unsetDotenvValue, deletePath, coerceValue, formWritable,
SECRET_NAME_RE,
};