codex-webapp 0.1.0-beta.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.
@@ -0,0 +1,111 @@
1
+ import { createRequire } from "node:module";
2
+ import { resolve } from "node:path";
3
+
4
+ const DEFAULT_TEXT = "What should we build";
5
+ const DEFAULT_MAX_RESPONSE_MS = 5_000;
6
+
7
+ export function parseSmokeArgs(args = []) {
8
+ const screenshot = valueAfter(args, "--screenshot");
9
+ return {
10
+ url: valueAfter(args, "--url") ?? "http://127.0.0.1:8214/",
11
+ text: valueAfter(args, "--text") ?? DEFAULT_TEXT,
12
+ timeoutMs: Number(valueAfter(args, "--timeout-ms") ?? 10_000),
13
+ maxResponseMs: Number(valueAfter(args, "--max-response-ms") ?? DEFAULT_MAX_RESPONSE_MS),
14
+ browser: args.includes("--browser") || Boolean(screenshot),
15
+ screenshot,
16
+ };
17
+ }
18
+
19
+ export async function runSmoke(args = []) {
20
+ const options = parseSmokeArgs(args);
21
+ if (options.browser) {
22
+ await runBrowserSmokeWithOptions(options);
23
+ return;
24
+ }
25
+ await runHttpSmokeWithOptions(options);
26
+ }
27
+
28
+ export async function runHttpSmoke(args = []) {
29
+ await runHttpSmokeWithOptions(parseSmokeArgs(args));
30
+ }
31
+
32
+ export async function runBrowserSmoke(args = []) {
33
+ await runBrowserSmokeWithOptions(parseSmokeArgs(args));
34
+ }
35
+
36
+ async function runHttpSmokeWithOptions(options) {
37
+ const started = performance.now();
38
+ const response = await fetch(options.url, {
39
+ signal: AbortSignal.timeout(options.timeoutMs),
40
+ });
41
+ const elapsedMs = Math.round(performance.now() - started);
42
+ if (!response.ok) {
43
+ throw new Error(`HTTP smoke returned ${response.status} for ${options.url}`);
44
+ }
45
+ const text = await response.text();
46
+ if (!text.includes(options.text)) {
47
+ throw new Error(`HTTP smoke could not find visible text: ${options.text}`);
48
+ }
49
+ if (elapsedMs > options.maxResponseMs) {
50
+ throw new Error(
51
+ `HTTP smoke exceeded response budget: ${elapsedMs}ms > ${options.maxResponseMs}ms`,
52
+ );
53
+ }
54
+ console.log(`HTTP smoke passed: ${options.url}`);
55
+ console.log(`Found text: ${options.text}`);
56
+ console.log(`Response time: ${elapsedMs}ms (budget ${options.maxResponseMs}ms)`);
57
+ console.log("For screenshot evidence, rerun with --browser --screenshot <path>.");
58
+ }
59
+
60
+ async function runBrowserSmokeWithOptions(options) {
61
+ const playwright = await loadPlaywright();
62
+ const browser = await playwright.chromium.launch({ headless: true });
63
+ try {
64
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
65
+ await page.goto(options.url, {
66
+ waitUntil: "networkidle",
67
+ timeout: options.timeoutMs,
68
+ });
69
+ await page.getByText(options.text, { exact: false }).waitFor({
70
+ timeout: options.timeoutMs,
71
+ });
72
+ if (options.screenshot) {
73
+ await page.screenshot({
74
+ path: resolve(options.screenshot),
75
+ fullPage: true,
76
+ });
77
+ }
78
+ console.log(`Browser smoke passed: ${options.url}`);
79
+ console.log(`Visible text: ${options.text}`);
80
+ } finally {
81
+ await browser.close();
82
+ }
83
+ }
84
+
85
+ async function loadPlaywright() {
86
+ try {
87
+ return await import("playwright");
88
+ } catch {
89
+ const requireFromCwd = createRequire(resolve(process.cwd(), "package.json"));
90
+ try {
91
+ return requireFromCwd("playwright");
92
+ } catch {
93
+ throw new Error(
94
+ [
95
+ "Playwright is required for browser smoke checks.",
96
+ "",
97
+ "Install it in this project:",
98
+ " npm install -D playwright",
99
+ " npx playwright install chromium",
100
+ ].join("\n"),
101
+ );
102
+ }
103
+ }
104
+ }
105
+
106
+ function valueAfter(args, flag) {
107
+ const index = args.indexOf(flag);
108
+ if (index === -1) return undefined;
109
+ const value = args[index + 1];
110
+ return value && !value.startsWith("--") ? value : undefined;
111
+ }
@@ -0,0 +1,48 @@
1
+ export const CODEX_WEB_UPSTREAM = "github:0xcaff/codex-web";
2
+ export const CODEX_WEB_COMMIT = "585613f5a3a355af5aefc388ca4e31b07a472cda";
3
+ export const CODEX_WEB_REFERENCE = `${CODEX_WEB_UPSTREAM}#${CODEX_WEB_COMMIT}`;
4
+ export const DEFAULT_WEB_HOST = "127.0.0.1";
5
+ export const DEFAULT_WEB_PORT = 8214;
6
+
7
+ export function parseStartArgs(args = []) {
8
+ return {
9
+ dryRun: args.includes("--dry-run"),
10
+ yes: args.includes("--yes"),
11
+ allowNonLoopback: args.includes("--allow-non-loopback"),
12
+ host: valueAfter(args, "--host") ?? DEFAULT_WEB_HOST,
13
+ port: Number(valueAfter(args, "--port") ?? valueAfter(args, "--ui-port") ?? DEFAULT_WEB_PORT),
14
+ };
15
+ }
16
+
17
+ export function buildWebUrl({ host = DEFAULT_WEB_HOST, port = DEFAULT_WEB_PORT } = {}) {
18
+ return `http://${host}:${port}/`;
19
+ }
20
+
21
+ export function assertSafeHost(host, { allowNonLoopback = false } = {}) {
22
+ if (allowNonLoopback || isLoopbackHost(host)) return;
23
+ throw new Error(
24
+ "Refusing to bind codex-web to a non-loopback host without --allow-non-loopback. Put Tailscale, Cloudflare Access, WireGuard, SSH tunneling, or an equivalent trusted boundary in front before remote access.",
25
+ );
26
+ }
27
+
28
+ export function buildCodexWebNpxArgs({ host = DEFAULT_WEB_HOST, port = DEFAULT_WEB_PORT } = {}) {
29
+ return [
30
+ "--yes",
31
+ CODEX_WEB_REFERENCE,
32
+ "--host",
33
+ host,
34
+ "--port",
35
+ String(port),
36
+ ];
37
+ }
38
+
39
+ export function isLoopbackHost(host) {
40
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
41
+ }
42
+
43
+ function valueAfter(args, flag) {
44
+ const index = args.indexOf(flag);
45
+ if (index === -1) return undefined;
46
+ const value = args[index + 1];
47
+ return value && !value.startsWith("--") ? value : undefined;
48
+ }
@@ -0,0 +1,193 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+
5
+ import {
6
+ compareVersions,
7
+ extractVersion,
8
+ MIN_CODEX_VERSION,
9
+ } from "./version.js";
10
+ import { runSmoke } from "./browserSmoke.js";
11
+ import {
12
+ CODEX_WEB_REFERENCE,
13
+ assertSafeHost,
14
+ buildCodexWebNpxArgs,
15
+ buildWebUrl,
16
+ parseStartArgs,
17
+ } from "./codexWeb.js";
18
+
19
+ const BANNER = "Codex WebApp — unofficial OpenAI Codex companion; not endorsed by OpenAI.";
20
+
21
+ export function main(argv = process.argv) {
22
+ const command = argv[2] ?? "help";
23
+ if (command === "doctor") {
24
+ doctor();
25
+ return;
26
+ }
27
+ if (command === "start") {
28
+ start(argv.slice(3)).catch((error) => {
29
+ fail([
30
+ "Start failed.",
31
+ String(error?.message ?? error),
32
+ ]);
33
+ });
34
+ return;
35
+ }
36
+ if (command === "smoke") {
37
+ runSmoke(argv.slice(3)).catch((error) => {
38
+ fail([
39
+ "Smoke failed.",
40
+ String(error?.message ?? error),
41
+ "",
42
+ "Check that the UI server is running and that the URL is reachable.",
43
+ ]);
44
+ });
45
+ return;
46
+ }
47
+ help();
48
+ }
49
+
50
+ export function doctor() {
51
+ const codex = spawnSync("codex", ["--version"], { encoding: "utf8" });
52
+ const codexPath = resolveCodexPath();
53
+ if (codex.error || codex.status !== 0) {
54
+ fail([
55
+ "Codex CLI was not found.",
56
+ "",
57
+ "Install or update Codex first:",
58
+ " npm install -g @openai/codex",
59
+ "",
60
+ "Then run:",
61
+ " npx codex-webapp doctor",
62
+ ]);
63
+ }
64
+
65
+ const versionText = `${codex.stdout}${codex.stderr}`.trim();
66
+ const version = extractVersion(versionText);
67
+ if (!version || compareVersions(version, MIN_CODEX_VERSION) < 0) {
68
+ fail([
69
+ `Codex CLI ${MIN_CODEX_VERSION}+ is required for remote-control.`,
70
+ `Detected: ${versionText || "unknown"}`,
71
+ "",
72
+ "Update Codex:",
73
+ " npm install -g @openai/codex@latest",
74
+ "",
75
+ "Then verify:",
76
+ " codex remote-control --help",
77
+ ]);
78
+ }
79
+
80
+ const helpResult = spawnSync("codex", ["remote-control", "--help"], {
81
+ encoding: "utf8",
82
+ });
83
+ if (helpResult.status !== 0) {
84
+ fail([
85
+ "`codex remote-control --help` did not run successfully.",
86
+ "",
87
+ "Codex is installed, but this CLI may not include remote-control yet.",
88
+ "Update Codex and try again:",
89
+ " npm install -g @openai/codex@latest",
90
+ ]);
91
+ }
92
+
93
+ ok([
94
+ BANNER,
95
+ `Codex CLI is ready: ${versionText}`,
96
+ `Codex executable: ${codexPath || "unknown"}`,
97
+ "`codex remote-control` is available.",
98
+ `codex-web reference: ${CODEX_WEB_REFERENCE}`,
99
+ "Next:",
100
+ " npx codex-webapp start",
101
+ ]);
102
+ }
103
+
104
+ export async function start(args = []) {
105
+ const options = parseStartArgs(args);
106
+ const check = spawnSync("codex", ["remote-control", "--help"], {
107
+ encoding: "utf8",
108
+ });
109
+ if (check.status !== 0) {
110
+ fail([
111
+ "Cannot start because `codex remote-control` is unavailable.",
112
+ "Run:",
113
+ " npx codex-webapp doctor",
114
+ ]);
115
+ }
116
+
117
+ assertSafeHost(options.host, { allowNonLoopback: options.allowNonLoopback });
118
+ const plannedWebUrl = buildWebUrl({ host: options.host, port: options.port });
119
+ const npxArgs = buildCodexWebNpxArgs({
120
+ host: options.host,
121
+ port: options.port,
122
+ });
123
+
124
+ if (options.dryRun) {
125
+ ok([
126
+ BANNER,
127
+ "Dry run passed.",
128
+ `Would start codex-web: ${plannedWebUrl}`,
129
+ "Would start:",
130
+ ` npx ${npxArgs.join(" ")}`,
131
+ ]);
132
+ return;
133
+ }
134
+
135
+ if (!options.yes) {
136
+ console.log(BANNER);
137
+ console.log(`About to start codex-web: ${plannedWebUrl}`);
138
+ console.log("Default expectation: keep it on localhost or behind Tailscale, Cloudflare Access, or an equivalent trusted boundary.");
139
+ console.log("Anyone who can reach this URL can operate Codex on this host.");
140
+ const rl = createInterface({ input, output });
141
+ const answer = await rl.question("Continue? Type 'yes' to start: ");
142
+ rl.close();
143
+ if (answer.trim().toLowerCase() !== "yes") {
144
+ console.log("Canceled.");
145
+ return;
146
+ }
147
+ }
148
+
149
+ console.log(`Starting codex-web from ${CODEX_WEB_REFERENCE}...`);
150
+ console.log(`Open: ${plannedWebUrl}`);
151
+ console.log("Keep this terminal open. Expose it only through a trusted local, Tailscale, Cloudflare Access, or equivalent boundary.");
152
+ const child = spawn("npx", npxArgs, { stdio: "inherit" });
153
+ child.on("exit", (code) => {
154
+ process.exit(code ?? 0);
155
+ });
156
+ }
157
+
158
+ export function help() {
159
+ console.log(`${BANNER}
160
+
161
+ Commands:
162
+ codex-webapp doctor Check Codex CLI >= ${MIN_CODEX_VERSION}
163
+ codex-webapp start Confirm, then start codex-web
164
+ codex-webapp start --dry-run
165
+ codex-webapp start --yes Start without the confirmation prompt
166
+ codex-webapp start --port 8214
167
+ codex-webapp start --host 127.0.0.1
168
+ codex-webapp start --allow-non-loopback
169
+ codex-webapp smoke --url http://127.0.0.1:8214/
170
+ codex-webapp smoke --browser --screenshot artifacts/codex-webapp.png
171
+
172
+ Quick start:
173
+ npx codex-webapp doctor
174
+ `);
175
+ }
176
+
177
+ function ok(lines) {
178
+ for (const line of lines) console.log(line);
179
+ }
180
+
181
+ function fail(lines) {
182
+ console.error(BANNER);
183
+ for (const line of lines) console.error(line);
184
+ process.exit(1);
185
+ }
186
+
187
+ export function resolveCodexPath() {
188
+ const result = spawnSync("bash", ["-lc", "command -v codex"], {
189
+ encoding: "utf8",
190
+ });
191
+ if (result.status !== 0) return "";
192
+ return result.stdout.trim();
193
+ }
package/src/version.js ADDED
@@ -0,0 +1,27 @@
1
+ export const MIN_CODEX_VERSION = "0.130.0";
2
+
3
+ export function extractVersion(text) {
4
+ const match = String(text ?? "").match(/(\d+)\.(\d+)\.(\d+)/);
5
+ return match ? match[0] : null;
6
+ }
7
+
8
+ export function compareVersions(left, right) {
9
+ const a = numericParts(left);
10
+ const b = numericParts(right);
11
+ for (let i = 0; i < 3; i += 1) {
12
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return 1;
13
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return -1;
14
+ }
15
+ return 0;
16
+ }
17
+
18
+ export function isRemoteControlReady(versionText) {
19
+ const version = extractVersion(versionText);
20
+ return Boolean(version && compareVersions(version, MIN_CODEX_VERSION) >= 0);
21
+ }
22
+
23
+ function numericParts(version) {
24
+ const normalized = extractVersion(version);
25
+ if (!normalized) return [0, 0, 0];
26
+ return normalized.split(".").map((part) => Number(part));
27
+ }