opencode-webui 2.4.0 → 3.0.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.
@@ -0,0 +1,774 @@
1
+ /**
2
+ * First-run setup — a fast global command and an OpenCode lifecycle plugin.
3
+ *
4
+ * The webui is a foreground process; `bunx opencode-webui` re-resolves the
5
+ * package each time (slow) and nothing restarts it after a reboot. This module
6
+ * makes the webui self-install, once, into the user's environment:
7
+ *
8
+ * 1. a launcher shim on PATH (`~/.local/bin/opencode-webui`) that execs the
9
+ * installed entry with bun directly — no bunx resolution, no network;
10
+ * 2. the built-in OpenCode lifecycle plugin in
11
+ * `<config>/opencode/plugins/opencode-webui/`, which the engine
12
+ * auto-discovers globally and activates on use: it starts the webui
13
+ * detached, so the webui comes up whenever you use OpenCode;
14
+ * 3. `launch.json` in the state dir — the stable handoff the plugin reads;
15
+ * 4. a pidfile so `stop` / `restart` / `update` can find the running server.
16
+ *
17
+ * Gating, one state the user owns (same shape as before): a dev checkout and
18
+ * `WEBUI_SANDBOX=1` never install; `WEBUI_NO_SETUP=1` (or `WEBUI_NO_PLUGIN=1`
19
+ * for just the plugin) skips a run; `uninstall` removes everything and records
20
+ * the decline so the next boot does not resurrect it. Files are only
21
+ * overwritten when they carry our own marker — a foreign file of the same name
22
+ * is left alone and reported.
23
+ *
24
+ * This is a deliberate, documented exception to the "webui never installs
25
+ * engine plugins" rule in docs/engine-payload-convention.md: the webui installs
26
+ * *its own* lifecycle plugin, opt-out, because the user asked for the webui to
27
+ * follow OpenCode's lifecycle.
28
+ */
29
+
30
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { delimiter, dirname, join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import {
35
+ LIFECYCLE_PLUGIN_PACKAGE_JSON,
36
+ LIFECYCLE_PLUGIN_SOURCE,
37
+ } from "./lifecyclePlugin";
38
+ import { runConfigCli } from "./config";
39
+
40
+ export const SERVICE_NAME = "opencode-webui";
41
+ const REPO_URL = "https://github.com/AbdelftahZowail/opencode-webui";
42
+ const RELEASES_URL = `${REPO_URL}/releases/latest`;
43
+ const MANAGED_MARK = "managed by opencode-webui";
44
+ const PLUGIN_MARKER = ".opencode-webui.json";
45
+
46
+ export type SetupStatus =
47
+ | "installed"
48
+ | "updated"
49
+ | "present"
50
+ | "declined"
51
+ | "disabled"
52
+ | "dev"
53
+ | "error";
54
+
55
+ export interface LaunchCommand {
56
+ /** argv for the launcher + plugin (absolute paths — no inherited PATH). */
57
+ cmd: string[];
58
+ /** Human form for hints, e.g. `bunx opencode-webui`. */
59
+ display: string;
60
+ }
61
+
62
+ export interface SetupOptions {
63
+ /** `import.meta.url` of the server entry (index.ts). */
64
+ entryUrl: string;
65
+ port: number;
66
+ version: string;
67
+ /** Explicit `setup`: ignore dev-checkout and declined gates. */
68
+ force?: boolean;
69
+ quiet?: boolean;
70
+ /** Config `autostart` — false skips first-run setup (unless forced). */
71
+ autostart?: boolean;
72
+ }
73
+
74
+ export interface SetupResult {
75
+ status: SetupStatus;
76
+ message: string | null;
77
+ wrapper: string | null;
78
+ plugin: string | null;
79
+ }
80
+
81
+ export interface SetupStatusInfo {
82
+ wrapper: string | null;
83
+ wrapperPresent: boolean;
84
+ wrapperOnPath: boolean;
85
+ plugin: string | null;
86
+ pluginPresent: boolean;
87
+ launch: LaunchCommand | null;
88
+ declined: boolean;
89
+ pid: number | null;
90
+ port: number | null;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Paths
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function stateDir(): string {
98
+ const base =
99
+ process.env.XDG_STATE_HOME ??
100
+ (process.platform === "win32"
101
+ ? process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local")
102
+ : join(homedir(), ".local", "state"));
103
+ return join(base, SERVICE_NAME);
104
+ }
105
+
106
+ function configDir(): string {
107
+ return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "opencode");
108
+ }
109
+
110
+ function launchFilePath(): string {
111
+ return join(stateDir(), "launch.json");
112
+ }
113
+
114
+ function pidFilePath(): string {
115
+ return join(stateDir(), "webui.pid");
116
+ }
117
+
118
+ function markerPath(): string {
119
+ return join(stateDir(), "setup.json");
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Launch command + version
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /** Compiled binaries get a virtual ($bunfs) entry URL but a real execPath. */
127
+ export function resolveLaunchCommand(entryUrl: string): LaunchCommand {
128
+ const exec = process.execPath;
129
+ if (entryUrl.includes("bunfs")) return { cmd: [exec], display: exec };
130
+ const script = fileURLToPath(entryUrl);
131
+ const isBun = /(^|[\\/])bun(\.exe)?$/i.test(exec);
132
+ if (isBun) return { cmd: [exec, script], display: `bunx ${SERVICE_NAME}` };
133
+ return { cmd: [exec, script], display: `${exec} ${script}` };
134
+ }
135
+
136
+ function readOwnVersion(): string {
137
+ try {
138
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
139
+ version?: string;
140
+ };
141
+ return pkg.version ?? "0.0.0";
142
+ } catch {
143
+ return "0.0.0";
144
+ }
145
+ }
146
+
147
+ function isDevCheckout(): boolean {
148
+ try {
149
+ return existsSync(fileURLToPath(new URL("../vite.config.ts", import.meta.url)));
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // launch.json — the webui -> plugin handoff
157
+ // ---------------------------------------------------------------------------
158
+
159
+ interface LaunchFile {
160
+ cmd: string[];
161
+ display: string;
162
+ port: number;
163
+ version: string;
164
+ updatedAt: number;
165
+ }
166
+
167
+ /** Meaningful fields only — `updatedAt` must not make a steady state "change". */
168
+ function launchSignature(launch: { cmd: string[]; display: string; port: number; version: string }): string {
169
+ return JSON.stringify([launch.cmd, launch.display, launch.port, launch.version]);
170
+ }
171
+
172
+ export function writeLaunchConfig(launch: LaunchCommand, port: number, version: string): boolean {
173
+ try {
174
+ const current = readLaunchConfig();
175
+ if (current && launchSignature(current) === launchSignature({ ...launch, port, version })) return false;
176
+ } catch {
177
+ /* treat unreadable as changed */
178
+ }
179
+ const next: LaunchFile = {
180
+ cmd: launch.cmd,
181
+ display: launch.display,
182
+ port,
183
+ version,
184
+ updatedAt: Date.now(),
185
+ };
186
+ try {
187
+ mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
188
+ writeFileSync(launchFilePath(), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
189
+ chmodSync(launchFilePath(), 0o600);
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ export function readLaunchConfig(): LaunchFile | null {
197
+ try {
198
+ const parsed: unknown = JSON.parse(readFileSync(launchFilePath(), "utf8"));
199
+ if (parsed && typeof parsed === "object" && Array.isArray((parsed as LaunchFile).cmd)) {
200
+ return parsed as LaunchFile;
201
+ }
202
+ } catch {
203
+ /* absent/corrupt */
204
+ }
205
+ return null;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Wrapper + plugin install
210
+ // ---------------------------------------------------------------------------
211
+
212
+ function pathHasDir(dir: string): boolean {
213
+ const target = dir.replace(/[\\/]+$/, "");
214
+ return (process.env.PATH ?? "")
215
+ .split(delimiter)
216
+ .map((p) => p.replace(/[\\/]+$/, ""))
217
+ .some((p) => p === target);
218
+ }
219
+
220
+ /** First existing bin dir we can own: explicit env, then the usual user dirs. */
221
+ function wrapperDir(): string | null {
222
+ const explicit = process.env.WEBUI_BIN_DIR;
223
+ if (explicit && explicit.length > 0) return explicit;
224
+ const candidates =
225
+ process.platform === "win32"
226
+ ? [join(homedir(), ".bun", "bin"), join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "npm")]
227
+ : [join(homedir(), ".local", "bin"), join(homedir(), ".bun", "bin")];
228
+ for (const candidate of candidates) {
229
+ try {
230
+ if (statSync(candidate).isDirectory()) return candidate;
231
+ } catch {
232
+ /* not a directory (yet) */
233
+ }
234
+ }
235
+ // Create the conventional user bin dir rather than skipping setup.
236
+ const fallback = candidates[0] ?? null;
237
+ if (!fallback) return null;
238
+ try {
239
+ mkdirSync(fallback, { recursive: true });
240
+ return fallback;
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ function wrapperPath(): string | null {
247
+ const dir = wrapperDir();
248
+ if (!dir) return null;
249
+ return join(dir, process.platform === "win32" ? `${SERVICE_NAME}.cmd` : SERVICE_NAME);
250
+ }
251
+
252
+ function posixShellQuote(value: string): string {
253
+ return `'${value.replace(/'/g, `'\\''`)}'`;
254
+ }
255
+
256
+ export function renderWrapper(cmd: string[], windows = process.platform === "win32"): string {
257
+ if (windows) {
258
+ const quoted = cmd.map((arg) => `"${arg.replace(/"/g, '""')}"`).join(" ");
259
+ return [
260
+ "@echo off",
261
+ `rem ${MANAGED_MARK} — do not edit. Remove: ${SERVICE_NAME} uninstall`,
262
+ `${quoted} %*`,
263
+ "",
264
+ ].join("\r\n");
265
+ }
266
+ return [
267
+ "#!/bin/sh",
268
+ `# ${MANAGED_MARK} — regenerated on each boot. Remove: ${SERVICE_NAME} uninstall`,
269
+ `exec ${cmd.map(posixShellQuote).join(" ")} "$@"`,
270
+ "",
271
+ ].join("\n");
272
+ }
273
+
274
+ interface InstallResult {
275
+ path: string | null;
276
+ changed: boolean;
277
+ detail?: string;
278
+ }
279
+
280
+ function installWrapper(launch: LaunchCommand): InstallResult {
281
+ const path = wrapperPath();
282
+ if (!path) return { path: null, changed: false, detail: "no writable bin directory on PATH" };
283
+ const content = renderWrapper(launch.cmd);
284
+ try {
285
+ if (existsSync(path)) {
286
+ const existing = readFileSync(path, "utf8");
287
+ if (!existing.includes(MANAGED_MARK)) {
288
+ return { path, changed: false, detail: `left ${path} untouched (not managed by us)` };
289
+ }
290
+ if (existing === content) return { path, changed: false };
291
+ }
292
+ mkdirSync(dirname(path), { recursive: true });
293
+ writeFileSync(path, content, { mode: 0o755 });
294
+ chmodSync(path, 0o755);
295
+ return { path, changed: true };
296
+ } catch (err) {
297
+ return { path, changed: false, detail: `could not write ${path}: ${errorText(err)}` };
298
+ }
299
+ }
300
+
301
+ function pluginDir(): string {
302
+ return join(configDir(), "plugins", SERVICE_NAME);
303
+ }
304
+
305
+ function installPlugin(version: string): InstallResult {
306
+ const dir = pluginDir();
307
+ const marker = join(dir, PLUGIN_MARKER);
308
+ try {
309
+ if (existsSync(dir) && !existsSync(marker)) {
310
+ return { path: dir, changed: false, detail: `left ${dir} untouched (not managed by us)` };
311
+ }
312
+ const index = join(dir, "index.js");
313
+ const pkg = join(dir, "package.json");
314
+ const markerContent = JSON.stringify({ managed: true, version }, null, 2) + "\n";
315
+ const unchanged =
316
+ existsSync(index) &&
317
+ readFileSync(index, "utf8") === LIFECYCLE_PLUGIN_SOURCE &&
318
+ existsSync(pkg) &&
319
+ readFileSync(pkg, "utf8") === LIFECYCLE_PLUGIN_PACKAGE_JSON &&
320
+ existsSync(marker) &&
321
+ readFileSync(marker, "utf8") === markerContent;
322
+ if (unchanged) return { path: dir, changed: false };
323
+ mkdirSync(dir, { recursive: true });
324
+ writeFileSync(index, LIFECYCLE_PLUGIN_SOURCE);
325
+ writeFileSync(pkg, LIFECYCLE_PLUGIN_PACKAGE_JSON);
326
+ writeFileSync(marker, markerContent);
327
+ return { path: dir, changed: true };
328
+ } catch (err) {
329
+ return { path: dir, changed: false, detail: `could not write ${dir}: ${errorText(err)}` };
330
+ }
331
+ }
332
+
333
+ function errorText(err: unknown): string {
334
+ return err instanceof Error ? err.message : String(err);
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Marker (declined state)
339
+ // ---------------------------------------------------------------------------
340
+
341
+ interface Marker {
342
+ declined?: boolean;
343
+ installedAt?: number;
344
+ version?: string;
345
+ }
346
+
347
+ function readMarker(): Marker {
348
+ try {
349
+ const parsed: unknown = JSON.parse(readFileSync(markerPath(), "utf8"));
350
+ if (parsed && typeof parsed === "object") return parsed as Marker;
351
+ } catch {
352
+ /* absent/corrupt */
353
+ }
354
+ return {};
355
+ }
356
+
357
+ function writeMarker(marker: Marker): void {
358
+ try {
359
+ mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
360
+ writeFileSync(markerPath(), JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
361
+ } catch {
362
+ /* read-only state dir must not break boot */
363
+ }
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // pidfile
368
+ // ---------------------------------------------------------------------------
369
+
370
+ export function writePidFile(port: number): void {
371
+ try {
372
+ mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
373
+ const content = JSON.stringify({ pid: process.pid, port, startedAt: Date.now() }, null, 2) + "\n";
374
+ writeFileSync(pidFilePath(), content, { mode: 0o600 });
375
+ } catch {
376
+ /* best-effort */
377
+ }
378
+ }
379
+
380
+ export function clearPidFile(): void {
381
+ try {
382
+ const raw = readFileSync(pidFilePath(), "utf8");
383
+ const parsed = JSON.parse(raw) as { pid?: number };
384
+ if (parsed.pid !== process.pid) return; // a newer instance owns it
385
+ rmSync(pidFilePath(), { force: true });
386
+ } catch {
387
+ /* absent */
388
+ }
389
+ }
390
+
391
+ export function readPidFile(): { pid: number; port: number | null } | null {
392
+ try {
393
+ const parsed = JSON.parse(readFileSync(pidFilePath(), "utf8")) as { pid?: number; port?: number };
394
+ if (typeof parsed.pid === "number") {
395
+ return { pid: parsed.pid, port: typeof parsed.port === "number" ? parsed.port : null };
396
+ }
397
+ } catch {
398
+ /* absent/corrupt */
399
+ }
400
+ return null;
401
+ }
402
+
403
+ function isAlive(pid: number): boolean {
404
+ try {
405
+ process.kill(pid, 0);
406
+ return true;
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+
412
+ /** Linux: confirm the pid is actually a webui before signaling it. */
413
+ function pidLooksLikeOurs(pid: number): boolean {
414
+ try {
415
+ if (process.platform === "linux") {
416
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
417
+ return cmdline.includes(SERVICE_NAME) || cmdline.includes("server/index");
418
+ }
419
+ } catch {
420
+ return false;
421
+ }
422
+ return true;
423
+ }
424
+
425
+ /** Stop the running server (verified pidfile), if any. */
426
+ export function stopRunning(): boolean {
427
+ const info = readPidFile();
428
+ if (!info || !isAlive(info.pid) || !pidLooksLikeOurs(info.pid)) return false;
429
+ try {
430
+ process.kill(info.pid, "SIGTERM");
431
+ } catch {
432
+ return false;
433
+ }
434
+ const deadline = Date.now() + 3000;
435
+ while (Date.now() < deadline && isAlive(info.pid)) Bun.sleepSync(50);
436
+ return true;
437
+ }
438
+
439
+ /** Spawn the given launch command detached so it outlives this process. */
440
+ export function spawnDetached(launch: LaunchCommand): boolean {
441
+ try {
442
+ const child = Bun.spawn(launch.cmd, {
443
+ stdio: ["ignore", "ignore", "ignore"],
444
+ env: { ...process.env, WEBUI_SPAWNED_BY: "cli" },
445
+ });
446
+ child.unref();
447
+ return true;
448
+ } catch {
449
+ return false;
450
+ }
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Install / uninstall
455
+ // ---------------------------------------------------------------------------
456
+
457
+ function installArtifacts(launch: LaunchCommand, port: number, version: string): {
458
+ changed: boolean;
459
+ wrapper: InstallResult;
460
+ plugin: InstallResult | null;
461
+ problems: string[];
462
+ } {
463
+ const launchChanged = writeLaunchConfig(launch, port, version);
464
+ const wrapper = installWrapper(launch);
465
+ const plugin = process.env.WEBUI_NO_PLUGIN === "1" ? null : installPlugin(version);
466
+ const problems: string[] = [];
467
+ if (wrapper.detail) problems.push(wrapper.detail);
468
+ if (plugin?.detail) problems.push(plugin.detail);
469
+ return {
470
+ changed: launchChanged || wrapper.changed || (plugin?.changed ?? false),
471
+ wrapper,
472
+ plugin,
473
+ problems,
474
+ };
475
+ }
476
+
477
+ function setupMessage(result: {
478
+ wrapper: InstallResult;
479
+ plugin: InstallResult | null;
480
+ first: boolean;
481
+ problems: string[];
482
+ }): string {
483
+ const lines: string[] = [];
484
+ const at = result.wrapper.path ? ` at ${result.wrapper.path}` : "";
485
+ lines.push(
486
+ result.first
487
+ ? `[webui] setup: installed the \`${SERVICE_NAME}\` command${at} and the OpenCode lifecycle plugin.`
488
+ : `[webui] setup: refreshed the \`${SERVICE_NAME}\` command and lifecycle plugin.`,
489
+ );
490
+ lines.push(`[webui] the webui now starts with OpenCode · undo: ${SERVICE_NAME} uninstall`);
491
+ const dir = result.wrapper.path ? dirname(result.wrapper.path) : null;
492
+ if (dir && !pathHasDir(dir)) {
493
+ lines.push(`[webui] note: ${dir} is not on PATH — add it: export PATH="${dir}:$PATH"`);
494
+ }
495
+ for (const problem of result.problems) lines.push(`[webui] ${problem}`);
496
+ return lines.join("\n");
497
+ }
498
+
499
+ /**
500
+ * Ensure the global command + lifecycle plugin exist and match this build.
501
+ * Called after the server binds; a matching install is a no-op. Never throws.
502
+ */
503
+ export function ensureSetup(opts: SetupOptions): SetupResult {
504
+ const marker = readMarker();
505
+ const forced = opts.force === true;
506
+ // WEBUI_SETUP=1 forces setup from a dev checkout (testing / power users);
507
+ // it does NOT override an explicit uninstall — only `opencode-webui setup` does.
508
+ const forceEnv = process.env.WEBUI_SETUP === "1";
509
+
510
+ if (!forced) {
511
+ if (opts.autostart === false || process.env.WEBUI_NO_SETUP === "1" || process.env.WEBUI_SANDBOX === "1") {
512
+ return { status: "disabled", message: null, wrapper: null, plugin: null };
513
+ }
514
+ if (!forceEnv && isDevCheckout()) {
515
+ return { status: "dev", message: null, wrapper: null, plugin: null };
516
+ }
517
+ if (marker.declined) {
518
+ return { status: "declined", message: null, wrapper: null, plugin: null };
519
+ }
520
+ }
521
+
522
+ const launch = resolveLaunchCommand(opts.entryUrl);
523
+ const first = marker.installedAt === undefined;
524
+ const result = installArtifacts(launch, opts.port, opts.version);
525
+ writeMarker({ declined: false, installedAt: marker.installedAt ?? Date.now(), version: opts.version });
526
+
527
+ const status: SetupStatus = result.changed ? (first ? "installed" : "updated") : "present";
528
+ const message =
529
+ opts.quiet || status === "present"
530
+ ? status === "present" && result.problems.length > 0
531
+ ? result.problems.map((p) => `[webui] setup: ${p}`).join("\n")
532
+ : null
533
+ : setupMessage({ wrapper: result.wrapper, plugin: result.plugin, first, problems: result.problems });
534
+
535
+ return {
536
+ status,
537
+ message,
538
+ wrapper: result.wrapper.path,
539
+ plugin: result.plugin?.path ?? null,
540
+ };
541
+ }
542
+
543
+ export interface UninstallResult {
544
+ removed: string[];
545
+ problems: string[];
546
+ }
547
+
548
+ /** Remove the wrapper, plugin, and launch handoff; remember the choice. */
549
+ export function uninstallSetup(): UninstallResult {
550
+ const removed: string[] = [];
551
+ const problems: string[] = [];
552
+ stopRunning();
553
+ const path = wrapperPath();
554
+ if (path && existsSync(path)) {
555
+ try {
556
+ if (readFileSync(path, "utf8").includes(MANAGED_MARK)) {
557
+ rmSync(path, { force: true });
558
+ removed.push(path);
559
+ } else {
560
+ problems.push(`left ${path} untouched (not managed by us)`);
561
+ }
562
+ } catch (err) {
563
+ problems.push(errorText(err));
564
+ }
565
+ }
566
+ const dir = pluginDir();
567
+ if (existsSync(join(dir, PLUGIN_MARKER))) {
568
+ try {
569
+ rmSync(dir, { recursive: true, force: true });
570
+ removed.push(dir);
571
+ } catch (err) {
572
+ problems.push(errorText(err));
573
+ }
574
+ }
575
+ try {
576
+ rmSync(launchFilePath(), { force: true });
577
+ } catch {
578
+ /* absent */
579
+ }
580
+ writeMarker({ declined: true, installedAt: Date.now(), version: readOwnVersion() });
581
+ return { removed, problems };
582
+ }
583
+
584
+ export function setupStatus(): SetupStatusInfo {
585
+ const wrapper = wrapperPath();
586
+ const plugin = pluginDir();
587
+ const launchFile = readLaunchConfig();
588
+ const marker = readMarker();
589
+ const info = readPidFile();
590
+ return {
591
+ wrapper,
592
+ wrapperPresent: wrapper !== null && existsSync(wrapper),
593
+ wrapperOnPath: wrapper !== null ? pathHasDir(dirname(wrapper)) : false,
594
+ plugin,
595
+ pluginPresent: existsSync(join(plugin, PLUGIN_MARKER)),
596
+ launch: launchFile ? { cmd: launchFile.cmd, display: launchFile.display } : null,
597
+ declined: marker.declined === true,
598
+ pid: info && isAlive(info.pid) ? info.pid : null,
599
+ port: info?.port ?? null,
600
+ };
601
+ }
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // CLI — `opencode-webui <setup|update|uninstall|status|stop|restart>`
605
+ // ---------------------------------------------------------------------------
606
+
607
+ const USAGE = `${SERVICE_NAME} [command]
608
+
609
+ (none) start the webui (starting the OpenCode service first if needed)
610
+ update update to the latest published version and restart
611
+ status show the command, lifecycle plugin, launch command, and pid
612
+ config show/edit serve + security settings (host, port, auth, hosts, …)
613
+ stop stop the running webui
614
+ restart restart the running webui
615
+ uninstall remove the global command + lifecycle plugin (remembered; no auto-reinstall)
616
+ sandbox start an isolated second instance (loopback, no password, port 4099)
617
+ --install-skill copy the agent skill and exit
618
+
619
+ Environment:
620
+ WEBUI_NO_SETUP=1 skip first-run setup for one run (CI, one-offs)
621
+ WEBUI_NO_PLUGIN=1 install the command but not the OpenCode lifecycle plugin
622
+ WEBUI_SETUP=1 force setup even in a dev checkout (testing)`;
623
+
624
+ function printStatus(): number {
625
+ const info = setupStatus();
626
+ console.log(`${SERVICE_NAME} setup`);
627
+ console.log(` command: ${info.wrapper ?? "(none)"} ${info.wrapperPresent ? "(present)" : "(missing)"}${info.wrapperOnPath ? "" : " — not on PATH"}`);
628
+ console.log(` plugin: ${info.plugin} ${info.pluginPresent ? "(present)" : "(missing)"}`);
629
+ console.log(` launch: ${info.launch ? info.launch.cmd.join(" ") : "(none)"}`);
630
+ console.log(` running: ${info.pid ? `pid ${info.pid} :${info.port ?? "?"}` : "no"}`);
631
+ if (info.declined) console.log(" declined: yes — `opencode-webui setup` re-enables auto-install");
632
+ return 0;
633
+ }
634
+
635
+ /** Parse the `WEBUI_LAUNCH_JSON=` line a spawned `internal:setup` prints. */
636
+ function parseLaunchJson(stdout: string): { version: string; cmd: string[]; display: string; port: number } | null {
637
+ for (const line of stdout.split(/\r?\n/)) {
638
+ if (!line.startsWith("WEBUI_LAUNCH_JSON=")) continue;
639
+ try {
640
+ const parsed = JSON.parse(line.slice("WEBUI_LAUNCH_JSON=".length)) as {
641
+ version?: string;
642
+ cmd?: string[];
643
+ display?: string;
644
+ port?: number;
645
+ };
646
+ if (typeof parsed.version === "string" && Array.isArray(parsed.cmd) && parsed.cmd.length > 0) {
647
+ return {
648
+ version: parsed.version,
649
+ cmd: parsed.cmd,
650
+ display: parsed.display ?? parsed.cmd.join(" "),
651
+ port: typeof parsed.port === "number" ? parsed.port : 0,
652
+ };
653
+ }
654
+ } catch {
655
+ /* not the line */
656
+ }
657
+ }
658
+ return null;
659
+ }
660
+
661
+ function runUpdate(entryUrl: string): number {
662
+ if (entryUrl.includes("bunfs")) {
663
+ console.log(`${SERVICE_NAME}: this is a compiled binary — download the latest from ${RELEASES_URL}`);
664
+ return 1;
665
+ }
666
+ if (isDevCheckout()) {
667
+ console.log(`${SERVICE_NAME}: dev checkout — update with \`git pull\` (nothing to fetch)`);
668
+ return 1;
669
+ }
670
+ const bun = process.execPath;
671
+ // `@latest` forces a fresh resolve + cache install. The timeout guards the
672
+ // transition case: a published version without `internal:setup` would
673
+ // otherwise start a server and block the probe.
674
+ const probe = Bun.spawnSync([bun, "x", `${SERVICE_NAME}@latest`, "internal:setup"], {
675
+ stdout: "pipe",
676
+ stderr: "pipe",
677
+ timeout: 60_000,
678
+ });
679
+ const parsed = parseLaunchJson(probe.stdout.toString());
680
+ if (!parsed) {
681
+ console.error(`${SERVICE_NAME}: update failed — could not resolve the latest version`);
682
+ const err = probe.stderr.toString().trim();
683
+ if (err) console.error(err.split("\n").slice(0, 5).join("\n"));
684
+ return 1;
685
+ }
686
+ const current = readOwnVersion();
687
+ const launch: LaunchCommand = { cmd: parsed.cmd, display: parsed.display };
688
+ stopRunning();
689
+ spawnDetached(launch);
690
+ if (parsed.version === current) {
691
+ console.log(`${SERVICE_NAME}: already on the latest version (v${current}) — restarted`);
692
+ return 0;
693
+ }
694
+ console.log(`${SERVICE_NAME}: updated v${current} → v${parsed.version} — restarted`);
695
+ return 0;
696
+ }
697
+
698
+ /** The hidden `internal:setup`: install from the *new* version and report it. */
699
+ function runInternalSetup(entryUrl: string): number {
700
+ const version = readOwnVersion();
701
+ const port = Number(process.env.WEBUI_PROXY_PORT ?? 4097);
702
+ const launch = resolveLaunchCommand(entryUrl);
703
+ installArtifacts(launch, port, version);
704
+ writeMarker({ declined: false, installedAt: readMarker().installedAt ?? Date.now(), version });
705
+ console.log(
706
+ `WEBUI_LAUNCH_JSON=${JSON.stringify({ version, cmd: launch.cmd, display: launch.display, port })}`,
707
+ );
708
+ return 0;
709
+ }
710
+
711
+ export async function runSetupCli(action: string | undefined, entryUrl: string, rest: string[] = []): Promise<number> {
712
+ switch (action) {
713
+ case "config":
714
+ return runConfigCli(rest);
715
+ case "internal:setup":
716
+ return runInternalSetup(entryUrl);
717
+ case "update":
718
+ return runUpdate(entryUrl);
719
+ case "status":
720
+ return printStatus();
721
+ case "stop":
722
+ console.log(stopRunning() ? `${SERVICE_NAME}: stopped` : `${SERVICE_NAME}: not running`);
723
+ return 0;
724
+ case "restart": {
725
+ const launch = readLaunchConfig();
726
+ stopRunning();
727
+ const command: LaunchCommand = launch
728
+ ? { cmd: launch.cmd, display: launch.display }
729
+ : resolveLaunchCommand(entryUrl);
730
+ const ok = spawnDetached(command);
731
+ console.log(ok ? `${SERVICE_NAME}: restarted` : `${SERVICE_NAME}: could not restart`);
732
+ return ok ? 0 : 1;
733
+ }
734
+ case "uninstall": {
735
+ const result = uninstallSetup();
736
+ console.log(
737
+ result.removed.length > 0
738
+ ? `${SERVICE_NAME}: removed ${result.removed.join(", ")}`
739
+ : `${SERVICE_NAME}: nothing to remove`,
740
+ );
741
+ for (const problem of result.problems) console.error(` ${problem}`);
742
+ console.log(" the next boot will NOT reinstall (declined); `opencode-webui setup` re-enables");
743
+ return 0;
744
+ }
745
+ case "setup": {
746
+ const version = readOwnVersion();
747
+ const port = Number(process.env.WEBUI_PROXY_PORT ?? 4097);
748
+ const result = ensureSetup({ entryUrl, port, version, force: true, quiet: true });
749
+ if (result.status === "disabled") {
750
+ console.log(`${SERVICE_NAME}: skipped (sandbox mode)`);
751
+ return 0;
752
+ }
753
+ if (result.status === "error") {
754
+ console.error(`${SERVICE_NAME}: setup failed — ${result.message ?? ""}`);
755
+ return 1;
756
+ }
757
+ console.log(`${SERVICE_NAME}: setup ${result.status}${result.wrapper ? ` → ${result.wrapper}` : ""}`);
758
+ return 0;
759
+ }
760
+ case "help":
761
+ case "--help":
762
+ case "-h":
763
+ console.log(USAGE);
764
+ return 0;
765
+ default:
766
+ if (!action) {
767
+ console.log(USAGE);
768
+ return 1;
769
+ }
770
+ console.error(`unknown command: ${action}\n`);
771
+ console.error(USAGE);
772
+ return 1;
773
+ }
774
+ }