oh-my-agent-web 0.2.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,321 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * oh-my-agent-web CLI — plain ESM (no build step).
4
+ *
5
+ * 加载已编译的 dist/server/index.js,解析参数 / 环境变量,启动服务并在需要时打开浏览器。
6
+ * 使用 `--help` 查看全部参数。
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
9
+ import { spawn } from "node:child_process";
10
+ import { dirname, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const SERVER_ENTRY = resolve(__dirname, "..", "dist", "server", "index.js");
15
+ /** Shared with `npm run stop` / `npm run restart`; it owns the "who owns this port" logic. */
16
+ const MANAGE_SCRIPT = resolve(__dirname, "..", "scripts", "manage-server.mjs");
17
+
18
+ const HELP = `oh-my-agent-web — browser UI for the Codex CLI app-server
19
+
20
+ Usage:
21
+ oh-my-agent-web [options]
22
+ oh-my-agent-web ws [options] ["message" ...] Connect to the app-server over WebSocket
23
+ oh-my-agent-web ps [--json] Show running processes and their ports
24
+ oh-my-agent-web stop [--port <n>] Stop the service listening on the UI port
25
+ oh-my-agent-web restart [options] Stop the service, then serve again
26
+
27
+ Commands:
28
+ ws WebSocket client for the codex app-server (run 'oh-my-agent-web ws --help')
29
+ ps Show running oh-my-agent-web / app-server processes and their ports
30
+ stop Gracefully stop the service on the UI port (OMAW_PORT, default 25257)
31
+ restart Stop, then start again in the foreground
32
+
33
+ Note: unknown subcommands are rejected — only [options] starts a server.
34
+
35
+ Options:
36
+ --port <n> Port for the web UI (env OMAW_PORT, default 25257)
37
+ --host <h> Host to bind (env OMAW_HOST, default 127.0.0.1)
38
+ --cwd <path> Working directory for codex (env OMAW_CWD, default cwd)
39
+ --codex-port <n> app-server port (env OMAW_CODEX_PORT, default 25258)
40
+ --codex-bin <name> codex executable (env OMAW_CODEX_BIN, default "codex")
41
+ --no-browser Do not open the browser (env OMAW_OPEN=0)
42
+ --help, -h Show this help
43
+ --version, -v Show version
44
+
45
+ Environment:
46
+ OMAW_ALLOW_ORIGINS Comma separated extra allowed WS origins
47
+ `;
48
+
49
+ /** 支持 `--flag value` 与 `--flag=value` 两种写法。 */
50
+ function parseArgs(argv) {
51
+ const opts = { open: true, positionals: [] };
52
+ const take = (i, inline) => {
53
+ if (inline !== undefined) return { value: inline, next: i };
54
+ const value = argv[i + 1];
55
+ if (value === undefined || value.startsWith("--")) {
56
+ throw new Error(`missing value for ${argv[i]}`);
57
+ }
58
+ return { value, next: i + 1 };
59
+ };
60
+ for (let i = 0; i < argv.length; i += 1) {
61
+ const token = argv[i];
62
+ if (!token.startsWith("-")) {
63
+ // Not a flag and not a value of one (values are consumed below), so it is a
64
+ // stray word. Collect it instead of ignoring it: silently ignoring a mistyped
65
+ // subcommand used to mean `stop` *started* a server.
66
+ opts.positionals.push(token);
67
+ continue;
68
+ }
69
+ const eq = token.indexOf("=");
70
+ const flag = eq === -1 ? token : token.slice(0, eq);
71
+ const inline = eq === -1 ? undefined : token.slice(eq + 1);
72
+ switch (flag) {
73
+ case "--help":
74
+ case "-h":
75
+ opts.help = true;
76
+ break;
77
+ case "--version":
78
+ case "-v":
79
+ opts.version = true;
80
+ break;
81
+ case "--no-browser":
82
+ opts.open = false;
83
+ break;
84
+ case "--port": {
85
+ const r = take(i, inline);
86
+ opts.port = r.value;
87
+ i = r.next;
88
+ break;
89
+ }
90
+ case "--host": {
91
+ const r = take(i, inline);
92
+ opts.host = r.value;
93
+ i = r.next;
94
+ break;
95
+ }
96
+ case "--cwd": {
97
+ const r = take(i, inline);
98
+ opts.cwd = r.value;
99
+ i = r.next;
100
+ break;
101
+ }
102
+ case "--codex-port": {
103
+ const r = take(i, inline);
104
+ opts.codexPort = r.value;
105
+ i = r.next;
106
+ break;
107
+ }
108
+ case "--codex-bin": {
109
+ const r = take(i, inline);
110
+ opts.codexBin = r.value;
111
+ i = r.next;
112
+ break;
113
+ }
114
+ default:
115
+ throw new Error(`unknown option: ${flag}`);
116
+ }
117
+ }
118
+ return opts;
119
+ }
120
+
121
+ function readVersion() {
122
+ try {
123
+ const pkgPath = resolve(__dirname, "..", "package.json");
124
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
125
+ } catch {
126
+ return "0.0.0";
127
+ }
128
+ }
129
+
130
+ /** Open a URL in the OS default browser; best-effort, failures swallowed. */
131
+ function openBrowser(url) {
132
+ const platform = process.platform;
133
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
134
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
135
+ try {
136
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
137
+ child.on("error", (err) => {
138
+ console.warn(`[browser] failed to open ${url}: ${err.message}`);
139
+ });
140
+ child.unref();
141
+ } catch (err) {
142
+ console.warn(`[browser] failed to open ${url}: ${err instanceof Error ? err.message : String(err)}`);
143
+ }
144
+ }
145
+
146
+ function parsePort(value, fallback) {
147
+ if (value === undefined || value === null || value === "") return fallback;
148
+ const n = Number(value);
149
+ if (!Number.isFinite(n) || n < 0 || n > 65535) throw new Error(`invalid port: ${value}`);
150
+ return n;
151
+ }
152
+
153
+ /** Pull `--port <n>` / `--port=<n>` out of a subcommand's argv (used by stop/restart). */
154
+ function portFlag(argv) {
155
+ for (let i = 0; i < argv.length; i += 1) {
156
+ const token = argv[i];
157
+ if (token === "--port" || token === "-p") return argv[i + 1];
158
+ if (token.startsWith("--port=")) return token.slice("--port=".length);
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ /**
164
+ * Run `scripts/manage-server.mjs <cmd>` in the foreground and resolve with its exit code.
165
+ *
166
+ * Delegating instead of reimplementing keeps one definition of “who owns this port”
167
+ * (including the safety check that refuses to kill a foreign process).
168
+ */
169
+ function runManageServer(cmd, argv = []) {
170
+ const port = portFlag(argv);
171
+ const env = port === undefined ? process.env : { ...process.env, OMAW_PORT: String(port) };
172
+ return new Promise((resolvePromise) => {
173
+ let child;
174
+ try {
175
+ child = spawn(process.execPath, [MANAGE_SCRIPT, cmd], { stdio: "inherit", env });
176
+ } catch (err) {
177
+ console.error(`✖ 无法启动 ${MANAGE_SCRIPT}: ${err instanceof Error ? err.message : String(err)}`);
178
+ return resolvePromise(1);
179
+ }
180
+ child.on("error", (err) => {
181
+ console.error(`✖ 无法启动 ${MANAGE_SCRIPT}: ${err.message}`);
182
+ resolvePromise(1);
183
+ });
184
+ child.on("exit", (code) => resolvePromise(code ?? 0));
185
+ });
186
+ }
187
+
188
+ async function main() {
189
+ const argv = process.argv.slice(2);
190
+
191
+ // Best-effort update notice (cached; skipped for help/version/json output).
192
+ // Never let this delay or break normal operation.
193
+ const skipUpdateCheck =
194
+ argv.includes("--json") ||
195
+ argv.includes("-h") ||
196
+ argv.includes("--help") ||
197
+ argv.includes("-v") ||
198
+ argv.includes("--version");
199
+ if (!skipUpdateCheck) {
200
+ try {
201
+ const { notifyUpdate } = await import("./update-check.mjs");
202
+ await notifyUpdate(readVersion());
203
+ } catch {
204
+ /* ignore */
205
+ }
206
+ }
207
+
208
+ // Subcommand: `oh-my-agent-web ws [...]` — WebSocket client for the app-server.
209
+ if (argv[0] === "ws") {
210
+ const { runWsClient } = await import("./oh-my-agent-web-ws.mjs");
211
+ await runWsClient(argv.slice(1));
212
+ return;
213
+ }
214
+
215
+ // Subcommand: `oh-my-agent-web ps [...]` (aliases: status, ports) — running processes/ports.
216
+ if (argv[0] === "ps" || argv[0] === "status" || argv[0] === "ports") {
217
+ const { runPs } = await import("./oh-my-agent-web-ps.mjs");
218
+ await runPs(argv.slice(1));
219
+ return;
220
+ }
221
+
222
+ // Subcommand: `oh-my-agent-web stop [--port <n>]` — stop the service on the UI port.
223
+ if (argv[0] === "stop") {
224
+ process.exitCode = await runManageServer("stop", argv.slice(1));
225
+ return;
226
+ }
227
+
228
+ // Subcommand: `oh-my-agent-web restart [options]` — stop, then fall through and serve.
229
+ if (argv[0] === "restart") {
230
+ const code = await runManageServer("stop", argv.slice(1));
231
+ if (code !== 0) {
232
+ process.exitCode = code;
233
+ return;
234
+ }
235
+ argv.shift();
236
+ }
237
+
238
+ const opts = parseArgs(argv);
239
+
240
+ if (opts.help) {
241
+ process.stdout.write(HELP);
242
+ return;
243
+ }
244
+ if (opts.version) {
245
+ process.stdout.write(`${readVersion()}\n`);
246
+ return;
247
+ }
248
+
249
+ if (opts.positionals.length > 0) {
250
+ console.error(
251
+ `✖ 未知的命令或参数:${opts.positionals.join(" ")}\n` +
252
+ " 子命令:ws / ps / stop / restart。查看全部选项:--help",
253
+ );
254
+ process.exit(1);
255
+ }
256
+
257
+ if (!existsSync(SERVER_ENTRY)) {
258
+ console.error(
259
+ `✖ 找不到编译产物:${SERVER_ENTRY}\n 请先运行 npm run build(或 npm run build:server)再启动。`,
260
+ );
261
+ process.exit(1);
262
+ }
263
+
264
+ const port = parsePort(opts.port ?? process.env.OMAW_PORT, 25257);
265
+ const host = opts.host ?? process.env.OMAW_HOST ?? "127.0.0.1";
266
+ const cwd = opts.cwd ? resolve(opts.cwd) : process.env.OMAW_CWD ? resolve(process.env.OMAW_CWD) : process.cwd();
267
+ const codexPort = parsePort(opts.codexPort ?? process.env.OMAW_CODEX_PORT, 25258);
268
+ const codexBin = opts.codexBin ?? process.env.OMAW_CODEX_BIN ?? "codex";
269
+
270
+ // 用户显式给的 --cwd 往往还不存在;create 出来而不是让 codex 子进程
271
+ // 以一个看不懂的 `spawn codex ENOENT` 失败。
272
+ try {
273
+ mkdirSync(cwd, { recursive: true });
274
+ } catch (err) {
275
+ console.error(`无法创建 cwd ${cwd}: ${err instanceof Error ? err.message : String(err)}`);
276
+ process.exit(1);
277
+ }
278
+
279
+ // 同步进 env,dist 内的 startServer 也会读取(参数优先)。
280
+ process.env.OMAW_PORT = String(port);
281
+ process.env.OMAW_HOST = host;
282
+ process.env.OMAW_CWD = cwd;
283
+ process.env.OMAW_CODEX_PORT = String(codexPort);
284
+ process.env.OMAW_CODEX_BIN = codexBin;
285
+
286
+ const { startServer } = await import(SERVER_ENTRY);
287
+ const handle = await startServer({ port, host, cwd, codexPort, codexBin });
288
+
289
+ const displayUrl = `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${handle.port}`;
290
+ console.log("");
291
+ console.log(" ┌──────────────────────────────────────────────────┐");
292
+ console.log(" │ oh-my-agent-web" + " ".repeat(36) + "│");
293
+ console.log(" └──────────────────────────────────────────────────┘");
294
+ console.log(` ➜ Web UI: ${displayUrl}`);
295
+ console.log(` ➜ Codex: ${handle.supervisor.url} (${handle.supervisor.isExternal() ? "external" : "managed"})`);
296
+ console.log(` ➜ cwd: ${handle.cwd}`);
297
+ console.log("");
298
+
299
+ const noBrowser = !opts.open || process.env.OMAW_OPEN === "0" || !process.stdout.isTTY;
300
+ if (!noBrowser) openBrowser(displayUrl);
301
+ else if (!process.env.OMAW_OPEN && !process.stdout.isTTY) console.log(" (非交互终端,跳过自动打开浏览器;--no-browser 可显式关闭)");
302
+
303
+ let shuttingDown = false;
304
+ const shutdown = async (signal) => {
305
+ if (shuttingDown) return;
306
+ shuttingDown = true;
307
+ console.log(`\n[oh-my-agent-web] received ${signal}, shutting down…`);
308
+ try {
309
+ await handle.close();
310
+ } finally {
311
+ process.exit(0);
312
+ }
313
+ };
314
+ process.on("SIGINT", () => void shutdown("SIGINT"));
315
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
316
+ }
317
+
318
+ main().catch((err) => {
319
+ console.error(`[oh-my-agent-web] ${err instanceof Error ? err.message : String(err)}`);
320
+ process.exit(1);
321
+ });
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * update-check.mjs — 轻量级「有新版本就提示」检查(无第三方依赖)。
4
+ *
5
+ * 设计要点:
6
+ * - 结果缓存到 ~/.cache/oh-my-agent-web/update-check.json,默认 24h 才联网检查一次,
7
+ * 避免每次运行都等网络;已知有新版时,每次运行都会提示(读缓存,0 延迟)。
8
+ * - 任何失败都静默忽略,绝不阻塞 / 影响 CLI 正常功能。
9
+ * - 可通过 OMAW_NO_UPDATE_CHECK=1 关闭;CI 环境自动跳过。
10
+ */
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+
15
+ const PKG = "oh-my-agent-web";
16
+ const REGISTRY = `https://registry.npmjs.org/${PKG.replace("/", "%2f")}/latest`;
17
+ const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
18
+ const DEFAULT_TIMEOUT_MS = 1500;
19
+
20
+ function cacheFile() {
21
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
22
+ return join(base, "oh-my-agent-web", "update-check.json");
23
+ }
24
+
25
+ /** Should the check be skipped entirely? */
26
+ export function isDisabled() {
27
+ const off = (process.env.OMAW_NO_UPDATE_CHECK ?? "").toLowerCase();
28
+ if (off === "1" || off === "true" || off === "yes") return true;
29
+ if (process.env.NO_UPDATE_NOTIFIER) return true;
30
+ if (process.env.CI) return true;
31
+ return false;
32
+ }
33
+
34
+ /** Compare two semver-ish versions. Returns 1 (a>b), -1 (a<b), 0 (equal). */
35
+ export function compareVersions(a, b) {
36
+ const parse = (v) =>
37
+ String(v)
38
+ .split("-")[0]
39
+ .split(".")
40
+ .map((n) => parseInt(n, 10) || 0);
41
+ const A = parse(a);
42
+ const B = parse(b);
43
+ for (let i = 0; i < Math.max(A.length, B.length); i += 1) {
44
+ const d = (A[i] || 0) - (B[i] || 0);
45
+ if (d !== 0) return d > 0 ? 1 : -1;
46
+ }
47
+ const pre = (v) => String(v).includes("-");
48
+ if (pre(a) && !pre(b)) return -1;
49
+ if (!pre(a) && pre(b)) return 1;
50
+ return 0;
51
+ }
52
+
53
+ async function readCache(file) {
54
+ try {
55
+ const data = JSON.parse(await readFile(file, "utf8"));
56
+ return data && typeof data === "object" ? data : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ async function writeCache(file, data) {
63
+ try {
64
+ await mkdir(dirname(file), { recursive: true });
65
+ await writeFile(file, JSON.stringify(data), "utf8");
66
+ } catch {
67
+ /* ignore */
68
+ }
69
+ }
70
+
71
+ async function fetchLatest(timeoutMs) {
72
+ const ctrl = new AbortController();
73
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
74
+ try {
75
+ const res = await fetch(REGISTRY, {
76
+ signal: ctrl.signal,
77
+ headers: { accept: "application/json", "user-agent": `${PKG} update-check` },
78
+ });
79
+ if (!res.ok) return null;
80
+ const json = await res.json();
81
+ return typeof json?.version === "string" ? json.version : null;
82
+ } catch {
83
+ return null;
84
+ } finally {
85
+ clearTimeout(timer);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Check for a newer published version.
91
+ * @returns {Promise<string|null>} the newer version, or null if up to date / unknown.
92
+ */
93
+ export async function checkForUpdate(current, opts = {}) {
94
+ if (isDisabled()) return null;
95
+ const file = opts.cacheFile || cacheFile();
96
+ const interval = Number(process.env.OMAW_UPDATE_CHECK_INTERVAL_MS ?? opts.intervalMs ?? DEFAULT_INTERVAL_MS);
97
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
98
+ const now = Date.now();
99
+
100
+ const cache = await readCache(file);
101
+ let latest = typeof cache?.latest === "string" ? cache.latest : null;
102
+
103
+ const lastCheck = typeof cache?.lastCheck === "number" ? cache.lastCheck : 0;
104
+ if (now - lastCheck >= interval) {
105
+ const fetched = await fetchLatest(timeout);
106
+ if (fetched) latest = fetched;
107
+ await writeCache(file, { lastCheck: now, latest });
108
+ }
109
+
110
+ return latest && compareVersions(latest, current) > 0 ? latest : null;
111
+ }
112
+
113
+ /** Format the update notice (plain, no ANSI). */
114
+ export function formatNotice(current, latest) {
115
+ const lines = [
116
+ "",
117
+ ` Update available: oh-my-agent-web ${current} -> ${latest}`,
118
+ ` npm install -g ${PKG}@latest`,
119
+ ` (disable this check with OMAW_NO_UPDATE_CHECK=1)`,
120
+ "",
121
+ ];
122
+ return lines.join("\n") + "\n";
123
+ }
124
+
125
+ /** Check and print the notice to stderr. Never throws. */
126
+ export async function notifyUpdate(current, opts = {}) {
127
+ try {
128
+ const latest = await checkForUpdate(current, opts);
129
+ if (!latest) return null;
130
+ let text = formatNotice(current, latest);
131
+ if (process.stderr.isTTY) {
132
+ text = text.replace(
133
+ `Update available: oh-my-agent-web ${current} -> ${latest}`,
134
+ `\x1b[33mUpdate available: oh-my-agent-web ${current} \u2192 ${latest}\x1b[0m`,
135
+ );
136
+ }
137
+ process.stderr.write(text);
138
+ return latest;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }