opencode-webui 2.4.0 → 3.0.1
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/README.md +76 -8
- package/dist/assets/TerminalView-46ee1Rko.js +18 -0
- package/dist/assets/TerminalView-BrP-ENHg.css +1 -0
- package/dist/assets/client-DkhM26jE.js +2 -0
- package/dist/assets/index-BpbA074E.css +2 -0
- package/dist/assets/index-NnlYGssZ.js +113 -0
- package/dist/assets/report-D38Le2zy.js +2 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/badge-96.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/maskable-512.png +0 -0
- package/dist/index.html +10 -3
- package/dist/manifest.webmanifest +1 -0
- package/dist/sw.js +143 -0
- package/package.json +4 -1
- package/server/auth.ts +39 -22
- package/server/config.ts +498 -0
- package/server/index.ts +500 -36
- package/server/lifecyclePlugin.ts +118 -0
- package/server/setup.ts +774 -0
- package/server/userExtensions.ts +198 -5
- package/skills/webui/SKILL.md +59 -12
- package/webui-extensions/README.md +255 -17
- package/dist/assets/index-Cn0VQKKh.css +0 -1
- package/dist/assets/index-DYfCCaPy.js +0 -128
- package/dist/assets/report-BQezg0ph.js +0 -2
package/server/config.ts
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webui configuration — ONE persisted file, env overrides, one apply path.
|
|
3
|
+
*
|
|
4
|
+
* Serve/security knobs used to be env-only and were read once at boot, which
|
|
5
|
+
* meant the plugin-spawned background instance (whose env comes from the
|
|
6
|
+
* engine, not your shell) could silently fall back to different host/password
|
|
7
|
+
* values. This module makes them durable:
|
|
8
|
+
*
|
|
9
|
+
* ~/.config/opencode/webui/config.json (chmod 600)
|
|
10
|
+
*
|
|
11
|
+
* Precedence per key: explicit env var > config file > built-in default. The
|
|
12
|
+
* env escape hatch stays, so container/CI setups are unaffected. The CLI
|
|
13
|
+
* (`opencode-webui config …`) and the Settings › Access tab both edit this one
|
|
14
|
+
* file; nothing else persists serve settings.
|
|
15
|
+
*
|
|
16
|
+
* Passwords: `WEBUI_PASSWORD` is plaintext and never written. A password set
|
|
17
|
+
* through the CLI/UI is stored as a SHA-256 `passwordHash`, mirroring the
|
|
18
|
+
* in-memory digest the server already compares. `auth: "none"` disables the
|
|
19
|
+
* login entirely — allowed, but always warned about when the bind is reachable.
|
|
20
|
+
*
|
|
21
|
+
* Applying changes requires a restart (Bun binds the socket once and the auth
|
|
22
|
+
* digest is in memory); the API reports `restartRequired` and the CLI/UI reuse
|
|
23
|
+
* the existing restart machinery. No hot-reload of network settings, by design.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { isLoopbackHostname, isWildcardHostname, hostnameOf } from "./auth";
|
|
31
|
+
|
|
32
|
+
export type AuthMode = "password" | "none";
|
|
33
|
+
|
|
34
|
+
export interface WebuiConfig {
|
|
35
|
+
/** Bind address. Loopback by default; `0.0.0.0`/`::` expose to the network. */
|
|
36
|
+
host: string;
|
|
37
|
+
port: number;
|
|
38
|
+
/** `"password"` (default) or `"none"` — no login at all. */
|
|
39
|
+
auth: AuthMode;
|
|
40
|
+
/** SHA-256 hex of a password set via config; never plaintext. */
|
|
41
|
+
passwordHash: string | null;
|
|
42
|
+
/** Extra accepted Host headers (comma-separated in `WEBUI_ALLOWED_HOSTS`). */
|
|
43
|
+
allowedHosts: string[];
|
|
44
|
+
/** Honor X-Forwarded-Host/Proto (trusted reverse proxy in front). */
|
|
45
|
+
trustProxy: boolean;
|
|
46
|
+
/** Install the global command + lifecycle plugin on boot. */
|
|
47
|
+
autostart: boolean;
|
|
48
|
+
/** Canonical public URL, for display (e.g. behind Tailscale serve). */
|
|
49
|
+
publicUrl: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SourceKind = "env" | "file" | "default";
|
|
53
|
+
|
|
54
|
+
export interface EffectiveConfig extends WebuiConfig {
|
|
55
|
+
/** Per-key provenance, so the UI can show why a field is locked. */
|
|
56
|
+
sources: Record<keyof WebuiConfig, SourceKind>;
|
|
57
|
+
/** Plaintext `WEBUI_PASSWORD` from the env (never persisted). */
|
|
58
|
+
envPassword: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const DEFAULTS: WebuiConfig = {
|
|
62
|
+
host: "127.0.0.1",
|
|
63
|
+
port: 4097,
|
|
64
|
+
auth: "password",
|
|
65
|
+
passwordHash: null,
|
|
66
|
+
allowedHosts: [],
|
|
67
|
+
trustProxy: false,
|
|
68
|
+
autostart: true,
|
|
69
|
+
publicUrl: null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const KEYS: Array<keyof WebuiConfig> = [
|
|
73
|
+
"host",
|
|
74
|
+
"port",
|
|
75
|
+
"auth",
|
|
76
|
+
"passwordHash",
|
|
77
|
+
"allowedHosts",
|
|
78
|
+
"trustProxy",
|
|
79
|
+
"autostart",
|
|
80
|
+
"publicUrl",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The environment variable that pins each key (null = env cannot set it).
|
|
85
|
+
* Exported so the settings API can tell the UI WHICH variable is overriding a
|
|
86
|
+
* field — "env" alone leaves the user hunting for a file that does not exist.
|
|
87
|
+
*/
|
|
88
|
+
export const ENV_KEYS: Record<keyof WebuiConfig, string | null> = {
|
|
89
|
+
host: "WEBUI_HOST",
|
|
90
|
+
port: "WEBUI_PROXY_PORT",
|
|
91
|
+
auth: "WEBUI_PASSWORD",
|
|
92
|
+
passwordHash: "WEBUI_PASSWORD",
|
|
93
|
+
allowedHosts: "WEBUI_ALLOWED_HOSTS",
|
|
94
|
+
trustProxy: "WEBUI_TRUST_PROXY",
|
|
95
|
+
autostart: "WEBUI_NO_SETUP",
|
|
96
|
+
publicUrl: null,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// File I/O
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function configBaseDir(): string {
|
|
104
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "opencode", "webui");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function configPath(): string {
|
|
108
|
+
return join(configBaseDir(), "config.json");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
112
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Parse + validate on read; unknown/invalid fields fall back to defaults. */
|
|
116
|
+
export function readFileConfig(): WebuiConfig {
|
|
117
|
+
let raw: unknown;
|
|
118
|
+
try {
|
|
119
|
+
raw = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
120
|
+
} catch {
|
|
121
|
+
return { ...DEFAULTS };
|
|
122
|
+
}
|
|
123
|
+
if (!raw || typeof raw !== "object") return { ...DEFAULTS };
|
|
124
|
+
const input = raw as Record<string, unknown>;
|
|
125
|
+
const out: WebuiConfig = { ...DEFAULTS };
|
|
126
|
+
if (typeof input.host === "string" && input.host.length > 0) out.host = input.host;
|
|
127
|
+
if (typeof input.port === "number" && Number.isInteger(input.port) && input.port > 0 && input.port < 65536) out.port = input.port;
|
|
128
|
+
if (input.auth === "password" || input.auth === "none") out.auth = input.auth;
|
|
129
|
+
if (typeof input.passwordHash === "string" && /^[0-9a-f]{64}$/i.test(input.passwordHash)) out.passwordHash = input.passwordHash.toLowerCase();
|
|
130
|
+
if (Array.isArray(input.allowedHosts)) out.allowedHosts = input.allowedHosts.filter((v): v is string => typeof v === "string");
|
|
131
|
+
if (typeof input.trustProxy === "boolean") out.trustProxy = input.trustProxy;
|
|
132
|
+
if (typeof input.autostart === "boolean") out.autostart = input.autostart;
|
|
133
|
+
if (typeof input.publicUrl === "string" && input.publicUrl.length > 0) out.publicUrl = input.publicUrl;
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeFileConfig(config: WebuiConfig): void {
|
|
138
|
+
mkdirSync(configBaseDir(), { recursive: true, mode: 0o700 });
|
|
139
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
140
|
+
chmodSync(configPath(), 0o600);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Resolution (env > file > default)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function envPort(): number | undefined {
|
|
148
|
+
const raw = nonEmpty(process.env.WEBUI_PROXY_PORT);
|
|
149
|
+
if (raw === undefined) return undefined;
|
|
150
|
+
const n = Number(raw);
|
|
151
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function envAllowedHosts(): string[] | undefined {
|
|
155
|
+
const raw = process.env.WEBUI_ALLOWED_HOSTS;
|
|
156
|
+
if (raw === undefined) return undefined;
|
|
157
|
+
return raw
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((s) => hostnameOf(s.trim()))
|
|
160
|
+
.filter((s) => s.length > 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Effective config with per-key provenance. Never throws. */
|
|
164
|
+
export function resolveConfig(env: NodeJS.ProcessEnv = process.env): EffectiveConfig {
|
|
165
|
+
const file = readFileConfig();
|
|
166
|
+
const sources = {} as Record<keyof WebuiConfig, SourceKind>;
|
|
167
|
+
for (const key of KEYS) sources[key] = "file";
|
|
168
|
+
|
|
169
|
+
const envHost = nonEmpty(env.WEBUI_HOST);
|
|
170
|
+
const host = envHost ?? file.host;
|
|
171
|
+
sources.host = envHost !== undefined ? "env" : existsSync(configPath()) ? "file" : "default";
|
|
172
|
+
|
|
173
|
+
const ePort = envPort();
|
|
174
|
+
const port = ePort ?? file.port;
|
|
175
|
+
sources.port = ePort !== undefined ? "env" : existsSync(configPath()) ? "file" : "default";
|
|
176
|
+
|
|
177
|
+
const envPassword = nonEmpty(env.WEBUI_PASSWORD) ?? null;
|
|
178
|
+
// Env password forces password auth; otherwise the file decides.
|
|
179
|
+
const auth: AuthMode = envPassword ? "password" : file.auth;
|
|
180
|
+
sources.auth = envPassword ? "env" : existsSync(configPath()) ? "file" : "default";
|
|
181
|
+
sources.passwordHash = existsSync(configPath()) ? "file" : "default";
|
|
182
|
+
|
|
183
|
+
const eHosts = envAllowedHosts();
|
|
184
|
+
const allowedHosts = eHosts ?? file.allowedHosts;
|
|
185
|
+
sources.allowedHosts = eHosts !== undefined ? "env" : existsSync(configPath()) ? "file" : "default";
|
|
186
|
+
|
|
187
|
+
const envTrust = env.WEBUI_TRUST_PROXY;
|
|
188
|
+
const trustProxy = envTrust !== undefined ? envTrust === "1" : file.trustProxy;
|
|
189
|
+
sources.trustProxy = envTrust !== undefined ? "env" : existsSync(configPath()) ? "file" : "default";
|
|
190
|
+
|
|
191
|
+
const envNoSetup = env.WEBUI_NO_SETUP;
|
|
192
|
+
const autostart = envNoSetup !== undefined ? envNoSetup !== "1" : file.autostart;
|
|
193
|
+
sources.autostart = envNoSetup !== undefined ? "env" : existsSync(configPath()) ? "file" : "default";
|
|
194
|
+
|
|
195
|
+
sources.publicUrl = existsSync(configPath()) ? "file" : "default";
|
|
196
|
+
|
|
197
|
+
return { host, port, auth, passwordHash: file.passwordHash, allowedHosts, trustProxy, autostart, publicUrl: file.publicUrl, sources, envPassword };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Validation + mutation
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
export interface ConfigPatch {
|
|
205
|
+
host?: string;
|
|
206
|
+
port?: number;
|
|
207
|
+
auth?: AuthMode;
|
|
208
|
+
/** Plaintext; hashed before storage. Omitted = unchanged. */
|
|
209
|
+
password?: string;
|
|
210
|
+
/** Explicitly remove the stored password hash. */
|
|
211
|
+
clearPassword?: boolean;
|
|
212
|
+
allowedHosts?: string[];
|
|
213
|
+
trustProxy?: boolean;
|
|
214
|
+
autostart?: boolean;
|
|
215
|
+
publicUrl?: string | null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Field-level errors, empty = valid. */
|
|
219
|
+
export function validatePatch(patch: ConfigPatch): string[] {
|
|
220
|
+
const errors: string[] = [];
|
|
221
|
+
if (patch.host !== undefined && (typeof patch.host !== "string" || patch.host.trim().length === 0)) {
|
|
222
|
+
errors.push("host must be a non-empty address");
|
|
223
|
+
}
|
|
224
|
+
if (patch.port !== undefined && (!Number.isInteger(patch.port) || patch.port < 1 || patch.port > 65535)) {
|
|
225
|
+
errors.push("port must be an integer between 1 and 65535");
|
|
226
|
+
}
|
|
227
|
+
if (patch.auth !== undefined && patch.auth !== "password" && patch.auth !== "none") {
|
|
228
|
+
errors.push('auth must be "password" or "none"');
|
|
229
|
+
}
|
|
230
|
+
if (patch.password !== undefined && typeof patch.password === "string" && patch.password.length > 0 && patch.password.length < 8) {
|
|
231
|
+
errors.push("password must be at least 8 characters");
|
|
232
|
+
}
|
|
233
|
+
if (patch.allowedHosts !== undefined && (!Array.isArray(patch.allowedHosts) || patch.allowedHosts.some((v) => typeof v !== "string"))) {
|
|
234
|
+
errors.push("allowedHosts must be an array of strings");
|
|
235
|
+
}
|
|
236
|
+
if (patch.publicUrl !== undefined && patch.publicUrl !== null) {
|
|
237
|
+
try {
|
|
238
|
+
new URL(patch.publicUrl);
|
|
239
|
+
} catch {
|
|
240
|
+
errors.push("publicUrl must be a valid URL");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return errors;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeHostEntry(entry: string): string {
|
|
247
|
+
return hostnameOf(entry.trim());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Pure merge (no I/O) — lets callers preview the exposure of a pending change
|
|
252
|
+
* before committing it. Unknown fields are ignored.
|
|
253
|
+
*/
|
|
254
|
+
export function mergePatch(current: WebuiConfig, patch: ConfigPatch): WebuiConfig {
|
|
255
|
+
const next: WebuiConfig = { ...current };
|
|
256
|
+
if (patch.host !== undefined) next.host = patch.host.trim();
|
|
257
|
+
if (patch.port !== undefined) next.port = patch.port;
|
|
258
|
+
if (patch.auth !== undefined) next.auth = patch.auth;
|
|
259
|
+
if (patch.clearPassword === true) next.passwordHash = null;
|
|
260
|
+
if (typeof patch.password === "string" && patch.password.length > 0) next.passwordHash = hashPassword(patch.password);
|
|
261
|
+
if (patch.allowedHosts !== undefined) next.allowedHosts = patch.allowedHosts.map(normalizeHostEntry).filter((s) => s.length > 0);
|
|
262
|
+
if (patch.trustProxy !== undefined) next.trustProxy = patch.trustProxy;
|
|
263
|
+
if (patch.autostart !== undefined) next.autostart = patch.autostart;
|
|
264
|
+
if (patch.publicUrl !== undefined) next.publicUrl = patch.publicUrl === null ? null : patch.publicUrl.trim();
|
|
265
|
+
return next;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Merge a validated patch into the file and persist. */
|
|
269
|
+
export function applyConfigPatch(patch: ConfigPatch): WebuiConfig {
|
|
270
|
+
const next = mergePatch(readFileConfig(), patch);
|
|
271
|
+
writeFileConfig(next);
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Reset one key to its default (remove from the file). */
|
|
276
|
+
export function resetConfigKey(key: keyof WebuiConfig): WebuiConfig {
|
|
277
|
+
const next = readFileConfig();
|
|
278
|
+
next[key] = DEFAULTS[key] as never;
|
|
279
|
+
writeFileConfig(next);
|
|
280
|
+
return next;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function hashPassword(plain: string): string {
|
|
284
|
+
return createHash("sha256").update(plain, "utf8").digest("hex");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Exposure analysis (the warnings the UI shows)
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
export type ExposureLevel = "ok" | "warn" | "danger";
|
|
292
|
+
|
|
293
|
+
export interface Exposure {
|
|
294
|
+
level: ExposureLevel;
|
|
295
|
+
/** True when the bind/hosts are reachable beyond this machine. */
|
|
296
|
+
exposed: boolean;
|
|
297
|
+
/** True when there is no login in front of an exposed instance. */
|
|
298
|
+
unauthenticated: boolean;
|
|
299
|
+
message: string | null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function analyzeExposure(config: Pick<WebuiConfig, "host" | "auth" | "allowedHosts">): Exposure {
|
|
303
|
+
const bind = hostnameOf(config.host);
|
|
304
|
+
const wildcard = isWildcardHostname(bind);
|
|
305
|
+
const loopback = isLoopbackHostname(bind);
|
|
306
|
+
const anyHost = config.allowedHosts.includes("*");
|
|
307
|
+
const exposed = wildcard || !loopback || anyHost || config.allowedHosts.length > 0;
|
|
308
|
+
const unauthenticated = config.auth === "none";
|
|
309
|
+
if (unauthenticated && exposed) {
|
|
310
|
+
return {
|
|
311
|
+
level: "danger",
|
|
312
|
+
exposed,
|
|
313
|
+
unauthenticated,
|
|
314
|
+
message:
|
|
315
|
+
"No password and not loopback-only — anyone who can reach this port gets full access to your sessions and files.",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (exposed) {
|
|
319
|
+
return {
|
|
320
|
+
level: "warn",
|
|
321
|
+
exposed,
|
|
322
|
+
unauthenticated,
|
|
323
|
+
message: anyHost
|
|
324
|
+
? "The allowed-hosts list contains * — the Host check accepts anything. Fine behind a trusted proxy, risky otherwise."
|
|
325
|
+
: "Reachable beyond this machine. Make sure the network (and your password) is trusted.",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return { level: "ok", exposed, unauthenticated, message: null };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// API shape (never leaks a password or hash)
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
export interface RedactedConfig {
|
|
336
|
+
host: string;
|
|
337
|
+
port: number;
|
|
338
|
+
auth: AuthMode;
|
|
339
|
+
passwordSet: boolean;
|
|
340
|
+
allowedHosts: string[];
|
|
341
|
+
trustProxy: boolean;
|
|
342
|
+
autostart: boolean;
|
|
343
|
+
publicUrl: string | null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function redact(config: WebuiConfig, passwordSet = config.passwordHash !== null): RedactedConfig {
|
|
347
|
+
return {
|
|
348
|
+
host: config.host,
|
|
349
|
+
port: config.port,
|
|
350
|
+
auth: config.auth,
|
|
351
|
+
passwordSet,
|
|
352
|
+
allowedHosts: [...config.allowedHosts],
|
|
353
|
+
trustProxy: config.trustProxy,
|
|
354
|
+
autostart: config.autostart,
|
|
355
|
+
publicUrl: config.publicUrl,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// CLI — `opencode-webui config <get|set|unset|path>`
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
const CLI_FIELDS: Record<string, keyof WebuiConfig> = {
|
|
364
|
+
host: "host",
|
|
365
|
+
port: "port",
|
|
366
|
+
auth: "auth",
|
|
367
|
+
password: "passwordHash",
|
|
368
|
+
"allowed-hosts": "allowedHosts",
|
|
369
|
+
"trust-proxy": "trustProxy",
|
|
370
|
+
autostart: "autostart",
|
|
371
|
+
"public-url": "publicUrl",
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const CONFIG_USAGE = `opencode-webui config <command>
|
|
375
|
+
|
|
376
|
+
get show effective values and where each came from
|
|
377
|
+
set <key> <value...> set a value (add --confirm to allow a risky one)
|
|
378
|
+
unset <key> reset a key to its default
|
|
379
|
+
path print the config file path
|
|
380
|
+
|
|
381
|
+
keys: host · port · auth (password|none) · password · allowed-hosts ·
|
|
382
|
+
trust-proxy · autostart · public-url
|
|
383
|
+
|
|
384
|
+
Restart to apply: opencode-webui restart`;
|
|
385
|
+
|
|
386
|
+
function truthy(value: string): boolean {
|
|
387
|
+
return /^(1|true|yes|on)$/i.test(value.trim());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function cliPatch(key: string, value: string): ConfigPatch {
|
|
391
|
+
switch (key) {
|
|
392
|
+
case "host":
|
|
393
|
+
return { host: value };
|
|
394
|
+
case "port":
|
|
395
|
+
return { port: Number(value) };
|
|
396
|
+
case "auth":
|
|
397
|
+
return { auth: value as AuthMode };
|
|
398
|
+
case "password":
|
|
399
|
+
return { password: value, auth: "password" };
|
|
400
|
+
case "allowed-hosts":
|
|
401
|
+
return { allowedHosts: value.split(",").map((s) => s.trim()).filter((s) => s.length > 0) };
|
|
402
|
+
case "trust-proxy":
|
|
403
|
+
return { trustProxy: truthy(value) };
|
|
404
|
+
case "autostart":
|
|
405
|
+
return { autostart: truthy(value) };
|
|
406
|
+
case "public-url":
|
|
407
|
+
return { publicUrl: value === "" || value === "none" ? null : value };
|
|
408
|
+
default:
|
|
409
|
+
return {};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function readStdinLine(): Promise<string> {
|
|
414
|
+
try {
|
|
415
|
+
const text = await Bun.stdin.text();
|
|
416
|
+
return text.split(/\r?\n/)[0]?.trim() ?? "";
|
|
417
|
+
} catch {
|
|
418
|
+
return "";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function runConfigCli(args: string[]): Promise<number> {
|
|
423
|
+
const confirm = args.includes("--confirm");
|
|
424
|
+
const rest = args.filter((a) => a !== "--confirm");
|
|
425
|
+
const action = rest[0];
|
|
426
|
+
|
|
427
|
+
if (!action || action === "help" || action === "-h" || action === "--help") {
|
|
428
|
+
console.log(CONFIG_USAGE);
|
|
429
|
+
return action ? 0 : 1;
|
|
430
|
+
}
|
|
431
|
+
if (action === "path") {
|
|
432
|
+
console.log(configPath());
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
if (action === "get") {
|
|
436
|
+
const now = resolveConfig();
|
|
437
|
+
const rows: Array<[string, string, SourceKind]> = [
|
|
438
|
+
["host", now.host, now.sources.host],
|
|
439
|
+
["port", String(now.port), now.sources.port],
|
|
440
|
+
["auth", now.auth, now.sources.auth],
|
|
441
|
+
[
|
|
442
|
+
"password",
|
|
443
|
+
now.envPassword ? "set (WEBUI_PASSWORD)" : now.passwordHash ? "set (config)" : "(none)",
|
|
444
|
+
now.envPassword ? "env" : now.sources.passwordHash,
|
|
445
|
+
],
|
|
446
|
+
["allowed-hosts", now.allowedHosts.join(",") || "(none)", now.sources.allowedHosts],
|
|
447
|
+
["trust-proxy", String(now.trustProxy), now.sources.trustProxy],
|
|
448
|
+
["autostart", String(now.autostart), now.sources.autostart],
|
|
449
|
+
["public-url", now.publicUrl ?? "(none)", now.sources.publicUrl],
|
|
450
|
+
];
|
|
451
|
+
console.log(`config: ${configPath()}`);
|
|
452
|
+
for (const [key, value, source] of rows) console.log(` ${key.padEnd(14)} ${value.padEnd(26)} [${source}]`);
|
|
453
|
+
const exposure = analyzeExposure(now);
|
|
454
|
+
if (exposure.level !== "ok") console.log(` warning ${exposure.message}`);
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
if (action === "set") {
|
|
458
|
+
const key = rest[1];
|
|
459
|
+
if (!key || !(key in CLI_FIELDS)) {
|
|
460
|
+
console.error(`unknown key: ${key ?? "(none)"}\n`);
|
|
461
|
+
console.error(CONFIG_USAGE);
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
let value = rest.slice(2).join(" ").trim();
|
|
465
|
+
if (key === "password" && value.length === 0) value = await readStdinLine();
|
|
466
|
+
const patch = cliPatch(key, value);
|
|
467
|
+
const errors = validatePatch(patch);
|
|
468
|
+
if (errors.length > 0) {
|
|
469
|
+
console.error(`config: ${errors.join("; ")}`);
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
const pending = analyzeExposure(mergePatch(readFileConfig(), patch));
|
|
473
|
+
if (pending.level === "danger" && !confirm) {
|
|
474
|
+
console.error(`config: refused — ${pending.message}`);
|
|
475
|
+
console.error(" re-run with --confirm if that is what you want.");
|
|
476
|
+
return 1;
|
|
477
|
+
}
|
|
478
|
+
applyConfigPatch(patch);
|
|
479
|
+
console.log(`config: ${key} set`);
|
|
480
|
+
console.log(" restart to apply: opencode-webui restart");
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
if (action === "unset") {
|
|
484
|
+
const key = rest[1];
|
|
485
|
+
if (!key || !(key in CLI_FIELDS)) {
|
|
486
|
+
console.error(`unknown key: ${key ?? "(none)"}\n`);
|
|
487
|
+
console.error(CONFIG_USAGE);
|
|
488
|
+
return 1;
|
|
489
|
+
}
|
|
490
|
+
resetConfigKey(CLI_FIELDS[key]!);
|
|
491
|
+
console.log(`config: ${key} reset to default`);
|
|
492
|
+
console.log(" restart to apply: opencode-webui restart");
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
console.error(`unknown config command: ${action}\n`);
|
|
496
|
+
console.error(CONFIG_USAGE);
|
|
497
|
+
return 1;
|
|
498
|
+
}
|