codex-webstrapper 0.1.1 → 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.
package/README.md CHANGED
@@ -75,6 +75,8 @@ This project provides near-parity by emulation/bridging, not by removing those d
75
75
 
76
76
  ```bash
77
77
  codex-webstrap [--port <n>] [--bind <ip>] [--open] [--token-file <path>] [--codex-app <path>]
78
+ codex-webstrapper [--port <n>] [--bind <ip>] [--open] [--token-file <path>] [--codex-app <path>]
79
+ codex-webstrapper open [--port <n>] [--bind <ip>] [--token-file <path>] [--copy]
78
80
  ```
79
81
 
80
82
  ### Environment Overrides
@@ -112,7 +114,12 @@ codex-webstrap [--port <n>] [--bind <ip>] [--open] [--token-file <path>] [--code
112
114
  - macOS
113
115
  - Node.js 20+
114
116
  - Installed Codex app bundle at `/Applications/Codex.app` (or pass `--codex-app`)
115
- - `codex` CLI available in `PATH` (or set `CODEX_CLI_PATH`)
117
+
118
+ By default, webstrapper runs the app-server via the bundled desktop CLI at:
119
+ - `/Applications/Codex.app/Contents/Resources/codex`
120
+
121
+ Optional override:
122
+ - `CODEX_CLI_PATH=/custom/codex`
116
123
 
117
124
  ### Install
118
125
 
@@ -131,7 +138,7 @@ npm install -g codex-webstrapper
131
138
  With global install:
132
139
 
133
140
  ```bash
134
- codex-webstrap --port 8080 --bind 127.0.0.1
141
+ codex-webstrapper --port 8080 --bind 127.0.0.1
135
142
  ```
136
143
 
137
144
  From local checkout:
@@ -143,7 +150,19 @@ From local checkout:
143
150
  Optional auto-open:
144
151
 
145
152
  ```bash
146
- codex-webstrap --open
153
+ codex-webstrapper --open
154
+ ```
155
+
156
+ Generate/open the full auth URL from your persisted token:
157
+
158
+ ```bash
159
+ codex-webstrapper open
160
+ ```
161
+
162
+ Copy the full auth URL (including token) to macOS clipboard:
163
+
164
+ ```bash
165
+ codex-webstrapper open --copy
147
166
  ```
148
167
 
149
168
  ## Authentication Model
@@ -155,6 +174,12 @@ codex-webstrap --open
155
174
  open "http://127.0.0.1:8080/__webstrapper/auth?token=$(cat ~/.codex-webstrap/token)"
156
175
  ```
157
176
 
177
+ Or use the helper command:
178
+
179
+ ```bash
180
+ codex-webstrapper open
181
+ ```
182
+
158
183
  3. Server sets `cw_session` cookie (`HttpOnly`, `SameSite=Lax`, scoped to `/`).
159
184
  4. UI and bridge endpoints require a valid session cookie.
160
185
 
@@ -232,7 +257,7 @@ Codex setup-script compatible command:
232
257
  - Codex app not found
233
258
  - Pass `--codex-app /path/to/Codex.app`.
234
259
  - `codex` CLI spawn failures
235
- - Ensure `codex` is on `PATH` or set `CODEX_CLI_PATH`.
260
+ - Ensure the bundled CLI exists in your Codex app install, or set `CODEX_CLI_PATH`.
236
261
 
237
262
  ## License
238
263
 
@@ -17,40 +17,106 @@ OPEN_FLAG="0"
17
17
  TOKEN_FILE="${CODEX_WEBSTRAP_TOKEN_FILE:-}"
18
18
  CODEX_APP="${CODEX_WEBSTRAP_CODEX_APP:-}"
19
19
  INTERNAL_WS_PORT="${CODEX_WEBSTRAP_INTERNAL_WS_PORT:-38080}"
20
+ PORT_SET="0"
21
+ BIND_SET="0"
22
+ COPY_FLAG="0"
23
+ COMMAND="serve"
24
+
25
+ # Treat non-empty env overrides as explicit selections so open-mode runtime
26
+ # autodetection cannot replace them.
27
+ if [[ -n "${CODEX_WEBSTRAP_PORT:-}" ]]; then
28
+ PORT_SET="1"
29
+ fi
30
+ if [[ -n "${CODEX_WEBSTRAP_BIND:-}" ]]; then
31
+ BIND_SET="1"
32
+ fi
33
+
34
+ DEFAULT_TOKEN_FILE="${HOME}/.codex-webstrap/token"
35
+ if [[ -z "$TOKEN_FILE" ]]; then
36
+ TOKEN_FILE="$DEFAULT_TOKEN_FILE"
37
+ fi
38
+
39
+ print_usage() {
40
+ cat <<USAGE
41
+ Usage:
42
+ $(basename "$0") [--port <n>] [--bind <ip>] [--open] [--token-file <path>] [--codex-app <path>]
43
+ $(basename "$0") open [--port <n>] [--bind <ip>] [--token-file <path>] [--copy]
44
+
45
+ Commands:
46
+ open Build the full auth URL and open it in the browser.
47
+
48
+ Options for open:
49
+ --copy Copy full auth URL to clipboard with pbcopy instead of launching browser.
50
+
51
+ Env overrides:
52
+ CODEX_WEBSTRAP_PORT
53
+ CODEX_WEBSTRAP_BIND
54
+ CODEX_WEBSTRAP_TOKEN_FILE
55
+ CODEX_WEBSTRAP_CODEX_APP
56
+ CODEX_WEBSTRAP_INTERNAL_WS_PORT
57
+ USAGE
58
+ }
59
+
60
+ require_value() {
61
+ local flag="$1"
62
+ local value="${2:-}"
63
+ if [[ -z "$value" ]]; then
64
+ echo "Missing value for ${flag}" >&2
65
+ exit 1
66
+ fi
67
+ }
68
+
69
+ if [[ $# -gt 0 ]]; then
70
+ case "$1" in
71
+ open)
72
+ COMMAND="open"
73
+ shift
74
+ ;;
75
+ --help|-h|help)
76
+ print_usage
77
+ exit 0
78
+ ;;
79
+ esac
80
+ fi
20
81
 
21
82
  while [[ $# -gt 0 ]]; do
22
83
  case "$1" in
84
+ open)
85
+ COMMAND="open"
86
+ shift
87
+ ;;
23
88
  --port)
89
+ require_value "$1" "${2:-}"
24
90
  PORT="$2"
91
+ PORT_SET="1"
25
92
  shift 2
26
93
  ;;
27
94
  --bind)
95
+ require_value "$1" "${2:-}"
28
96
  BIND="$2"
97
+ BIND_SET="1"
29
98
  shift 2
30
99
  ;;
31
- --open)
32
- OPEN_FLAG="1"
33
- shift
34
- ;;
35
100
  --token-file)
101
+ require_value "$1" "${2:-}"
36
102
  TOKEN_FILE="$2"
37
103
  shift 2
38
104
  ;;
105
+ --copy)
106
+ COPY_FLAG="1"
107
+ shift
108
+ ;;
109
+ --open)
110
+ OPEN_FLAG="1"
111
+ shift
112
+ ;;
39
113
  --codex-app)
114
+ require_value "$1" "${2:-}"
40
115
  CODEX_APP="$2"
41
116
  shift 2
42
117
  ;;
43
- --help|-h)
44
- cat <<USAGE
45
- Usage: $(basename "$0") [--port <n>] [--bind <ip>] [--open] [--token-file <path>] [--codex-app <path>]
46
-
47
- Env overrides:
48
- CODEX_WEBSTRAP_PORT
49
- CODEX_WEBSTRAP_BIND
50
- CODEX_WEBSTRAP_TOKEN_FILE
51
- CODEX_WEBSTRAP_CODEX_APP
52
- CODEX_WEBSTRAP_INTERNAL_WS_PORT
53
- USAGE
118
+ --help|-h|help)
119
+ print_usage
54
120
  exit 0
55
121
  ;;
56
122
  *)
@@ -60,6 +126,71 @@ USAGE
60
126
  esac
61
127
  done
62
128
 
129
+ if [[ "$COMMAND" != "open" && "$COPY_FLAG" == "1" ]]; then
130
+ echo "--copy can only be used with the 'open' command" >&2
131
+ exit 1
132
+ fi
133
+
134
+ if [[ "$COMMAND" == "open" ]]; then
135
+ if [[ ! -f "$TOKEN_FILE" ]]; then
136
+ echo "Token file not found: $TOKEN_FILE" >&2
137
+ exit 1
138
+ fi
139
+
140
+ TOKEN="$(tr -d '\r\n' < "$TOKEN_FILE")"
141
+ if [[ -z "$TOKEN" ]]; then
142
+ echo "Token file is empty: $TOKEN_FILE" >&2
143
+ exit 1
144
+ fi
145
+
146
+ if [[ "$PORT_SET" == "0" || "$BIND_SET" == "0" ]]; then
147
+ RUNTIME_FILE="${TOKEN_FILE}.runtime"
148
+ if [[ -f "$RUNTIME_FILE" ]]; then
149
+ RUNTIME_VALUES="$(node -e 'const fs = require("node:fs"); const filePath = process.argv[2]; try { const data = JSON.parse(fs.readFileSync(filePath, "utf8")); const bind = data.bind || ""; const port = data.port || ""; if (!bind || !port) { process.exit(1); } process.stdout.write(`${bind}\n${port}\n`); } catch { process.exit(1); }' "$TOKEN_FILE" "$RUNTIME_FILE" 2>/dev/null || true)"
150
+ if [[ -n "$RUNTIME_VALUES" ]]; then
151
+ RUNTIME_BIND="$(printf '%s' "$RUNTIME_VALUES" | sed -n '1p')"
152
+ RUNTIME_PORT="$(printf '%s' "$RUNTIME_VALUES" | sed -n '2p')"
153
+ if [[ -n "$RUNTIME_BIND" && -n "$RUNTIME_PORT" ]]; then
154
+ if command -v curl >/dev/null 2>&1; then
155
+ if curl -fsS --max-time 1 --connect-timeout 1 "http://${RUNTIME_BIND}:${RUNTIME_PORT}/__webstrapper/healthz" >/dev/null 2>&1; then
156
+ if [[ "$BIND_SET" == "0" ]]; then
157
+ BIND="$RUNTIME_BIND"
158
+ fi
159
+ if [[ "$PORT_SET" == "0" ]]; then
160
+ PORT="$RUNTIME_PORT"
161
+ fi
162
+ fi
163
+ else
164
+ if [[ "$BIND_SET" == "0" ]]; then
165
+ BIND="$RUNTIME_BIND"
166
+ fi
167
+ if [[ "$PORT_SET" == "0" ]]; then
168
+ PORT="$RUNTIME_PORT"
169
+ fi
170
+ fi
171
+ fi
172
+ fi
173
+ fi
174
+ fi
175
+
176
+ ENCODED_TOKEN="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1] || ""))' "$TOKEN")"
177
+ AUTH_URL="http://${BIND}:${PORT}/__webstrapper/auth?token=${ENCODED_TOKEN}"
178
+
179
+ if [[ "$COPY_FLAG" == "1" ]]; then
180
+ if ! command -v pbcopy >/dev/null 2>&1; then
181
+ echo "pbcopy not found in PATH" >&2
182
+ exit 1
183
+ fi
184
+ printf '%s' "$AUTH_URL" | pbcopy >/dev/null 2>&1
185
+ printf 'Copied auth URL to clipboard.\n'
186
+ else
187
+ open "$AUTH_URL"
188
+ printf 'Opened auth URL in browser.\n'
189
+ printf '%s\n' "$AUTH_URL"
190
+ fi
191
+ exit 0
192
+ fi
193
+
63
194
  export CODEX_WEBSTRAP_PORT="$PORT"
64
195
  export CODEX_WEBSTRAP_BIND="$BIND"
65
196
  export CODEX_WEBSTRAP_TOKEN_FILE="$TOKEN_FILE"
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "codex-webstrapper",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Web wrapper for Codex desktop assets with bridge + token auth",
6
6
  "license": "MIT",
7
7
  "bin": {
8
- "codex-webstrap": "./bin/codex-webstrap.sh"
8
+ "codex-webstrap": "./bin/codex-webstrap.sh",
9
+ "codex-webstrapper": "./bin/codex-webstrap.sh"
9
10
  },
10
11
  "files": [
11
12
  "bin/",
package/src/assets.mjs CHANGED
@@ -37,7 +37,8 @@ export function resolveCodexAppPaths(explicitCodexAppPath) {
37
37
  const resourcesPath = path.join(appPath, "Contents", "Resources");
38
38
  const asarPath = path.join(resourcesPath, "app.asar");
39
39
  const infoPlistPath = path.join(appPath, "Contents", "Info.plist");
40
- return { appPath, resourcesPath, asarPath, infoPlistPath };
40
+ const codexCliPath = path.join(resourcesPath, "codex");
41
+ return { appPath, resourcesPath, asarPath, infoPlistPath, codexCliPath };
41
42
  }
42
43
 
43
44
  export async function ensureCodexAppExists(paths) {
@@ -599,6 +599,18 @@
599
599
  }
600
600
 
601
601
  function sendMessageFromView(payload) {
602
+ if (payload?.type === "open-in-browser") {
603
+ const target = payload?.url || payload?.href || null;
604
+ if (target) {
605
+ const opened = window.open(target, "_blank", "noopener,noreferrer");
606
+ if (!opened) {
607
+ // Fallback when popup is blocked.
608
+ window.location.href = target;
609
+ }
610
+ }
611
+ return Promise.resolve();
612
+ }
613
+
602
614
  sendEnvelope({
603
615
  type: "view-message",
604
616
  payload
@@ -880,6 +880,7 @@ export class MessageRouter {
880
880
  }
881
881
 
882
882
  let payload = {};
883
+ let status = 200;
883
884
  switch (endpoint) {
884
885
  case "get-global-state": {
885
886
  const key = params?.key;
@@ -1018,7 +1019,7 @@ export class MessageRouter {
1018
1019
  };
1019
1020
  break;
1020
1021
  case "local-environments":
1021
- payload = [];
1022
+ payload = await this._resolveLocalEnvironments(params);
1022
1023
  break;
1023
1024
  case "has-custom-cli-executable":
1024
1025
  payload = { hasCustomCliExecutable: false };
@@ -1050,6 +1051,21 @@ export class MessageRouter {
1050
1051
  };
1051
1052
  break;
1052
1053
  }
1054
+ case "git-create-branch": {
1055
+ payload = await this._handleGitCreateBranch(params);
1056
+ status = payload.ok ? 200 : 500;
1057
+ break;
1058
+ }
1059
+ case "git-checkout-branch": {
1060
+ payload = await this._handleGitCheckoutBranch(params);
1061
+ status = payload.ok ? 200 : 500;
1062
+ break;
1063
+ }
1064
+ case "git-push": {
1065
+ payload = await this._handleGitPush(params);
1066
+ status = payload.ok ? 200 : 500;
1067
+ break;
1068
+ }
1053
1069
  case "git-merge-base": {
1054
1070
  const gitRoot = typeof params?.gitRoot === "string" && params.gitRoot.length > 0
1055
1071
  ? params.gitRoot
@@ -1102,20 +1118,35 @@ export class MessageRouter {
1102
1118
  payload = await this._resolveGhPrStatus({ cwd, headBranch });
1103
1119
  break;
1104
1120
  }
1121
+ case "generate-pull-request-message":
1122
+ payload = await this._handleGeneratePullRequestMessage(params);
1123
+ break;
1124
+ case "gh-pr-create":
1125
+ payload = await this._handleGhPrCreate(params);
1126
+ break;
1105
1127
  case "paths-exist": {
1106
1128
  const paths = Array.isArray(params?.paths) ? params.paths.filter((p) => typeof p === "string") : [];
1107
1129
  payload = { existingPaths: paths };
1108
1130
  break;
1109
1131
  }
1110
1132
  default:
1111
- this.logger.warn("Unhandled vscode fetch endpoint", { endpoint });
1112
- payload = {};
1133
+ if (endpoint.startsWith("git-")) {
1134
+ this.logger.warn("Unhandled git vscode fetch endpoint", { endpoint });
1135
+ payload = {
1136
+ ok: false,
1137
+ error: `unhandled git endpoint: ${endpoint}`
1138
+ };
1139
+ status = 500;
1140
+ } else {
1141
+ this.logger.warn("Unhandled vscode fetch endpoint", { endpoint });
1142
+ payload = {};
1143
+ }
1113
1144
  }
1114
1145
 
1115
1146
  this._sendFetchJson(ws, {
1116
1147
  requestId,
1117
1148
  url: message.url,
1118
- status: 200,
1149
+ status,
1119
1150
  payload
1120
1151
  });
1121
1152
  return true;
@@ -1312,6 +1343,350 @@ export class MessageRouter {
1312
1343
  };
1313
1344
  }
1314
1345
 
1346
+ async _handleGeneratePullRequestMessage(params) {
1347
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1348
+ ? params.cwd
1349
+ : process.cwd();
1350
+ const prompt = typeof params?.prompt === "string" ? params.prompt : "";
1351
+ const generated = await this._generatePullRequestMessageWithCodex({ cwd, prompt });
1352
+ const fallback = generated || await this._generateFallbackPullRequestMessage({ cwd, prompt });
1353
+ const title = this._normalizePullRequestTitle(fallback.title);
1354
+ const body = this._normalizePullRequestBody(fallback.body);
1355
+
1356
+ return {
1357
+ status: "success",
1358
+ title,
1359
+ body,
1360
+ // Older clients read `bodyInstructions`; keep it in sync with the generated body.
1361
+ bodyInstructions: body
1362
+ };
1363
+ }
1364
+
1365
+ _normalizePullRequestTitle(title) {
1366
+ if (typeof title !== "string") {
1367
+ return "Update project files";
1368
+ }
1369
+
1370
+ const normalized = title.replace(/\s+/g, " ").trim();
1371
+ if (normalized.length === 0) {
1372
+ return "Update project files";
1373
+ }
1374
+ if (normalized.length <= 120) {
1375
+ return normalized;
1376
+ }
1377
+ return `${normalized.slice(0, 117).trimEnd()}...`;
1378
+ }
1379
+
1380
+ _normalizePullRequestBody(body) {
1381
+ if (typeof body !== "string") {
1382
+ return "## Summary\n- Update project files.\n\n## Testing\n- Not run (context not provided).";
1383
+ }
1384
+
1385
+ const normalized = body.trim();
1386
+ if (normalized.length > 0) {
1387
+ return normalized;
1388
+ }
1389
+ return "## Summary\n- Update project files.\n\n## Testing\n- Not run (context not provided).";
1390
+ }
1391
+
1392
+ _buildPullRequestCodexPrompt(prompt) {
1393
+ const context = typeof prompt === "string" && prompt.trim().length > 0
1394
+ ? prompt.trim().slice(0, 20_000)
1395
+ : "No additional context was provided.";
1396
+
1397
+ return [
1398
+ "Generate a GitHub pull request title and body.",
1399
+ "Return JSON that matches the provided schema.",
1400
+ "Requirements:",
1401
+ "- title: concise imperative sentence, maximum 72 characters.",
1402
+ "- body: markdown with sections exactly '## Summary' and '## Testing'.",
1403
+ "- Summary should include 2 to 6 concrete bullet points.",
1404
+ "- Testing should include bullet points. If unknown, say '- Not run (context not provided).'.",
1405
+ "- Do not wrap output in code fences.",
1406
+ "- Use only the provided context.",
1407
+ "",
1408
+ "Context:",
1409
+ context
1410
+ ].join("\n");
1411
+ }
1412
+
1413
+ async _generatePullRequestMessageWithCodex({ cwd, prompt }) {
1414
+ let tempDir = null;
1415
+ try {
1416
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-webstrap-prmsg-"));
1417
+ const schemaPath = path.join(tempDir, "schema.json");
1418
+ const outputPath = path.join(tempDir, "output.json");
1419
+ const schema = {
1420
+ $schema: "http://json-schema.org/draft-07/schema#",
1421
+ type: "object",
1422
+ required: ["title", "body"],
1423
+ additionalProperties: false,
1424
+ properties: {
1425
+ title: { type: "string" },
1426
+ body: { type: "string" }
1427
+ }
1428
+ };
1429
+ await fs.writeFile(schemaPath, JSON.stringify(schema), "utf8");
1430
+
1431
+ const result = await this._runCommand(
1432
+ "codex",
1433
+ [
1434
+ "exec",
1435
+ "--ephemeral",
1436
+ "--sandbox",
1437
+ "read-only",
1438
+ "--output-schema",
1439
+ schemaPath,
1440
+ "--output-last-message",
1441
+ outputPath,
1442
+ this._buildPullRequestCodexPrompt(prompt)
1443
+ ],
1444
+ {
1445
+ timeoutMs: 120_000,
1446
+ allowNonZero: true,
1447
+ cwd
1448
+ }
1449
+ );
1450
+
1451
+ if (!result.ok) {
1452
+ this.logger.warn("PR message generation via codex failed", {
1453
+ cwd,
1454
+ error: result.error || result.stderr || "unknown error"
1455
+ });
1456
+ return null;
1457
+ }
1458
+
1459
+ const rawOutput = await fs.readFile(outputPath, "utf8");
1460
+ const parsed = safeJsonParse(rawOutput);
1461
+ if (!parsed || typeof parsed !== "object") {
1462
+ return null;
1463
+ }
1464
+
1465
+ const title = this._normalizePullRequestTitle(parsed.title);
1466
+ const body = this._normalizePullRequestBody(parsed.body);
1467
+ return { title, body };
1468
+ } catch (error) {
1469
+ this.logger.warn("PR message generation via codex errored", {
1470
+ cwd,
1471
+ error: toErrorMessage(error)
1472
+ });
1473
+ return null;
1474
+ } finally {
1475
+ if (tempDir) {
1476
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ async _resolvePullRequestBaseRef(cwd) {
1482
+ const originHead = await this._runCommand(
1483
+ "git",
1484
+ ["-C", cwd, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
1485
+ { timeoutMs: 5_000, allowNonZero: true, cwd }
1486
+ );
1487
+ if (originHead.ok && originHead.stdout) {
1488
+ return originHead.stdout;
1489
+ }
1490
+
1491
+ const candidates = ["origin/main", "origin/master", "main", "master"];
1492
+ for (const candidate of candidates) {
1493
+ const exists = await this._runCommand(
1494
+ "git",
1495
+ ["-C", cwd, "rev-parse", "--verify", "--quiet", candidate],
1496
+ { timeoutMs: 5_000, allowNonZero: true, cwd }
1497
+ );
1498
+ if (exists.code === 0) {
1499
+ return candidate;
1500
+ }
1501
+ }
1502
+
1503
+ return null;
1504
+ }
1505
+
1506
+ async _generateFallbackPullRequestMessage({ cwd, prompt }) {
1507
+ const baseRef = await this._resolvePullRequestBaseRef(cwd);
1508
+ const logArgs = baseRef
1509
+ ? ["-C", cwd, "log", "--no-merges", "--pretty=format:%s", `${baseRef}..HEAD`, "-n", "6"]
1510
+ : ["-C", cwd, "log", "--no-merges", "--pretty=format:%s", "-n", "6"];
1511
+ const logResult = await this._runCommand("git", logArgs, {
1512
+ timeoutMs: 8_000,
1513
+ allowNonZero: true,
1514
+ cwd
1515
+ });
1516
+ const commitSubjects = (logResult.stdout || "")
1517
+ .split("\n")
1518
+ .map((line) => line.trim())
1519
+ .filter(Boolean);
1520
+
1521
+ const range = baseRef ? `${baseRef}...HEAD` : "HEAD~1..HEAD";
1522
+ const filesResult = await this._runCommand(
1523
+ "git",
1524
+ ["-C", cwd, "diff", "--name-only", "--diff-filter=ACMR", range],
1525
+ { timeoutMs: 8_000, allowNonZero: true, cwd }
1526
+ );
1527
+ const changedFiles = (filesResult.stdout || "")
1528
+ .split("\n")
1529
+ .map((line) => line.trim())
1530
+ .filter(Boolean);
1531
+
1532
+ const statsResult = await this._runCommand(
1533
+ "git",
1534
+ ["-C", cwd, "diff", "--numstat", range],
1535
+ { timeoutMs: 8_000, allowNonZero: true, cwd }
1536
+ );
1537
+ let additions = 0;
1538
+ let deletions = 0;
1539
+ for (const line of (statsResult.stdout || "").split("\n")) {
1540
+ const [addedRaw, deletedRaw] = line.split("\t");
1541
+ const added = Number.parseInt(addedRaw, 10);
1542
+ const deleted = Number.parseInt(deletedRaw, 10);
1543
+ additions += Number.isFinite(added) ? added : 0;
1544
+ deletions += Number.isFinite(deleted) ? deleted : 0;
1545
+ }
1546
+
1547
+ const branch = await this._resolveGitCurrentBranch(cwd);
1548
+ const title = this._normalizePullRequestTitle(
1549
+ commitSubjects[0] || (branch ? `Update ${branch}` : "Update project files")
1550
+ );
1551
+
1552
+ const summaryBullets = [];
1553
+ for (const subject of commitSubjects.slice(0, 3)) {
1554
+ summaryBullets.push(subject);
1555
+ }
1556
+ if (summaryBullets.length === 0) {
1557
+ summaryBullets.push("Update project files.");
1558
+ }
1559
+ if (changedFiles.length > 0) {
1560
+ summaryBullets.push(`Modify ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}.`);
1561
+ }
1562
+ if (additions > 0 || deletions > 0) {
1563
+ summaryBullets.push(`Diff summary: +${additions} / -${deletions} lines.`);
1564
+ }
1565
+ if (baseRef) {
1566
+ summaryBullets.push(`Base branch: ${baseRef}.`);
1567
+ }
1568
+
1569
+ const bodyLines = ["## Summary"];
1570
+ for (const bullet of summaryBullets.slice(0, 6)) {
1571
+ bodyLines.push(`- ${bullet}`);
1572
+ }
1573
+
1574
+ bodyLines.push("", "## Testing", "- Not run (context not provided).");
1575
+
1576
+ if (changedFiles.length > 0) {
1577
+ bodyLines.push("", "## Files Changed");
1578
+ for (const file of changedFiles.slice(0, 12)) {
1579
+ bodyLines.push(`- \`${file}\``);
1580
+ }
1581
+ if (changedFiles.length > 12) {
1582
+ bodyLines.push(`- \`...and ${changedFiles.length - 12} more\``);
1583
+ }
1584
+ } else if (typeof prompt === "string" && prompt.trim().length > 0) {
1585
+ bodyLines.push("", "## Notes", "- Generated from available thread context.");
1586
+ }
1587
+
1588
+ return {
1589
+ title,
1590
+ body: this._normalizePullRequestBody(bodyLines.join("\n"))
1591
+ };
1592
+ }
1593
+
1594
+ _extractGithubPrUrl(text) {
1595
+ if (typeof text !== "string" || text.length === 0) {
1596
+ return null;
1597
+ }
1598
+ const match = text.match(/https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+/);
1599
+ return match ? match[0] : null;
1600
+ }
1601
+
1602
+ async _handleGhPrCreate(params) {
1603
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1604
+ ? params.cwd
1605
+ : process.cwd();
1606
+ const headBranch = typeof params?.headBranch === "string" ? params.headBranch.trim() : "";
1607
+ const baseBranch = typeof params?.baseBranch === "string" ? params.baseBranch.trim() : "";
1608
+ const bodyInstructions = typeof params?.bodyInstructions === "string" ? params.bodyInstructions : "";
1609
+ const titleOverride = typeof params?.titleOverride === "string" ? params.titleOverride.trim() : "";
1610
+ const bodyOverride = typeof params?.bodyOverride === "string" ? params.bodyOverride.trim() : "";
1611
+
1612
+ if (!headBranch || !baseBranch) {
1613
+ return {
1614
+ status: "error",
1615
+ error: "headBranch and baseBranch are required",
1616
+ url: null,
1617
+ number: null
1618
+ };
1619
+ }
1620
+
1621
+ const ghStatus = await this._resolveGhCliStatus();
1622
+ if (!ghStatus.isInstalled || !ghStatus.isAuthenticated) {
1623
+ return {
1624
+ status: "error",
1625
+ error: "gh cli unavailable or unauthenticated",
1626
+ url: null,
1627
+ number: null
1628
+ };
1629
+ }
1630
+
1631
+ const args = [
1632
+ "pr",
1633
+ "create",
1634
+ "--head",
1635
+ headBranch,
1636
+ "--base",
1637
+ baseBranch
1638
+ ];
1639
+ const shouldFill = titleOverride.length === 0 || bodyOverride.length === 0;
1640
+ if (shouldFill) {
1641
+ args.push("--fill");
1642
+ }
1643
+ if (titleOverride.length > 0) {
1644
+ args.push("--title", titleOverride);
1645
+ }
1646
+ if (bodyOverride.length > 0) {
1647
+ args.push("--body", bodyOverride);
1648
+ } else if (bodyInstructions.trim().length > 0) {
1649
+ args.push("--body", bodyInstructions);
1650
+ }
1651
+
1652
+ const result = await this._runCommand("gh", args, {
1653
+ timeoutMs: 30_000,
1654
+ allowNonZero: true,
1655
+ cwd
1656
+ });
1657
+
1658
+ if (result.ok) {
1659
+ const url = this._extractGithubPrUrl(result.stdout) || this._extractGithubPrUrl(result.stderr);
1660
+ const numberMatch = url ? url.match(/\/pull\/(\d+)/) : null;
1661
+ const number = numberMatch ? Number.parseInt(numberMatch[1], 10) : null;
1662
+ return {
1663
+ status: "success",
1664
+ url: url || null,
1665
+ number: Number.isFinite(number) ? number : null
1666
+ };
1667
+ }
1668
+
1669
+ const combinedOutput = `${result.stdout || ""}\n${result.stderr || ""}`;
1670
+ const existingUrl = this._extractGithubPrUrl(combinedOutput);
1671
+ const alreadyExists = /already exists|a pull request for branch/i.test(combinedOutput);
1672
+ if (alreadyExists && existingUrl) {
1673
+ const numberMatch = existingUrl.match(/\/pull\/(\d+)/);
1674
+ const number = numberMatch ? Number.parseInt(numberMatch[1], 10) : null;
1675
+ return {
1676
+ status: "success",
1677
+ url: existingUrl,
1678
+ number: Number.isFinite(number) ? number : null
1679
+ };
1680
+ }
1681
+
1682
+ return {
1683
+ status: "error",
1684
+ error: result.error || result.stderr || "failed to create pull request",
1685
+ url: null,
1686
+ number: null
1687
+ };
1688
+ }
1689
+
1315
1690
  async _resolveGitMergeBase({ gitRoot, baseBranch }) {
1316
1691
  if (!baseBranch) {
1317
1692
  return {
@@ -1333,6 +1708,179 @@ export class MessageRouter {
1333
1708
  };
1334
1709
  }
1335
1710
 
1711
+ async _resolveGitCurrentBranch(cwd) {
1712
+ const result = await this._runCommand("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
1713
+ timeoutMs: 5_000,
1714
+ allowNonZero: true,
1715
+ cwd
1716
+ });
1717
+ if (!result.ok || !result.stdout || result.stdout === "HEAD") {
1718
+ return null;
1719
+ }
1720
+ return result.stdout;
1721
+ }
1722
+
1723
+ async _handleGitCreateBranch(params) {
1724
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1725
+ ? params.cwd
1726
+ : process.cwd();
1727
+ const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
1728
+ ? params.branch.trim()
1729
+ : null;
1730
+
1731
+ if (!branch) {
1732
+ return {
1733
+ ok: false,
1734
+ code: null,
1735
+ error: "branch is required",
1736
+ stdout: "",
1737
+ stderr: ""
1738
+ };
1739
+ }
1740
+
1741
+ const existingResult = await this._runCommand("git", ["-C", cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
1742
+ cwd,
1743
+ timeoutMs: 10_000,
1744
+ allowNonZero: true
1745
+ });
1746
+ if (existingResult.code === 0) {
1747
+ return {
1748
+ ok: true,
1749
+ code: 0,
1750
+ branch,
1751
+ created: false,
1752
+ alreadyExists: true,
1753
+ stdout: existingResult.stdout || "",
1754
+ stderr: existingResult.stderr || ""
1755
+ };
1756
+ }
1757
+
1758
+ const createResult = await this._runCommand("git", ["-C", cwd, "branch", branch], {
1759
+ cwd,
1760
+ timeoutMs: 10_000,
1761
+ allowNonZero: true
1762
+ });
1763
+ if (createResult.ok) {
1764
+ return {
1765
+ ok: true,
1766
+ code: createResult.code,
1767
+ branch,
1768
+ created: true,
1769
+ alreadyExists: false,
1770
+ stdout: createResult.stdout || "",
1771
+ stderr: createResult.stderr || ""
1772
+ };
1773
+ }
1774
+
1775
+ return {
1776
+ ok: false,
1777
+ code: createResult.code,
1778
+ error: createResult.error || createResult.stderr || "git branch failed",
1779
+ stdout: createResult.stdout || "",
1780
+ stderr: createResult.stderr || ""
1781
+ };
1782
+ }
1783
+
1784
+ async _handleGitCheckoutBranch(params) {
1785
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1786
+ ? params.cwd
1787
+ : process.cwd();
1788
+ const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
1789
+ ? params.branch.trim()
1790
+ : null;
1791
+
1792
+ if (!branch) {
1793
+ return {
1794
+ ok: false,
1795
+ code: null,
1796
+ error: "branch is required",
1797
+ stdout: "",
1798
+ stderr: ""
1799
+ };
1800
+ }
1801
+
1802
+ const checkoutResult = await this._runCommand("git", ["-C", cwd, "checkout", branch], {
1803
+ cwd,
1804
+ timeoutMs: 20_000,
1805
+ allowNonZero: true
1806
+ });
1807
+ if (!checkoutResult.ok) {
1808
+ return {
1809
+ ok: false,
1810
+ code: checkoutResult.code,
1811
+ error: checkoutResult.error || checkoutResult.stderr || "git checkout failed",
1812
+ stdout: checkoutResult.stdout || "",
1813
+ stderr: checkoutResult.stderr || ""
1814
+ };
1815
+ }
1816
+
1817
+ const currentBranch = await this._resolveGitCurrentBranch(cwd);
1818
+ return {
1819
+ ok: true,
1820
+ code: checkoutResult.code,
1821
+ branch: currentBranch || branch,
1822
+ stdout: checkoutResult.stdout || "",
1823
+ stderr: checkoutResult.stderr || ""
1824
+ };
1825
+ }
1826
+
1827
+ async _handleGitPush(params) {
1828
+ const cwd = typeof params?.cwd === "string" && params.cwd.length > 0
1829
+ ? params.cwd
1830
+ : process.cwd();
1831
+ const explicitRemote = typeof params?.remote === "string" && params.remote.trim().length > 0
1832
+ ? params.remote.trim()
1833
+ : null;
1834
+ const branch = typeof params?.branch === "string" && params.branch.trim().length > 0
1835
+ ? params.branch.trim()
1836
+ : null;
1837
+ const refspec = typeof params?.refspec === "string" && params.refspec.trim().length > 0
1838
+ ? params.refspec.trim()
1839
+ : null;
1840
+ const remote = explicitRemote || (
1841
+ params?.setUpstream === true && (branch || refspec) ? "origin" : null
1842
+ );
1843
+
1844
+ const args = ["-C", cwd, "push"];
1845
+ if (params?.force === true || params?.forceWithLease === true) {
1846
+ args.push("--force-with-lease");
1847
+ }
1848
+ if (params?.setUpstream === true) {
1849
+ args.push("--set-upstream");
1850
+ }
1851
+ if (remote) {
1852
+ args.push(remote);
1853
+ }
1854
+ if (refspec) {
1855
+ args.push(refspec);
1856
+ } else if (branch) {
1857
+ args.push(branch);
1858
+ }
1859
+
1860
+ const result = await this._runCommand("git", args, {
1861
+ cwd,
1862
+ timeoutMs: 120_000,
1863
+ allowNonZero: true
1864
+ });
1865
+
1866
+ if (result.ok) {
1867
+ return {
1868
+ ok: true,
1869
+ code: result.code,
1870
+ stdout: result.stdout || "",
1871
+ stderr: result.stderr || ""
1872
+ };
1873
+ }
1874
+
1875
+ return {
1876
+ ok: false,
1877
+ code: result.code,
1878
+ error: result.error || result.stderr || "git push failed",
1879
+ stdout: result.stdout || "",
1880
+ stderr: result.stderr || ""
1881
+ };
1882
+ }
1883
+
1336
1884
  async _runCommand(command, args, { timeoutMs = 5_000, allowNonZero = false, cwd = process.cwd() } = {}) {
1337
1885
  return new Promise((resolve) => {
1338
1886
  const child = spawn(command, args, {
@@ -1471,6 +2019,128 @@ export class MessageRouter {
1471
2019
  return trimmed.replace(/\/+$/, "");
1472
2020
  }
1473
2021
 
2022
+ async _resolveLocalEnvironments(params) {
2023
+ const workspaceRoot = this._resolveLocalEnvironmentWorkspaceRoot(params?.workspaceRoot);
2024
+ if (!workspaceRoot) {
2025
+ return { environments: [] };
2026
+ }
2027
+
2028
+ const configDir = path.join(workspaceRoot, ".codex", "environments");
2029
+ let entries;
2030
+ try {
2031
+ entries = await fs.readdir(configDir, { withFileTypes: true });
2032
+ } catch {
2033
+ return { environments: [] };
2034
+ }
2035
+
2036
+ const configFiles = entries
2037
+ .filter((entry) => entry.isFile() && /^environment(?:-\d+)?\.toml$/i.test(entry.name))
2038
+ .map((entry) => entry.name)
2039
+ .sort((left, right) => {
2040
+ const leftIsDefault = left.toLowerCase() === "environment.toml";
2041
+ const rightIsDefault = right.toLowerCase() === "environment.toml";
2042
+ if (leftIsDefault && !rightIsDefault) {
2043
+ return -1;
2044
+ }
2045
+ if (!leftIsDefault && rightIsDefault) {
2046
+ return 1;
2047
+ }
2048
+ return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
2049
+ });
2050
+
2051
+ if (configFiles.length === 0) {
2052
+ return { environments: [] };
2053
+ }
2054
+
2055
+ const environments = await Promise.all(configFiles.map(async (fileName) => {
2056
+ const configPath = path.join(configDir, fileName);
2057
+ try {
2058
+ const raw = await fs.readFile(configPath, "utf8");
2059
+ const environment = this._parseLocalEnvironmentConfig(raw, configPath);
2060
+ return {
2061
+ type: "success",
2062
+ configPath,
2063
+ environment
2064
+ };
2065
+ } catch (error) {
2066
+ return {
2067
+ type: "error",
2068
+ configPath,
2069
+ error: {
2070
+ message: toErrorMessage(error)
2071
+ }
2072
+ };
2073
+ }
2074
+ }));
2075
+
2076
+ return { environments };
2077
+ }
2078
+
2079
+ _resolveLocalEnvironmentWorkspaceRoot(root) {
2080
+ const normalized = this._normalizeWorkspaceRoot(root);
2081
+ if (normalized) {
2082
+ return path.resolve(normalized);
2083
+ }
2084
+
2085
+ const activeRoot = this._normalizeWorkspaceRoot(this.activeWorkspaceRoots?.[0]);
2086
+ if (activeRoot) {
2087
+ return path.resolve(activeRoot);
2088
+ }
2089
+
2090
+ return this.defaultWorkspaceRoot ? path.resolve(this.defaultWorkspaceRoot) : null;
2091
+ }
2092
+
2093
+ _parseLocalEnvironmentConfig(raw, configPath) {
2094
+ const name = this._parseTomlString(raw, "name") || path.basename(configPath, ".toml");
2095
+ const versionRaw = this._parseTomlNumber(raw, "version");
2096
+ const setupScript = this._parseTomlStringInSection(raw, "setup", "script") || "";
2097
+
2098
+ return {
2099
+ version: Number.isInteger(versionRaw) ? versionRaw : 1,
2100
+ name,
2101
+ setup: {
2102
+ script: setupScript
2103
+ },
2104
+ actions: []
2105
+ };
2106
+ }
2107
+
2108
+ _parseTomlString(raw, key) {
2109
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2110
+ const pattern = new RegExp(`^\\s*${escapedKey}\\s*=\\s*(?:"([^"]*)"|'([^']*)')\\s*$`, "m");
2111
+ const match = raw.match(pattern);
2112
+ if (!match) {
2113
+ return null;
2114
+ }
2115
+ return match[1] ?? match[2] ?? null;
2116
+ }
2117
+
2118
+ _parseTomlNumber(raw, key) {
2119
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2120
+ const pattern = new RegExp(`^\\s*${escapedKey}\\s*=\\s*(-?\\d+)\\s*$`, "m");
2121
+ const match = raw.match(pattern);
2122
+ if (!match) {
2123
+ return null;
2124
+ }
2125
+ const value = Number.parseInt(match[1], 10);
2126
+ return Number.isNaN(value) ? null : value;
2127
+ }
2128
+
2129
+ _parseTomlStringInSection(raw, sectionName, key) {
2130
+ const escapedSection = sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2131
+ const sectionPattern = new RegExp(`^\\s*\\[${escapedSection}\\]\\s*$`, "m");
2132
+ const sectionMatch = sectionPattern.exec(raw);
2133
+ if (!sectionMatch) {
2134
+ return null;
2135
+ }
2136
+
2137
+ const sectionStart = sectionMatch.index + sectionMatch[0].length;
2138
+ const rest = raw.slice(sectionStart);
2139
+ const nextSectionMatch = rest.match(/^\s*\[[^\]]+\]\s*$/m);
2140
+ const sectionBody = nextSectionMatch ? rest.slice(0, nextSectionMatch.index) : rest;
2141
+ return this._parseTomlString(sectionBody, key);
2142
+ }
2143
+
1474
2144
  _loadPersistedGlobalState() {
1475
2145
  if (!this.globalStatePath) {
1476
2146
  return;
package/src/server.mjs CHANGED
@@ -114,6 +114,7 @@ async function main() {
114
114
  const config = parseConfig();
115
115
 
116
116
  const tokenResult = await ensurePersistentToken(config.tokenFile);
117
+ const runtimeMetadataPath = `${tokenResult.tokenFilePath}.runtime`;
117
118
  const sessionStore = new SessionStore({ ttlMs: 1000 * 60 * 60 * 12 });
118
119
  const auth = createAuthController({ token: tokenResult.token, sessionStore });
119
120
 
@@ -148,8 +149,20 @@ async function main() {
148
149
  });
149
150
  const patchedIndexHtml = await buildPatchedIndexHtml(assetBundle.indexPath);
150
151
 
152
+ let codexCliPath = process.env.CODEX_CLI_PATH || codexPaths.codexCliPath;
153
+ if (!process.env.CODEX_CLI_PATH) {
154
+ const bundledCliExists = await fs.access(codexPaths.codexCliPath).then(() => true).catch(() => false);
155
+ if (!bundledCliExists) {
156
+ codexCliPath = "codex";
157
+ logger.warn("Bundled Codex CLI not found, falling back to PATH", {
158
+ bundledCliPath: codexPaths.codexCliPath
159
+ });
160
+ }
161
+ }
162
+
151
163
  const appServer = new AppServerManager({
152
164
  internalPort: config.internalWsPort,
165
+ codexCliPath,
153
166
  logger: createLogger("app-server")
154
167
  });
155
168
 
@@ -287,6 +300,25 @@ async function main() {
287
300
  server.listen(config.port, config.bind, resolve);
288
301
  });
289
302
 
303
+ try {
304
+ await fs.writeFile(
305
+ runtimeMetadataPath,
306
+ JSON.stringify({
307
+ bind: config.bind,
308
+ port: config.port,
309
+ tokenFile: tokenResult.tokenFilePath,
310
+ pid: process.pid,
311
+ startedAt: Date.now()
312
+ }) + "\n",
313
+ { mode: 0o600 }
314
+ );
315
+ } catch (error) {
316
+ logger.warn("Failed to write runtime metadata file", {
317
+ path: runtimeMetadataPath,
318
+ error: toErrorMessage(error)
319
+ });
320
+ }
321
+
290
322
  const authHint = `http://${config.bind}:${config.port}/__webstrapper/auth?token=<redacted>`;
291
323
  const loginCommand = `open \"http://${config.bind}:${config.port}/__webstrapper/auth?token=$(cat ${tokenResult.tokenFilePath})\"`;
292
324
 
@@ -344,6 +376,11 @@ async function main() {
344
376
  await new Promise((resolve) => {
345
377
  server.close(() => resolve());
346
378
  });
379
+ try {
380
+ await fs.unlink(runtimeMetadataPath);
381
+ } catch {
382
+ // ignore
383
+ }
347
384
 
348
385
  process.exit(0);
349
386
  }