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 +29 -4
- package/bin/codex-webstrap.sh +146 -15
- package/package.json +3 -2
- package/src/assets.mjs +2 -1
- package/src/bridge-shim.js +12 -0
- package/src/message-router.mjs +674 -4
- package/src/server.mjs +37 -0
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
260
|
+
- Ensure the bundled CLI exists in your Codex app install, or set `CODEX_CLI_PATH`.
|
|
236
261
|
|
|
237
262
|
## License
|
|
238
263
|
|
package/bin/codex-webstrap.sh
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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) {
|
package/src/bridge-shim.js
CHANGED
|
@@ -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
|
package/src/message-router.mjs
CHANGED
|
@@ -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
|
-
|
|
1112
|
-
|
|
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
|
|
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
|
}
|