@tarunspandit/codexflow 0.29.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/AGENTS.example.md +18 -0
- package/CHANGELOG.md +311 -0
- package/CHATGPT_PROMPT.md +12 -0
- package/CODEX_PROMPT.md +12 -0
- package/CONTRIBUTING.md +51 -0
- package/DOMAIN_SETUP.md +241 -0
- package/FAQ.md +327 -0
- package/FAQ_ZH.md +279 -0
- package/LICENSE +21 -0
- package/NOTICE +9 -0
- package/PUBLIC_LAUNCH_CHECKLIST.md +111 -0
- package/README.md +287 -0
- package/README_ZH.md +461 -0
- package/SECURITY.md +145 -0
- package/config.example.env +31 -0
- package/dist/analysis/cache.js +27 -0
- package/dist/analysis/cache.js.map +1 -0
- package/dist/analysis/classify.js +102 -0
- package/dist/analysis/classify.js.map +1 -0
- package/dist/analysis/extract.js +139 -0
- package/dist/analysis/extract.js.map +1 -0
- package/dist/analysis/graph.js +22 -0
- package/dist/analysis/graph.js.map +1 -0
- package/dist/analysis/impact.js +167 -0
- package/dist/analysis/impact.js.map +1 -0
- package/dist/analysis/index.js +215 -0
- package/dist/analysis/index.js.map +1 -0
- package/dist/analysis/inventory.js +51 -0
- package/dist/analysis/inventory.js.map +1 -0
- package/dist/analysis/providers.js +24 -0
- package/dist/analysis/providers.js.map +1 -0
- package/dist/analysis/rank.js +27 -0
- package/dist/analysis/rank.js.map +1 -0
- package/dist/analysis/types.js +8 -0
- package/dist/analysis/types.js.map +1 -0
- package/dist/bashOps.js +233 -0
- package/dist/bashOps.js.map +1 -0
- package/dist/capabilitiesOps.js +365 -0
- package/dist/capabilitiesOps.js.map +1 -0
- package/dist/codexSessions.js +379 -0
- package/dist/codexSessions.js.map +1 -0
- package/dist/config.js +289 -0
- package/dist/config.js.map +1 -0
- package/dist/fsOps.js +286 -0
- package/dist/fsOps.js.map +1 -0
- package/dist/gitOps.js +79 -0
- package/dist/gitOps.js.map +1 -0
- package/dist/guard.js +198 -0
- package/dist/guard.js.map +1 -0
- package/dist/http.js +1671 -0
- package/dist/http.js.map +1 -0
- package/dist/proContext.js +274 -0
- package/dist/proContext.js.map +1 -0
- package/dist/profileStore.js +89 -0
- package/dist/profileStore.js.map +1 -0
- package/dist/projectCatalog.js +134 -0
- package/dist/projectCatalog.js.map +1 -0
- package/dist/redact.js +73 -0
- package/dist/redact.js.map +1 -0
- package/dist/searchOps.js +186 -0
- package/dist/searchOps.js.map +1 -0
- package/dist/server.js +2502 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.js +36 -0
- package/dist/stdio.js.map +1 -0
- package/dist/toolCardWidget.js +1155 -0
- package/dist/toolCardWidget.js.map +1 -0
- package/dist/workspaceOps.js +229 -0
- package/dist/workspaceOps.js.map +1 -0
- package/docs/.nojekyll +1 -0
- package/docs/favicon.svg +5 -0
- package/docs/index.html +638 -0
- package/docs/og.png +0 -0
- package/docs/script.js +80 -0
- package/docs/star.svg +11 -0
- package/docs/styles.css +1229 -0
- package/docs/zh.html +436 -0
- package/package.json +94 -0
- package/scripts/analysis-cli-smoke.mjs +81 -0
- package/scripts/analysis-smoke.mjs +179 -0
- package/scripts/clean.mjs +6 -0
- package/scripts/cli-smoke.mjs +168 -0
- package/scripts/codexflow.mjs +4375 -0
- package/scripts/doctor-smoke.mjs +90 -0
- package/scripts/execute-handoff-smoke.mjs +1110 -0
- package/scripts/http-smoke.mjs +812 -0
- package/scripts/pro-apply.mjs +141 -0
- package/scripts/pro-bundle.mjs +121 -0
- package/scripts/pro-smoke.mjs +95 -0
- package/scripts/settings-smoke.mjs +756 -0
- package/scripts/smoke.mjs +1194 -0
- package/scripts/stress.mjs +835 -0
|
@@ -0,0 +1,4375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { createInterface } from 'node:readline/promises';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const UNTRACKED_FILE_HASH_BYTES = 64 * 1024;
|
|
14
|
+
const UNTRACKED_SYMLINK_TARGET_BYTES = 512;
|
|
15
|
+
|
|
16
|
+
function packageVersion() {
|
|
17
|
+
return JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).version;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isLoopbackHost(host) {
|
|
21
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function usage() {
|
|
25
|
+
console.log(`CodexFlow easy launcher
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
npm install -g @tarunspandit/codexflow
|
|
29
|
+
codexflow
|
|
30
|
+
codexflow --root /path/to/repo
|
|
31
|
+
codexflow settings
|
|
32
|
+
codexflow status --root /path/to/repo [--json]
|
|
33
|
+
codexflow doctor
|
|
34
|
+
codexflow connection-test --root /path/to/repo
|
|
35
|
+
codexflow inspect --root /path/to/repo [--json]
|
|
36
|
+
codexflow review --root /path/to/repo [--staged] [--path src/file.ts] [--json]
|
|
37
|
+
codexflow execute-handoff --agent opencode --model provider/model
|
|
38
|
+
codexflow watch-handoff --agent opencode --model provider/model
|
|
39
|
+
codexflow loop-handoff --agent opencode --model provider/model --review-command "node ./reviewer.js --status {{status_file}} --diff {{diff_file}} --plan-file {{plan_file}}"
|
|
40
|
+
codexflow --root /path/to/repo
|
|
41
|
+
codexflow ngrok --hostname your-domain.ngrok-free.dev
|
|
42
|
+
codexflow tailscale --hostname your-device.your-tailnet.ts.net
|
|
43
|
+
codexflow stable --hostname codexflow.example.com --tunnel-name codexflow
|
|
44
|
+
codexflow pro-bundle --root /path/to/repo --copy
|
|
45
|
+
codexflow pro-apply --root /path/to/repo --file plan.md
|
|
46
|
+
codexflow install-cloudflared
|
|
47
|
+
npm run connect -- --root /path/to/repo
|
|
48
|
+
node scripts/codexflow.mjs --root /path/to/repo --tunnel cloudflare
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--root <dir> Override the default project. Normally discovered from local Codex metadata.
|
|
52
|
+
--from-root <dir> Copy saved settings from another workspace with settings use.
|
|
53
|
+
--allow-root <dir> Additional project root. Normally discovered automatically from local Codex metadata.
|
|
54
|
+
--allow-home Allow opening any workspace under your home directory.
|
|
55
|
+
--mode <agent|handoff|pro>
|
|
56
|
+
Default: agent.
|
|
57
|
+
agent = ChatGPT can read, write/edit/apply_patch files, search, and run safe bash.
|
|
58
|
+
handoff = ChatGPT writes .ai-bridge plans for a local implementation agent.
|
|
59
|
+
pro = export context for models that cannot call MCP tools.
|
|
60
|
+
--agent Shortcut for --mode agent.
|
|
61
|
+
--handoff Shortcut for --mode handoff.
|
|
62
|
+
--pro-planning Shortcut for --mode pro.
|
|
63
|
+
--host <host> Local bind host. Default: 127.0.0.1.
|
|
64
|
+
--port <port> Local port. Default: 8787.
|
|
65
|
+
--bash <off|safe|full> Bash mode. Default: safe.
|
|
66
|
+
--no-bash Shortcut for --bash off.
|
|
67
|
+
--bash-transcript <compact|full>
|
|
68
|
+
Chat transcript for bash results. Default: compact.
|
|
69
|
+
full prints raw stdout/stderr in chat.
|
|
70
|
+
--full-bash-transcript Shortcut for --bash-transcript full.
|
|
71
|
+
--bash-session <id> Local bash session label exposed to ChatGPT.
|
|
72
|
+
--require-bash-session Require bash calls to include matching session_id.
|
|
73
|
+
--codex-sessions <off|metadata|read>
|
|
74
|
+
Opt in to read local ~/.codex session history.
|
|
75
|
+
metadata lists ids/titles/cwd; read allows bounded transcript reads.
|
|
76
|
+
--codex-dir <dir> Codex config/session directory. Default: ~/.codex.
|
|
77
|
+
--write <off|handoff|workspace>
|
|
78
|
+
Write mode. Default: workspace in agent mode, handoff otherwise.
|
|
79
|
+
handoff = no generic write/edit/apply_patch tools; handoff tools write bounded .ai-bridge files.
|
|
80
|
+
--tool-mode <minimal|standard|full>
|
|
81
|
+
Tool surface exposed to ChatGPT. Default: standard.
|
|
82
|
+
minimal = config/self-test plus open/read/write/edit/apply_patch/bash/show_changes.
|
|
83
|
+
full = expose every compatibility and advanced tool.
|
|
84
|
+
--widget-domain <origin> Dedicated HTTPS origin for ChatGPT widget iframes.
|
|
85
|
+
Required for app submission. Default: https://tarunspandit.github.io.
|
|
86
|
+
--tool-cards <on|off> Opt in to ChatGPT widget metadata on tool descriptors. Default: off.
|
|
87
|
+
--tunnel <none|cloudflare|cloudflare-named|ngrok|tailscale>
|
|
88
|
+
Expose local MCP. Default: cloudflare.
|
|
89
|
+
cloudflare = quick tunnel with a new URL each restart.
|
|
90
|
+
cloudflare-named = stable hostname using a named tunnel.
|
|
91
|
+
ngrok = stable ngrok dev-domain endpoint using --hostname/--url.
|
|
92
|
+
tailscale = Tailscale Funnel using --hostname/--url.
|
|
93
|
+
--stable Shortcut for --tunnel cloudflare-named.
|
|
94
|
+
--hostname <host> Stable public hostname for cloudflare-named, ngrok, or tailscale.
|
|
95
|
+
--url <url> Alias for --hostname in stable URL modes.
|
|
96
|
+
--tunnel-name <name> Existing Cloudflare named tunnel to run.
|
|
97
|
+
--cloudflare-token <token> Cloudflare Tunnel token for this launch only; not saved by settings set.
|
|
98
|
+
--cloudflare-token-file <path>
|
|
99
|
+
File containing a Cloudflare Tunnel token.
|
|
100
|
+
--cloudflare-config <path> cloudflared YAML config for a named tunnel.
|
|
101
|
+
--token <token> Bearer token for HTTP MCP. Auto-generated for tunnels.
|
|
102
|
+
--cloudflared <path> cloudflared executable. Default: PATH, then ~/.codexflow/bin.
|
|
103
|
+
--ngrok <path> ngrok executable. Default: PATH.
|
|
104
|
+
--ngrok-config <path> Optional ngrok config file path.
|
|
105
|
+
--tailscale <path> tailscale executable. Default: PATH.
|
|
106
|
+
--no-profile Do not load a saved ~/.codexflow workspace profile.
|
|
107
|
+
--yes Confirm settings delete/reset without prompting.
|
|
108
|
+
--install-cloudflared Install/reinstall cloudflared into ~/.codexflow/bin.
|
|
109
|
+
--no-install-cloudflared Do not auto-install cloudflared when missing.
|
|
110
|
+
--copy-url Copy the ChatGPT Server URL to clipboard. Default for public HTTPS URLs.
|
|
111
|
+
--no-copy-url Do not copy the Server URL.
|
|
112
|
+
--non-interactive Keep the server running without terminal key controls; stop with SIGINT/SIGTERM.
|
|
113
|
+
--no-control-panel Alias for --non-interactive.
|
|
114
|
+
--open-chatgpt Open ChatGPT connector settings after the URL is ready.
|
|
115
|
+
--no-auth Disable bearer-token auth. Only allowed with --tunnel none.
|
|
116
|
+
--log-requests Print redacted HTTP request and tool-call logs from the local MCP server.
|
|
117
|
+
connection-test Start a read-only connector with request logging and no bash or tool cards.
|
|
118
|
+
--print-env Print the environment used to launch the server.
|
|
119
|
+
--version, -v Print the CodexFlow version.
|
|
120
|
+
--help Show this message.
|
|
121
|
+
|
|
122
|
+
Execute handoff options:
|
|
123
|
+
codexflow execute-handoff --agent opencode --model provider/model
|
|
124
|
+
codexflow execute-handoff --agent pi --model provider/model
|
|
125
|
+
codexflow execute-handoff --agent custom --command "my-agent --task-file {{plan_file}}"
|
|
126
|
+
--agent <opencode|pi|codex|custom>
|
|
127
|
+
Local implementation agent adapter.
|
|
128
|
+
--model <provider/model> Optional model name passed to the adapter.
|
|
129
|
+
--command <template> Custom command template. Supports {{model}}, {{plan_file}}, {{plan_text}}, {{root}}.
|
|
130
|
+
--dry-run Print the command that would run without executing it.
|
|
131
|
+
--timeout-ms <ms> Execution timeout. Default: 600000.
|
|
132
|
+
--max-output-bytes <n> Max stdout/stderr excerpt bytes per stream. Default: 120000.
|
|
133
|
+
--context-dir <dir> Handoff directory. Default: .ai-bridge.
|
|
134
|
+
--yes Run without interactive confirmation.
|
|
135
|
+
|
|
136
|
+
Watch handoff options:
|
|
137
|
+
codexflow watch-handoff --agent opencode --model provider/model
|
|
138
|
+
codexflow watch-handoff --agent pi --model provider/model
|
|
139
|
+
codexflow watch-handoff --agent custom --command "my-agent --task-file {{plan_file}}"
|
|
140
|
+
--once Exit after checking/running one new plan.
|
|
141
|
+
--poll-interval-ms <ms> Poll interval. Default: 2000.
|
|
142
|
+
--debounce-ms <ms> Wait for plan file stability. Default: 500.
|
|
143
|
+
--state-file <path> Watch state file. Default: .ai-bridge/watch-handoff-state.json.
|
|
144
|
+
--yes Start automatic local execution without startup confirmation.
|
|
145
|
+
|
|
146
|
+
Loop handoff options:
|
|
147
|
+
codexflow loop-handoff --agent opencode --model provider/model --review-command "reviewer --status {{status_file}} --diff {{diff_file}} --plan-file {{plan_file}}"
|
|
148
|
+
--review-command <template>
|
|
149
|
+
Local reviewer/orchestrator command. It should print CODEXFLOW_REVIEW=PASS or CODEXFLOW_REVIEW=FAIL.
|
|
150
|
+
On FAIL it must update .ai-bridge/current-plan.md before the next iteration.
|
|
151
|
+
--max-iters <n> Maximum execute/review iterations. Default: 3.
|
|
152
|
+
--run-tests <template> Optional local verification command before review.
|
|
153
|
+
--allow-implicit-review-verdict
|
|
154
|
+
Infer PASS/FAIL from reviewer exit code and plan changes when no CODEXFLOW_REVIEW line is printed.
|
|
155
|
+
--allow-review-pass-on-failure
|
|
156
|
+
Let explicit reviewer PASS override a failed executor or failed test command.
|
|
157
|
+
--require-clean-git-start Refuse to start unless git status is clean.
|
|
158
|
+
--stop-if-no-files-changed
|
|
159
|
+
Stop if an executor iteration produces no git diff.
|
|
160
|
+
--stop-if-same-diff Stop if an executor iteration repeats the previous diff.
|
|
161
|
+
--require-human-confirmation
|
|
162
|
+
Ask before running a reviewer-generated follow-up plan.
|
|
163
|
+
--dry-run Print executor/reviewer/test commands without executing them.
|
|
164
|
+
--yes Start the local loop without startup confirmation.
|
|
165
|
+
|
|
166
|
+
Default agent mode:
|
|
167
|
+
codexflow
|
|
168
|
+
codexflow --root /path/to/repo
|
|
169
|
+
|
|
170
|
+
Workspace settings:
|
|
171
|
+
codexflow settings
|
|
172
|
+
codexflow settings show
|
|
173
|
+
codexflow settings list
|
|
174
|
+
codexflow settings set --tunnel ngrok --hostname your-domain.ngrok-free.dev
|
|
175
|
+
codexflow settings use
|
|
176
|
+
codexflow settings delete --yes
|
|
177
|
+
|
|
178
|
+
Runtime status:
|
|
179
|
+
codexflow status
|
|
180
|
+
codexflow status --json
|
|
181
|
+
|
|
182
|
+
Preflight diagnostics:
|
|
183
|
+
codexflow doctor
|
|
184
|
+
|
|
185
|
+
Ngrok stable URL mode:
|
|
186
|
+
codexflow ngrok --root /path/to/repo --hostname your-domain.ngrok-free.dev
|
|
187
|
+
|
|
188
|
+
Tailscale Funnel mode:
|
|
189
|
+
codexflow tailscale --root /path/to/repo --hostname your-device.your-tailnet.ts.net
|
|
190
|
+
|
|
191
|
+
Planning-only handoff mode:
|
|
192
|
+
codexflow --root /path/to/repo --mode handoff
|
|
193
|
+
|
|
194
|
+
Execute a local handoff after ChatGPT writes .ai-bridge/current-plan.md:
|
|
195
|
+
codexflow execute-handoff --agent opencode --model provider/model
|
|
196
|
+
codexflow execute-handoff --agent pi --model provider/model
|
|
197
|
+
codexflow execute-handoff --agent custom --command "node ./agent.js --task-file {{plan_file}}" --yes
|
|
198
|
+
|
|
199
|
+
Watch for new handoff plans and execute them locally:
|
|
200
|
+
codexflow watch-handoff --agent opencode --model provider/model --yes
|
|
201
|
+
codexflow watch-handoff --agent custom --command "node ./agent.js --task-file {{plan_file}}" --yes
|
|
202
|
+
|
|
203
|
+
Run a bounded local execute/review loop:
|
|
204
|
+
codexflow loop-handoff --agent opencode --model provider/model --review-command "node ./reviewer.js --status {{status_file}} --diff {{diff_file}} --plan-file {{plan_file}}" --max-iters 3 --yes
|
|
205
|
+
|
|
206
|
+
Stable URL mode after one-time Cloudflare tunnel setup:
|
|
207
|
+
codexflow stable --root /path/to/repo --hostname codexflow.example.com --tunnel-name codexflow
|
|
208
|
+
`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const colorEnabled = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
212
|
+
const ansi = {
|
|
213
|
+
reset: '\x1b[0m',
|
|
214
|
+
bold: '\x1b[1m',
|
|
215
|
+
dim: '\x1b[2m',
|
|
216
|
+
cyan: '\x1b[36m',
|
|
217
|
+
green: '\x1b[32m',
|
|
218
|
+
yellow: '\x1b[33m',
|
|
219
|
+
red: '\x1b[31m'
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function paint(style, text) {
|
|
223
|
+
if (!colorEnabled) return text;
|
|
224
|
+
return `${ansi[style] ?? ''}${text}${ansi.reset}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function termWidth(max = 78) {
|
|
228
|
+
return Math.max(56, Math.min(max, process.stdout.columns || max));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function divider(label = '') {
|
|
232
|
+
const width = termWidth();
|
|
233
|
+
if (!label) return paint('dim', '-'.repeat(width));
|
|
234
|
+
const text = ` ${label} `;
|
|
235
|
+
return paint('dim', `${text}${'-'.repeat(Math.max(0, width - text.length))}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function printBox(title, lines) {
|
|
239
|
+
const width = termWidth();
|
|
240
|
+
const inner = width - 4;
|
|
241
|
+
console.log(divider(title));
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
const chunks = wrapLine(line, inner);
|
|
244
|
+
for (const chunk of chunks) console.log(`| ${chunk.padEnd(inner)} |`);
|
|
245
|
+
}
|
|
246
|
+
console.log(divider());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function wrapLine(text, width) {
|
|
250
|
+
if (text.length <= width) return [text];
|
|
251
|
+
const words = text.split(/\s+/);
|
|
252
|
+
const lines = [];
|
|
253
|
+
let current = '';
|
|
254
|
+
for (const word of words) {
|
|
255
|
+
if (!current) current = word;
|
|
256
|
+
else if (`${current} ${word}`.length <= width) current += ` ${word}`;
|
|
257
|
+
else {
|
|
258
|
+
lines.push(current);
|
|
259
|
+
current = word;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (current) lines.push(current);
|
|
263
|
+
return lines;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function labelValue(label, value) {
|
|
267
|
+
return `${label.padEnd(12)} ${value}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function statusLine(status, detail = '') {
|
|
271
|
+
const marker = status === 'ok' ? paint('green', 'OK') : status === 'warn' ? paint('yellow', 'WARN') : paint('cyan', '..');
|
|
272
|
+
console.log(`${marker} ${detail}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function profileSummary(profile) {
|
|
276
|
+
if (!profile?.tunnel) return '';
|
|
277
|
+
if (profile.tunnel === 'ngrok' && profile.hostname) return `Saved ngrok URL: ${profile.hostname}`;
|
|
278
|
+
if (profile.tunnel === 'cloudflare-named' && profile.hostname) return `Saved Cloudflare URL: ${profile.hostname}`;
|
|
279
|
+
if (profile.tunnel === 'tailscale' && profile.hostname) return `Saved Tailscale Funnel URL: ${profile.hostname}`;
|
|
280
|
+
if (profile.tunnel === 'cloudflare') return 'Saved Cloudflare quick-tunnel setup';
|
|
281
|
+
if (profile.tunnel === 'none') return 'Saved local-only setup';
|
|
282
|
+
return '';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function profileOneLine(profile, index = 0) {
|
|
286
|
+
const prefix = index ? `${index}. ` : '';
|
|
287
|
+
const tunnel = profile.tunnel ?? 'cloudflare';
|
|
288
|
+
const host = profile.hostname ? ` -> ${profile.hostname}` : '';
|
|
289
|
+
const port = profile.port ? ` :${profile.port}` : '';
|
|
290
|
+
return `${prefix}${profile.root} ${tunnel}${host}${port}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function printSavedProfileHint(profile) {
|
|
294
|
+
const summary = profileSummary(profile);
|
|
295
|
+
if (!summary) return;
|
|
296
|
+
printBox('Saved setup found', [
|
|
297
|
+
summary,
|
|
298
|
+
'From this folder, future launches only need: codexflow',
|
|
299
|
+
'Use codexflow when you want to change the port, mode, tool mode, tunnel, hostname, or token.'
|
|
300
|
+
]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseArgs(argv) {
|
|
304
|
+
const out = { allowRoots: [] };
|
|
305
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
306
|
+
const raw = argv[i];
|
|
307
|
+
if (!raw.startsWith('--')) continue;
|
|
308
|
+
const option = raw.slice(2);
|
|
309
|
+
const eq = option.indexOf('=');
|
|
310
|
+
const key = eq >= 0 ? option.slice(0, eq) : option;
|
|
311
|
+
const inlineValue = eq >= 0 ? option.slice(eq + 1) : undefined;
|
|
312
|
+
if (key === 'help') out.help = true;
|
|
313
|
+
else if (key === 'allow-home') out.allowHome = true;
|
|
314
|
+
else if (key === 'no-auth') out.noAuth = true;
|
|
315
|
+
else if (key === 'no-bash') out.bash = 'off';
|
|
316
|
+
else if (key === 'compact-bash-transcript') out.bashTranscript = 'compact';
|
|
317
|
+
else if (key === 'full-bash-transcript') out.bashTranscript = 'full';
|
|
318
|
+
else if (key === 'codex-sessions-read') out.codexSessions = 'read';
|
|
319
|
+
else if (key === 'require-bash-session') out.requireBashSession = true;
|
|
320
|
+
else if (key === 'copy-url') out.copyUrl = true;
|
|
321
|
+
else if (key === 'no-copy-url') out.noCopyUrl = true;
|
|
322
|
+
else if (key === 'non-interactive' || key === 'no-control-panel') out.nonInteractive = true;
|
|
323
|
+
else if (key === 'dry-run') out.dryRun = true;
|
|
324
|
+
else if (key === 'json') out.json = true;
|
|
325
|
+
else if (key === 'staged') out.staged = true;
|
|
326
|
+
else if (key === 'once') out.once = true;
|
|
327
|
+
else if (key === 'confirm') out.confirm = true;
|
|
328
|
+
else if (key === 'no-confirm') out.noConfirm = true;
|
|
329
|
+
else if (key === 'require-clean-git-start') out.requireCleanGitStart = true;
|
|
330
|
+
else if (key === 'stop-if-no-files-changed') out.stopIfNoFilesChanged = true;
|
|
331
|
+
else if (key === 'stop-if-same-diff') out.stopIfSameDiff = true;
|
|
332
|
+
else if (key === 'require-human-confirmation') out.requireHumanConfirmation = true;
|
|
333
|
+
else if (key === 'allow-implicit-review-verdict') out.allowImplicitReviewVerdict = true;
|
|
334
|
+
else if (key === 'allow-review-pass-on-failure') out.allowReviewPassOnFailure = true;
|
|
335
|
+
else if (key === 'open-chatgpt') out.openChatgpt = true;
|
|
336
|
+
else if (key === 'no-profile') out.noProfile = true;
|
|
337
|
+
else if (key === 'save-config') out.saveConfig = true;
|
|
338
|
+
else if (key === 'no-save-config') out.noSaveConfig = true;
|
|
339
|
+
else if (key === 'yes' || key === 'force') out.yes = true;
|
|
340
|
+
else if (key === 'stable') out.tunnel = 'cloudflare-named';
|
|
341
|
+
else if (key === 'install-cloudflared') out.installCloudflared = true;
|
|
342
|
+
else if (key === 'no-install-cloudflared') out.noInstallCloudflared = true;
|
|
343
|
+
else if (key === 'agent') {
|
|
344
|
+
const next = argv[i + 1];
|
|
345
|
+
if (inlineValue !== undefined || (next && !next.startsWith('--'))) {
|
|
346
|
+
out.agent = inlineValue ?? next;
|
|
347
|
+
if (inlineValue === undefined) i += 1;
|
|
348
|
+
} else {
|
|
349
|
+
out.mode = 'agent';
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else if (key === 'handoff') out.mode = 'handoff';
|
|
353
|
+
else if (key === 'pro-planning' || key === 'pro') out.mode = 'pro';
|
|
354
|
+
else if (key === 'log-requests') out.logRequests = true;
|
|
355
|
+
else if (key === 'print-env') out.printEnv = true;
|
|
356
|
+
else {
|
|
357
|
+
const next = argv[i + 1];
|
|
358
|
+
const value = inlineValue ?? next;
|
|
359
|
+
if (value === undefined || (inlineValue === undefined && value.startsWith('--'))) throw new Error(`Missing value for --${key}`);
|
|
360
|
+
if (inlineValue === undefined) i += 1;
|
|
361
|
+
if (key === 'allow-root') out.allowRoots.push(value);
|
|
362
|
+
else out[key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function expandHome(input) {
|
|
369
|
+
if (!input || input === '~') return os.homedir();
|
|
370
|
+
if (input.startsWith('~/')) return path.join(os.homedir(), input.slice(2));
|
|
371
|
+
return input;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function analysisChangedPaths(status) {
|
|
375
|
+
if (!status || status === '(no output)') return [];
|
|
376
|
+
const paths = [];
|
|
377
|
+
for (const rawLine of String(status).split(/\r?\n/)) {
|
|
378
|
+
const line = rawLine.trim();
|
|
379
|
+
if (!line || /^(fatal:|error:|git unavailable)/i.test(line)) continue;
|
|
380
|
+
let filePath = '';
|
|
381
|
+
if (line.startsWith('?? ')) filePath = line.slice(3).trim();
|
|
382
|
+
else if (line.includes('\t')) filePath = line.split('\t').pop()?.trim() ?? '';
|
|
383
|
+
else if (/^.{2}\s/.test(line)) filePath = line.slice(3).trim();
|
|
384
|
+
if (filePath.includes(' -> ')) filePath = filePath.split(' -> ').pop() ?? filePath;
|
|
385
|
+
if (filePath.startsWith('"') && filePath.endsWith('"')) {
|
|
386
|
+
try { filePath = JSON.parse(filePath); } catch { filePath = filePath.slice(1, -1); }
|
|
387
|
+
}
|
|
388
|
+
if (filePath && !paths.includes(filePath)) paths.push(filePath);
|
|
389
|
+
}
|
|
390
|
+
return paths;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function assertGitStatusAvailable(status) {
|
|
394
|
+
const value = String(status || '').trim();
|
|
395
|
+
if (/^(fatal:|error:|git unavailable or failed:|git exited with status|usage: git )/i.test(value) || /not a git repository/i.test(value)) {
|
|
396
|
+
throw new Error(`Unable to read Git changes: ${value}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function printWorkspaceInspection(result, json) {
|
|
401
|
+
const payload = {
|
|
402
|
+
schema_version: result.schemaVersion,
|
|
403
|
+
workspace_id: result.workspaceId,
|
|
404
|
+
root: result.root,
|
|
405
|
+
languages: result.languages,
|
|
406
|
+
project_types: result.projectTypes,
|
|
407
|
+
entrypoints: result.entrypoints,
|
|
408
|
+
important_files: result.importantFiles,
|
|
409
|
+
areas: result.areas,
|
|
410
|
+
files: result.files,
|
|
411
|
+
symbols: result.symbols,
|
|
412
|
+
relationships: result.relationships,
|
|
413
|
+
coverage: result.coverage,
|
|
414
|
+
warnings: result.warnings,
|
|
415
|
+
cache: result.cache
|
|
416
|
+
};
|
|
417
|
+
if (json) {
|
|
418
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
console.log([
|
|
422
|
+
'CodexFlow Repository Analysis',
|
|
423
|
+
'',
|
|
424
|
+
`Workspace: ${result.root}`,
|
|
425
|
+
`Projects: ${result.projectTypes.join(', ') || 'unknown'}`,
|
|
426
|
+
`Languages: ${result.languages.join(', ') || 'unknown'}`,
|
|
427
|
+
`Entrypoints: ${result.entrypoints.join(', ') || 'none detected'}`,
|
|
428
|
+
`Important areas: ${result.areas.slice(0, 8).map((area) => `${area.path} (${area.files})`).join(', ') || 'none'}`,
|
|
429
|
+
`Coverage: ${result.coverage.analyzedFiles}/${result.coverage.inventoryFiles} files, ${result.coverage.symbolCount} symbols, ${result.coverage.relationshipCount} relationships${result.coverage.truncated ? ' (partial)' : ''}`,
|
|
430
|
+
...(result.warnings.length ? ['', 'Warnings:', ...result.warnings.map((warning) => `- ${warning}`)] : [])
|
|
431
|
+
].join('\n'));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function printChangeReview(result, json) {
|
|
435
|
+
const payload = {
|
|
436
|
+
schema_version: result.schemaVersion,
|
|
437
|
+
changed_files: result.changedPaths,
|
|
438
|
+
affected_areas: result.affectedAreas,
|
|
439
|
+
dependent_files: result.dependentFiles,
|
|
440
|
+
related_tests: result.relatedTests,
|
|
441
|
+
risk_signals: result.riskSignals,
|
|
442
|
+
recommended_commands: result.recommendedCommands,
|
|
443
|
+
coverage: result.coverage,
|
|
444
|
+
warnings: result.warnings,
|
|
445
|
+
cache: result.cache
|
|
446
|
+
};
|
|
447
|
+
if (json) {
|
|
448
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
console.log([
|
|
452
|
+
'CodexFlow Change Review',
|
|
453
|
+
'',
|
|
454
|
+
`Changed files: ${result.changedPaths.join(', ') || 'none'}`,
|
|
455
|
+
`Affected areas: ${result.affectedAreas.join(', ') || 'none'}`,
|
|
456
|
+
`Risk: ${result.riskSignals.map((risk) => risk.label).join(', ') || 'none detected'}`,
|
|
457
|
+
`Related tests: ${result.relatedTests.map((file) => file.path).join(', ') || 'none detected'}`,
|
|
458
|
+
`Recommended verification: ${result.recommendedCommands.map((item) => item.command).join(', ') || 'none detected'}`,
|
|
459
|
+
`Coverage: ${result.coverage.analyzedFiles}/${result.coverage.inventoryFiles} files${result.coverage.truncated ? ' (partial)' : ''}`,
|
|
460
|
+
...(result.warnings.length ? ['', 'Warnings:', ...result.warnings.map((warning) => `- ${warning}`)] : [])
|
|
461
|
+
].join('\n'));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function runAnalysisCli(command, argv) {
|
|
465
|
+
const args = parseArgs(argv);
|
|
466
|
+
const root = realDir(args.root ?? process.cwd());
|
|
467
|
+
const [{ loadConfig }, { PathGuard, WorkspaceManager }, analysis, git] = await Promise.all([
|
|
468
|
+
import(pathToFileURL(path.join(projectRoot, 'dist', 'config.js')).href),
|
|
469
|
+
import(pathToFileURL(path.join(projectRoot, 'dist', 'guard.js')).href),
|
|
470
|
+
import(pathToFileURL(path.join(projectRoot, 'dist', 'analysis', 'index.js')).href),
|
|
471
|
+
import(pathToFileURL(path.join(projectRoot, 'dist', 'gitOps.js')).href)
|
|
472
|
+
]);
|
|
473
|
+
const config = loadConfig(['--root', root, '--bash', 'off', '--write', 'off']);
|
|
474
|
+
const guard = new PathGuard(config);
|
|
475
|
+
const workspace = new WorkspaceManager(config).defaultWorkspace();
|
|
476
|
+
if (args.path) guard.resolve(workspace, args.path);
|
|
477
|
+
if (command === 'inspect') {
|
|
478
|
+
printWorkspaceInspection(await analysis.inspectWorkspace(config, guard, workspace), Boolean(args.json));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const status = git.gitDiffStatus(config, guard, workspace, args.path, Boolean(args.staged));
|
|
482
|
+
assertGitStatusAvailable(status);
|
|
483
|
+
const changedPaths = analysisChangedPaths(status);
|
|
484
|
+
const review = await analysis.reviewWorkspaceChanges(config, guard, workspace, { changedPaths });
|
|
485
|
+
printChangeReview(review, Boolean(args.json));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function realDir(input) {
|
|
489
|
+
const resolved = path.resolve(expandHome(input));
|
|
490
|
+
if (!fs.existsSync(resolved)) throw new Error(`Directory does not exist: ${resolved}`);
|
|
491
|
+
const stat = fs.statSync(resolved);
|
|
492
|
+
if (!stat.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
|
|
493
|
+
return fs.realpathSync(resolved);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function resolveCodexDir(root, input) {
|
|
497
|
+
if (!input) return '';
|
|
498
|
+
const expanded = expandHome(input);
|
|
499
|
+
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function resolveConfigPath(root, input) {
|
|
503
|
+
if (!input) return '';
|
|
504
|
+
const expanded = expandHome(String(input));
|
|
505
|
+
return path.isAbsolute(expanded) || path.win32.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function effectiveWriteMode(mode, requested) {
|
|
509
|
+
const value = requested || (mode === 'agent' ? 'workspace' : 'handoff');
|
|
510
|
+
if (!['off', 'handoff', 'workspace'].includes(value)) {
|
|
511
|
+
throw new Error('--write must be off, handoff, or workspace');
|
|
512
|
+
}
|
|
513
|
+
if (mode === 'agent') return value;
|
|
514
|
+
return value === 'off' ? 'off' : 'handoff';
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function writeOption(args, profile, mode) {
|
|
518
|
+
return effectiveWriteMode(mode, optionValue(args, profile, 'write', ['CODEXFLOW_WRITE_MODE'], mode === 'agent' ? 'workspace' : 'handoff'));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function validateChoice(flag, value, allowed) {
|
|
522
|
+
if (allowed.includes(value)) return value;
|
|
523
|
+
throw new Error(`--${flag} must be ${allowed.slice(0, -1).join(', ')}, or ${allowed.at(-1)}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function optionalChoice(flag, value, allowed) {
|
|
527
|
+
if (!value) return '';
|
|
528
|
+
return validateChoice(flag, value, allowed);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function optionalWriteOption(args, profile, mode) {
|
|
532
|
+
const requested = optionValue(args, profile, 'write', ['CODEXFLOW_WRITE_MODE'], '');
|
|
533
|
+
return requested ? effectiveWriteMode(mode, requested) : '';
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function commandExists(command) {
|
|
537
|
+
const result = spawnSync(process.platform === 'win32' ? 'where' : 'command', process.platform === 'win32' ? [command] : ['-v', command], {
|
|
538
|
+
shell: process.platform !== 'win32',
|
|
539
|
+
stdio: 'ignore'
|
|
540
|
+
});
|
|
541
|
+
return result.status === 0;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function commandPaths(command) {
|
|
545
|
+
if (process.platform === 'win32') {
|
|
546
|
+
const result = spawnSync('where', [command], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
547
|
+
if (result.status !== 0) return [];
|
|
548
|
+
return String(result.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
549
|
+
}
|
|
550
|
+
const result = spawnSync('command', ['-v', command], { encoding: 'utf8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
551
|
+
if (result.status !== 0) return [];
|
|
552
|
+
return String(result.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function isPathLike(command) {
|
|
556
|
+
return command.includes('/') || command.includes('\\') || command.startsWith('.');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function resolveExecutablePath(command) {
|
|
560
|
+
const expanded = expandHome(command);
|
|
561
|
+
return path.resolve(expanded);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isWindowsBatchFile(command) {
|
|
565
|
+
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function isWindowsCommandCandidate(command) {
|
|
569
|
+
return process.platform === 'win32' && /\.(cmd|bat|exe)$/i.test(command);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function resolveCodexCommand() {
|
|
573
|
+
const explicit = String(process.env.CODEXFLOW_CODEX_BIN ?? '').trim();
|
|
574
|
+
if (explicit) {
|
|
575
|
+
if (isPathLike(explicit)) return resolveExecutablePath(explicit);
|
|
576
|
+
const candidates = commandPaths(explicit);
|
|
577
|
+
if (process.platform !== 'win32') return candidates[0] || explicit;
|
|
578
|
+
return candidates.find(isWindowsCommandCandidate) || explicit;
|
|
579
|
+
}
|
|
580
|
+
if (process.platform !== 'win32') return 'codex';
|
|
581
|
+
return commandPaths('codex').find(isWindowsCommandCandidate) || 'codex';
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function executableFileExists(filePath) {
|
|
585
|
+
try {
|
|
586
|
+
return fs.statSync(filePath).isFile();
|
|
587
|
+
} catch {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function commandAvailable(command) {
|
|
593
|
+
if (isPathLike(command)) return executableFileExists(resolveExecutablePath(command));
|
|
594
|
+
return commandExists(command);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function commandAvailableFromRoot(command, root) {
|
|
598
|
+
if (!isPathLike(command)) return commandExists(command);
|
|
599
|
+
const expanded = expandHome(command);
|
|
600
|
+
const resolved = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
|
|
601
|
+
return executableFileExists(resolved);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function codexFlowHome() {
|
|
605
|
+
const customHome = process.env.CODEXFLOW_HOME;
|
|
606
|
+
return customHome ? path.resolve(expandHome(customHome)) : path.join(os.homedir(), '.codexflow');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function profileDir() {
|
|
610
|
+
return path.join(codexFlowHome(), 'profiles');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function profileIdForRoot(root) {
|
|
614
|
+
return createHash('sha256').update(root).digest('hex').slice(0, 24);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function profilePathForRoot(root) {
|
|
618
|
+
return path.join(profileDir(), `${profileIdForRoot(root)}.json`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function runtimeDir() {
|
|
622
|
+
return path.join(codexFlowHome(), 'runtime');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function runtimeStatusPathForRoot(root) {
|
|
626
|
+
return path.join(runtimeDir(), `${profileIdForRoot(root)}.json`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function readJsonFile(filePath) {
|
|
630
|
+
try {
|
|
631
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return {};
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function loadWorkspaceProfile(root) {
|
|
639
|
+
const profilePath = profilePathForRoot(root);
|
|
640
|
+
if (!fs.existsSync(profilePath)) return {};
|
|
641
|
+
const profile = readJsonFile(profilePath);
|
|
642
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return {};
|
|
643
|
+
if (profile.root && profile.root !== root) return {};
|
|
644
|
+
return { ...profile, profilePath };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function listWorkspaceProfiles() {
|
|
648
|
+
const dir = profileDir();
|
|
649
|
+
if (!fs.existsSync(dir)) return [];
|
|
650
|
+
return fs.readdirSync(dir)
|
|
651
|
+
.filter((name) => name.endsWith('.json'))
|
|
652
|
+
.map((name) => {
|
|
653
|
+
const profilePath = path.join(dir, name);
|
|
654
|
+
const profile = readJsonFile(profilePath);
|
|
655
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile) || !profile.root) return null;
|
|
656
|
+
return { ...profile, profilePath };
|
|
657
|
+
})
|
|
658
|
+
.filter(Boolean)
|
|
659
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function deleteWorkspaceProfile(root) {
|
|
663
|
+
const filePath = profilePathForRoot(root);
|
|
664
|
+
if (!fs.existsSync(filePath)) return false;
|
|
665
|
+
fs.rmSync(filePath, { force: true });
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function saveWorkspaceProfile(root, profile) {
|
|
670
|
+
const dir = profileDir();
|
|
671
|
+
const filePath = profilePathForRoot(root);
|
|
672
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
673
|
+
const payload = {
|
|
674
|
+
version: 1,
|
|
675
|
+
root,
|
|
676
|
+
updatedAt: new Date().toISOString(),
|
|
677
|
+
...profile
|
|
678
|
+
};
|
|
679
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
680
|
+
try {
|
|
681
|
+
fs.chmodSync(filePath, 0o600);
|
|
682
|
+
} catch {}
|
|
683
|
+
return filePath;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function saveRuntimeConnection(root, details, options = {}) {
|
|
687
|
+
const filePath = runtimeStatusPathForRoot(root);
|
|
688
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
689
|
+
const payload = {
|
|
690
|
+
version: 1,
|
|
691
|
+
root,
|
|
692
|
+
pid: process.pid,
|
|
693
|
+
updatedAt: new Date().toISOString(),
|
|
694
|
+
endpoint: details.endpoint,
|
|
695
|
+
localBase: options.localBase ?? '',
|
|
696
|
+
localStatusUrl: details.localStatusUrl ? details.localStatusUrl.replace(/codexflow_token=[^&]+/, 'codexflow_token=<redacted>') : '',
|
|
697
|
+
tunnel: options.tunnel ?? '',
|
|
698
|
+
mode: options.mode ?? '',
|
|
699
|
+
bash: options.bash ?? '',
|
|
700
|
+
bashTranscript: options.bashTranscript ?? '',
|
|
701
|
+
codexSessions: options.codexSessions ?? '',
|
|
702
|
+
bashSession: options.bashSession ?? '',
|
|
703
|
+
requireBashSession: Boolean(options.requireBashSession),
|
|
704
|
+
write: options.write ?? '',
|
|
705
|
+
toolMode: options.toolMode ?? '',
|
|
706
|
+
toolCards: Boolean(options.toolCards)
|
|
707
|
+
};
|
|
708
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
709
|
+
try {
|
|
710
|
+
fs.chmodSync(filePath, 0o600);
|
|
711
|
+
} catch {}
|
|
712
|
+
return filePath;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function clearRuntimeConnection(root) {
|
|
716
|
+
try {
|
|
717
|
+
const filePath = runtimeStatusPathForRoot(root);
|
|
718
|
+
const runtime = readJsonFile(filePath);
|
|
719
|
+
if (runtime?.pid === process.pid) fs.rmSync(filePath, { force: true });
|
|
720
|
+
} catch {}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function sanitizedProfile(profile) {
|
|
724
|
+
if (!profile || !Object.keys(profile).length) return {};
|
|
725
|
+
const { token, cloudflareToken, ...rest } = profile;
|
|
726
|
+
return {
|
|
727
|
+
...rest,
|
|
728
|
+
...(token ? { token: '<saved>' } : {}),
|
|
729
|
+
...(cloudflareToken ? { cloudflareToken: '<saved>' } : {})
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function reusableProfilePayload(profile, overrides = {}) {
|
|
734
|
+
const {
|
|
735
|
+
version,
|
|
736
|
+
root,
|
|
737
|
+
updatedAt,
|
|
738
|
+
profilePath,
|
|
739
|
+
...rest
|
|
740
|
+
} = profile || {};
|
|
741
|
+
return {
|
|
742
|
+
...rest,
|
|
743
|
+
...overrides
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function optionValue(args, profile, field, envNames = [], fallback = undefined) {
|
|
748
|
+
if (args[field] !== undefined) return args[field];
|
|
749
|
+
for (const envName of envNames) {
|
|
750
|
+
if (process.env[envName] !== undefined && process.env[envName] !== '') return process.env[envName];
|
|
751
|
+
}
|
|
752
|
+
if (profile?.[field] !== undefined && profile[field] !== '') return profile[field];
|
|
753
|
+
return fallback;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function boolFromValue(value, fallback = false) {
|
|
757
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
758
|
+
if (typeof value === 'boolean') return value;
|
|
759
|
+
return ['1', 'true', 'yes', 'y', 'on'].includes(String(value).toLowerCase());
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function optionBool(args, profile, field, envNames = [], fallback = false) {
|
|
763
|
+
if (args[field] !== undefined) return boolFromValue(args[field], fallback);
|
|
764
|
+
for (const envName of envNames) {
|
|
765
|
+
if (process.env[envName] !== undefined && process.env[envName] !== '') return boolFromValue(process.env[envName], fallback);
|
|
766
|
+
}
|
|
767
|
+
if (profile?.[field] !== undefined && profile[field] !== '') return boolFromValue(profile[field], fallback);
|
|
768
|
+
return fallback;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function hasToolCardsInput(args, profile = {}) {
|
|
772
|
+
return args.toolCards !== undefined || profile.toolCards !== undefined || (process.env.CODEXFLOW_TOOL_CARDS !== undefined && process.env.CODEXFLOW_TOOL_CARDS !== '');
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function toolCardsProfileEntry(args, profile = {}) {
|
|
776
|
+
const hasInput = hasToolCardsInput(args, profile);
|
|
777
|
+
return hasInput ? { toolCards: optionBool(args, profile, 'toolCards', ['CODEXFLOW_TOOL_CARDS'], false) } : {};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function toolCardsCliArgs(args, profile = {}) {
|
|
781
|
+
if (!hasToolCardsInput(args, profile)) return [];
|
|
782
|
+
return ['--tool-cards', optionBool(args, profile, 'toolCards', ['CODEXFLOW_TOOL_CARDS'], false) ? 'on' : 'off'];
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function validateBashSession(value) {
|
|
786
|
+
if (!value) return '';
|
|
787
|
+
const trimmed = String(value).trim();
|
|
788
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(trimmed)) {
|
|
789
|
+
throw new Error('--bash-session must be 1-64 characters using letters, numbers, dot, underscore, or dash, and must start with a letter or number.');
|
|
790
|
+
}
|
|
791
|
+
return trimmed;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function bashSessionOptions(args, profile = {}) {
|
|
795
|
+
const bashSession = validateBashSession(optionValue(args, profile, 'bashSession', ['CODEXFLOW_BASH_SESSION_ID'], ''));
|
|
796
|
+
const requireBashSession = optionBool(args, profile, 'requireBashSession', ['CODEXFLOW_REQUIRE_BASH_SESSION'], false);
|
|
797
|
+
if (requireBashSession && !bashSession) {
|
|
798
|
+
throw new Error('--require-bash-session requires --bash-session <id>.');
|
|
799
|
+
}
|
|
800
|
+
return { bashSession, requireBashSession };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function bashTranscriptOption(args, profile = {}) {
|
|
804
|
+
const value = optionValue(args, profile, 'bashTranscript', ['CODEXFLOW_BASH_TRANSCRIPT'], 'compact');
|
|
805
|
+
if (value === 'compact' || value === 'full') return value;
|
|
806
|
+
throw new Error('--bash-transcript must be compact or full.');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function codexSessionsOption(args, profile = {}) {
|
|
810
|
+
const value = optionValue(args, profile, 'codexSessions', ['CODEXFLOW_CODEX_SESSIONS'], 'off');
|
|
811
|
+
if (value === 'off' || value === 'metadata' || value === 'read') return value;
|
|
812
|
+
throw new Error('--codex-sessions must be off, metadata, or read.');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function discoverCodexProjectDirectories(codexDirInput) {
|
|
816
|
+
try {
|
|
817
|
+
const modulePath = path.join(projectRoot, 'dist', 'codexSessions.js');
|
|
818
|
+
if (!fs.existsSync(modulePath)) return [];
|
|
819
|
+
const { listCodexProjectDirectories } = await import(pathToFileURL(modulePath).href);
|
|
820
|
+
const projects = await listCodexProjectDirectories({ codexSessions: 'metadata', codexDir: codexDirInput });
|
|
821
|
+
const roots = [];
|
|
822
|
+
const seen = new Set();
|
|
823
|
+
for (const project of projects) {
|
|
824
|
+
let root;
|
|
825
|
+
try {
|
|
826
|
+
root = realDir(project.project_dir);
|
|
827
|
+
} catch {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (seen.has(root)) continue;
|
|
831
|
+
seen.add(root);
|
|
832
|
+
roots.push(root);
|
|
833
|
+
}
|
|
834
|
+
return roots;
|
|
835
|
+
} catch {
|
|
836
|
+
return [];
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function stableToken(existing = '') {
|
|
841
|
+
return existing || randomBytes(24).toString('hex');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function cloudflaredBinName() {
|
|
845
|
+
return process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function localCloudflaredPath() {
|
|
849
|
+
return path.join(codexFlowHome(), 'bin', cloudflaredBinName());
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function cloudflaredReleaseAsset() {
|
|
853
|
+
const platform = process.platform;
|
|
854
|
+
const arch = process.arch;
|
|
855
|
+
|
|
856
|
+
if (platform === 'darwin') {
|
|
857
|
+
if (arch === 'arm64') return { file: 'cloudflared-darwin-arm64.tgz', archive: true };
|
|
858
|
+
if (arch === 'x64') return { file: 'cloudflared-darwin-amd64.tgz', archive: true };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (platform === 'linux') {
|
|
862
|
+
if (arch === 'arm64') return { file: 'cloudflared-linux-arm64', archive: false };
|
|
863
|
+
if (arch === 'arm') return { file: 'cloudflared-linux-arm', archive: false };
|
|
864
|
+
if (arch === 'x64') return { file: 'cloudflared-linux-amd64', archive: false };
|
|
865
|
+
if (arch === 'ia32') return { file: 'cloudflared-linux-386', archive: false };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (platform === 'win32') {
|
|
869
|
+
if (arch === 'x64') return { file: 'cloudflared-windows-amd64.exe', archive: false };
|
|
870
|
+
if (arch === 'ia32') return { file: 'cloudflared-windows-386.exe', archive: false };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
throw new Error(`Automatic cloudflared install is not supported on ${platform}/${arch}. Install cloudflared manually or pass --cloudflared <path>.`);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function findFileByName(root, fileName) {
|
|
877
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
878
|
+
for (const entry of entries) {
|
|
879
|
+
const fullPath = path.join(root, entry.name);
|
|
880
|
+
if (entry.isFile() && entry.name === fileName) return fullPath;
|
|
881
|
+
if (entry.isDirectory()) {
|
|
882
|
+
const found = findFileByName(fullPath, fileName);
|
|
883
|
+
if (found) return found;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return '';
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function downloadFile(url, destination) {
|
|
890
|
+
const response = await fetch(url, {
|
|
891
|
+
headers: { 'user-agent': 'codexflow-launcher' }
|
|
892
|
+
});
|
|
893
|
+
if (!response.ok) {
|
|
894
|
+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
895
|
+
}
|
|
896
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
897
|
+
fs.writeFileSync(destination, buffer, { mode: 0o755 });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function verifyCloudflared(binaryPath) {
|
|
901
|
+
const result = spawnSync(binaryPath, ['--version'], {
|
|
902
|
+
stdio: 'ignore',
|
|
903
|
+
shell: false,
|
|
904
|
+
timeout: 15000
|
|
905
|
+
});
|
|
906
|
+
if (result.status !== 0) {
|
|
907
|
+
throw new Error(`Downloaded cloudflared, but ${binaryPath} --version failed.`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function installCloudflaredLocal() {
|
|
912
|
+
const asset = cloudflaredReleaseAsset();
|
|
913
|
+
const installPath = localCloudflaredPath();
|
|
914
|
+
const binDir = path.dirname(installPath);
|
|
915
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codexflow-cloudflared-'));
|
|
916
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.file}`;
|
|
917
|
+
|
|
918
|
+
fs.mkdirSync(binDir, { recursive: true, mode: 0o700 });
|
|
919
|
+
console.error(`[codexflow] Installing cloudflared locally: ${installPath}`);
|
|
920
|
+
console.error(`[codexflow] Downloading official Cloudflare release: ${asset.file}`);
|
|
921
|
+
|
|
922
|
+
try {
|
|
923
|
+
if (asset.archive) {
|
|
924
|
+
const archivePath = path.join(tmpRoot, asset.file);
|
|
925
|
+
const extractDir = path.join(tmpRoot, 'extract');
|
|
926
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
927
|
+
await downloadFile(url, archivePath);
|
|
928
|
+
const tar = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir], {
|
|
929
|
+
encoding: 'utf8',
|
|
930
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
931
|
+
shell: false
|
|
932
|
+
});
|
|
933
|
+
if (tar.status !== 0) {
|
|
934
|
+
throw new Error(`Failed to extract ${asset.file}: ${tar.stderr || tar.stdout || `exit ${tar.status}`}`);
|
|
935
|
+
}
|
|
936
|
+
const extracted = findFileByName(extractDir, 'cloudflared');
|
|
937
|
+
if (!extracted) throw new Error(`Could not find cloudflared inside ${asset.file}`);
|
|
938
|
+
fs.copyFileSync(extracted, installPath);
|
|
939
|
+
} else {
|
|
940
|
+
const tmpBinary = path.join(tmpRoot, cloudflaredBinName());
|
|
941
|
+
await downloadFile(url, tmpBinary);
|
|
942
|
+
fs.copyFileSync(tmpBinary, installPath);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (process.platform !== 'win32') fs.chmodSync(installPath, 0o755);
|
|
946
|
+
verifyCloudflared(installPath);
|
|
947
|
+
console.error('[codexflow] cloudflared installed successfully.');
|
|
948
|
+
return installPath;
|
|
949
|
+
} finally {
|
|
950
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function resolveCloudflared(args) {
|
|
955
|
+
const explicit = args.cloudflared ?? process.env.CLOUDFLARED_BIN ?? '';
|
|
956
|
+
if (explicit) {
|
|
957
|
+
const resolved = isPathLike(explicit) ? resolveExecutablePath(explicit) : explicit;
|
|
958
|
+
if (commandAvailable(resolved)) {
|
|
959
|
+
verifyCloudflared(resolved);
|
|
960
|
+
return resolved;
|
|
961
|
+
}
|
|
962
|
+
throw new Error(`cloudflared was not found at ${explicit}. Remove --cloudflared, install it, or pass a valid path.`);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (!args.installCloudflared && commandExists('cloudflared')) {
|
|
966
|
+
try {
|
|
967
|
+
verifyCloudflared('cloudflared');
|
|
968
|
+
return 'cloudflared';
|
|
969
|
+
} catch (error) {
|
|
970
|
+
console.error(`[codexflow] cloudflared in PATH failed --version; trying local install. ${error instanceof Error ? error.message : String(error)}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const localPath = localCloudflaredPath();
|
|
975
|
+
if (!args.installCloudflared && executableFileExists(localPath)) {
|
|
976
|
+
try {
|
|
977
|
+
verifyCloudflared(localPath);
|
|
978
|
+
return localPath;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
if (args.noInstallCloudflared) return localPath;
|
|
981
|
+
console.error(`[codexflow] Existing ${localPath} failed --version; reinstalling. ${error instanceof Error ? error.message : String(error)}`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (args.noInstallCloudflared) return '';
|
|
986
|
+
return installCloudflaredLocal();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function verifyNgrok(binaryPath) {
|
|
990
|
+
const result = spawnSync(binaryPath, ['version'], {
|
|
991
|
+
stdio: 'ignore',
|
|
992
|
+
shell: false,
|
|
993
|
+
timeout: 15000
|
|
994
|
+
});
|
|
995
|
+
if (result.status !== 0) {
|
|
996
|
+
throw new Error(`ngrok was found, but ${binaryPath} version failed. Run ngrok version to inspect it.`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function resolveNgrok(args) {
|
|
1001
|
+
const explicit = args.ngrok ?? process.env.NGROK_BIN ?? '';
|
|
1002
|
+
if (explicit) {
|
|
1003
|
+
const resolved = isPathLike(explicit) ? resolveExecutablePath(explicit) : explicit;
|
|
1004
|
+
if (commandAvailable(resolved)) {
|
|
1005
|
+
verifyNgrok(resolved);
|
|
1006
|
+
return resolved;
|
|
1007
|
+
}
|
|
1008
|
+
throw new Error(`ngrok was not found at ${explicit}. Install ngrok, add it to PATH, or pass --ngrok <path>.`);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (commandExists('ngrok')) {
|
|
1012
|
+
verifyNgrok('ngrok');
|
|
1013
|
+
return 'ngrok';
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
throw new Error('ngrok was not found on PATH. Install it with Homebrew, winget, apt, or from https://ngrok.com/download, then run ngrok config add-authtoken <token>.');
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function verifyTailscale(binaryPath) {
|
|
1020
|
+
const result = spawnSync(binaryPath, ['version'], {
|
|
1021
|
+
stdio: 'ignore',
|
|
1022
|
+
shell: false,
|
|
1023
|
+
timeout: 15000
|
|
1024
|
+
});
|
|
1025
|
+
if (result.status !== 0) {
|
|
1026
|
+
throw new Error(`tailscale was found, but ${binaryPath} version failed. Run tailscale version to inspect it.`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function resolveTailscale(args) {
|
|
1031
|
+
const explicit = args.tailscale ?? process.env.TAILSCALE_BIN ?? '';
|
|
1032
|
+
if (explicit) {
|
|
1033
|
+
const resolved = isPathLike(explicit) ? resolveExecutablePath(explicit) : explicit;
|
|
1034
|
+
if (commandAvailable(resolved)) {
|
|
1035
|
+
verifyTailscale(resolved);
|
|
1036
|
+
return resolved;
|
|
1037
|
+
}
|
|
1038
|
+
throw new Error(`tailscale was not found at ${explicit}. Install Tailscale, add it to PATH, or pass --tailscale <path>.`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (commandExists('tailscale')) {
|
|
1042
|
+
verifyTailscale('tailscale');
|
|
1043
|
+
return 'tailscale';
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
throw new Error('tailscale was not found on PATH. Install Tailscale and enable Funnel, then run codexflow tailscale --hostname your-device.your-tailnet.ts.net.');
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function ngrokConfigPath(root, args, profile = {}) {
|
|
1050
|
+
const configPath = optionValue(args, profile, 'ngrokConfig', ['NGROK_CONFIG', 'CODEXFLOW_NGROK_CONFIG'], '');
|
|
1051
|
+
return resolveConfigPath(root, configPath);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function runHelperScript(scriptName, args) {
|
|
1055
|
+
const scriptPath = path.join(projectRoot, 'scripts', scriptName);
|
|
1056
|
+
const result = spawnSync(process.execPath, [scriptPath, ...args], {
|
|
1057
|
+
cwd: projectRoot,
|
|
1058
|
+
env: { ...process.env, CODEXFLOW_CALLER_CWD: process.cwd() },
|
|
1059
|
+
stdio: 'inherit'
|
|
1060
|
+
});
|
|
1061
|
+
if (result.error) throw result.error;
|
|
1062
|
+
process.exit(result.status ?? 1);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function sleep(ms) {
|
|
1066
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async function waitForHealth(url, token, timeoutMs = 15000) {
|
|
1070
|
+
const started = Date.now();
|
|
1071
|
+
let lastError = '';
|
|
1072
|
+
while (Date.now() - started < timeoutMs) {
|
|
1073
|
+
try {
|
|
1074
|
+
const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
|
1075
|
+
if (res.ok) return await res.json();
|
|
1076
|
+
lastError = `${res.status} ${await res.text()}`;
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1079
|
+
}
|
|
1080
|
+
await sleep(250);
|
|
1081
|
+
}
|
|
1082
|
+
throw new Error(`Timed out waiting for ${url}. Last error: ${lastError}`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function portInUseHelp(host, port) {
|
|
1086
|
+
return [
|
|
1087
|
+
`Local port ${port} is already in use on ${host}.`,
|
|
1088
|
+
'',
|
|
1089
|
+
'If you want two repositories running at the same time, each one needs its own local port.',
|
|
1090
|
+
'',
|
|
1091
|
+
'Example:',
|
|
1092
|
+
' repo A: codexflow -> port 8787 -> hostname A',
|
|
1093
|
+
' repo B: codexflow -> port 8788 -> hostname B',
|
|
1094
|
+
'',
|
|
1095
|
+
'For quick tunnels you can also start the second repo with:',
|
|
1096
|
+
' codexflow --port 8788',
|
|
1097
|
+
'',
|
|
1098
|
+
'Stable public hostnames also cannot be shared by two running repositories at once.'
|
|
1099
|
+
].join('\n');
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function normalizePort(port) {
|
|
1103
|
+
const numericPort = Number(port);
|
|
1104
|
+
if (!Number.isInteger(numericPort) || numericPort <= 0 || numericPort > 65535) {
|
|
1105
|
+
throw new Error(`Invalid port: ${port}`);
|
|
1106
|
+
}
|
|
1107
|
+
return String(numericPort);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async function assertPortAvailable(host, port) {
|
|
1111
|
+
const numericPort = Number(normalizePort(port));
|
|
1112
|
+
await new Promise((resolve, reject) => {
|
|
1113
|
+
const server = net.createServer();
|
|
1114
|
+
server.once('error', (error) => {
|
|
1115
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
|
|
1116
|
+
reject(new Error(portInUseHelp(host, port)));
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
reject(error);
|
|
1120
|
+
});
|
|
1121
|
+
server.once('listening', () => {
|
|
1122
|
+
server.close(() => resolve());
|
|
1123
|
+
});
|
|
1124
|
+
server.listen(numericPort, host);
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const spawnedChildren = new Set();
|
|
1129
|
+
|
|
1130
|
+
function spawnLogged(name, command, args, options = {}) {
|
|
1131
|
+
const { verbose = false, ...spawnOptions } = options;
|
|
1132
|
+
const child = spawn(command, args, { ...spawnOptions, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1133
|
+
const logLines = [];
|
|
1134
|
+
const record = (stream, chunk) => {
|
|
1135
|
+
const text = redactForLog(String(chunk));
|
|
1136
|
+
logLines.push(...text.split(/\r?\n/).filter(Boolean).map((line) => `[${name}] ${line}`));
|
|
1137
|
+
while (logLines.length > 120) logLines.shift();
|
|
1138
|
+
if (verbose) stream.write(`[${name}] ${text}`);
|
|
1139
|
+
};
|
|
1140
|
+
child.codexflowLogTail = () => logLines.join('\n');
|
|
1141
|
+
spawnedChildren.add(child);
|
|
1142
|
+
child.stdout.on('data', (chunk) => record(process.stdout, chunk));
|
|
1143
|
+
child.stderr.on('data', (chunk) => record(process.stderr, chunk));
|
|
1144
|
+
child.on('exit', (code, signal) => {
|
|
1145
|
+
spawnedChildren.delete(child);
|
|
1146
|
+
if (verbose) console.error(`[${name}] exited code=${code} signal=${signal}`);
|
|
1147
|
+
});
|
|
1148
|
+
return child;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function waitForCloudflareUrl(child, timeoutMs = 45000) {
|
|
1152
|
+
const re = /https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/g;
|
|
1153
|
+
let buffer = '';
|
|
1154
|
+
const isQuickTunnelUrl = (value) => {
|
|
1155
|
+
try {
|
|
1156
|
+
return new URL(value).hostname !== 'api.trycloudflare.com';
|
|
1157
|
+
} catch {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
return new Promise((resolve, reject) => {
|
|
1162
|
+
const timer = setTimeout(() => reject(new Error('Timed out waiting for cloudflared public URL.')), timeoutMs);
|
|
1163
|
+
timer.unref();
|
|
1164
|
+
const onData = (chunk) => {
|
|
1165
|
+
const text = String(chunk);
|
|
1166
|
+
buffer += text;
|
|
1167
|
+
const match = buffer.match(re);
|
|
1168
|
+
const tunnelUrl = match?.find(isQuickTunnelUrl);
|
|
1169
|
+
if (tunnelUrl) {
|
|
1170
|
+
clearTimeout(timer);
|
|
1171
|
+
resolve(tunnelUrl);
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
child.stdout.on('data', onData);
|
|
1175
|
+
child.stderr.on('data', onData);
|
|
1176
|
+
child.on('exit', (code) => {
|
|
1177
|
+
clearTimeout(timer);
|
|
1178
|
+
reject(new Error(`cloudflared exited before a URL was found, code=${code}`));
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function waitForTunnelStartup(child, label, timeoutMs = 1000) {
|
|
1184
|
+
return new Promise((resolve, reject) => {
|
|
1185
|
+
let settled = false;
|
|
1186
|
+
let timer;
|
|
1187
|
+
const cleanup = () => {
|
|
1188
|
+
clearTimeout(timer);
|
|
1189
|
+
child.off('exit', onExit);
|
|
1190
|
+
child.off('error', onError);
|
|
1191
|
+
};
|
|
1192
|
+
const settle = (fn, value) => {
|
|
1193
|
+
if (settled) return;
|
|
1194
|
+
settled = true;
|
|
1195
|
+
cleanup();
|
|
1196
|
+
fn(value);
|
|
1197
|
+
};
|
|
1198
|
+
const outputTail = () => {
|
|
1199
|
+
const tail = typeof child.codexflowLogTail === 'function' ? child.codexflowLogTail() : '';
|
|
1200
|
+
return tail ? `\n\nRecent ${label} output:\n${tail}` : '';
|
|
1201
|
+
};
|
|
1202
|
+
const onExit = (code, signal) => {
|
|
1203
|
+
settle(reject, new Error(`${label} exited before startup completed, code=${code} signal=${signal}${outputTail()}`));
|
|
1204
|
+
};
|
|
1205
|
+
const onError = (error) => {
|
|
1206
|
+
settle(reject, new Error(`${label} failed before startup completed: ${error instanceof Error ? error.message : String(error)}${outputTail()}`));
|
|
1207
|
+
};
|
|
1208
|
+
timer = setTimeout(() => settle(resolve), timeoutMs);
|
|
1209
|
+
timer.unref();
|
|
1210
|
+
child.once('exit', onExit);
|
|
1211
|
+
child.once('error', onError);
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function outboundProxyFromEnv(env = process.env) {
|
|
1216
|
+
return env.HTTPS_PROXY || env.https_proxy || env.ALL_PROXY || env.all_proxy || env.HTTP_PROXY || env.http_proxy || '';
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function requestQuickTunnelViaCurl(proxyUrl) {
|
|
1220
|
+
const args = ['--silent', '--show-error', '--fail', '--max-time', '30'];
|
|
1221
|
+
if (proxyUrl) args.push('--proxy', proxyUrl);
|
|
1222
|
+
args.push('-X', 'POST', 'https://api.trycloudflare.com/tunnel');
|
|
1223
|
+
const result = spawnSync('curl', args, {
|
|
1224
|
+
encoding: 'utf8',
|
|
1225
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1226
|
+
shell: false
|
|
1227
|
+
});
|
|
1228
|
+
if (result.status !== 0) {
|
|
1229
|
+
throw new Error(redactForLog(`Failed to request Cloudflare quick tunnel via curl: ${result.stderr || result.stdout || `exit ${result.status}`}`));
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
let body;
|
|
1233
|
+
try {
|
|
1234
|
+
body = JSON.parse(result.stdout);
|
|
1235
|
+
} catch {
|
|
1236
|
+
throw new Error('Cloudflare quick tunnel API returned invalid JSON.');
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const tunnel = body?.result;
|
|
1240
|
+
if (!body?.success || !tunnel?.id || !tunnel?.hostname || !tunnel?.account_tag || !tunnel?.secret) {
|
|
1241
|
+
const errors = Array.isArray(body?.errors) && body.errors.length ? ` ${JSON.stringify(body.errors)}` : '';
|
|
1242
|
+
throw new Error(redactForLog(`Cloudflare quick tunnel API did not return usable tunnel credentials.${errors}`));
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
id: String(tunnel.id),
|
|
1247
|
+
hostname: normalizePublicHostname(tunnel.hostname),
|
|
1248
|
+
accountTag: String(tunnel.account_tag),
|
|
1249
|
+
secret: String(tunnel.secret)
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function writeQuickTunnelCredentials(tunnel) {
|
|
1254
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codexflow-cloudflare-quick-'));
|
|
1255
|
+
const credentialsPath = path.join(tmpRoot, 'credentials.json');
|
|
1256
|
+
fs.writeFileSync(credentialsPath, JSON.stringify({
|
|
1257
|
+
AccountTag: tunnel.accountTag,
|
|
1258
|
+
TunnelSecret: tunnel.secret,
|
|
1259
|
+
TunnelID: tunnel.id
|
|
1260
|
+
}, null, 2), { mode: 0o600 });
|
|
1261
|
+
return { tmpRoot, credentialsPath };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function killProcess(child) {
|
|
1265
|
+
if (!child || child.killed) return;
|
|
1266
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
1267
|
+
setTimeout(() => {
|
|
1268
|
+
if (!child.killed) {
|
|
1269
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
1270
|
+
}
|
|
1271
|
+
}, 1500).unref();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function cleanupChildren() {
|
|
1275
|
+
for (const child of spawnedChildren) killProcess(child);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function endpointWithToken(endpoint, token) {
|
|
1279
|
+
if (!token) return endpoint;
|
|
1280
|
+
const url = new URL(endpoint);
|
|
1281
|
+
url.searchParams.set('codexflow_token', token);
|
|
1282
|
+
return url.toString();
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function normalizePublicHostname(value) {
|
|
1286
|
+
const raw = String(value ?? '').trim().replace(/\/+$/, '');
|
|
1287
|
+
if (!raw) return '';
|
|
1288
|
+
const url = new URL(raw.includes('://') ? raw : `https://${raw}`);
|
|
1289
|
+
if (url.protocol !== 'https:') throw new Error('hostname must use https when a scheme is provided.');
|
|
1290
|
+
if (url.search || url.hash) throw new Error('hostname must not include query strings or fragments.');
|
|
1291
|
+
if (url.pathname !== '/' && url.pathname !== '/mcp') throw new Error('hostname must be a host, URL root, or /mcp URL.');
|
|
1292
|
+
return url.host;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function publicBaseFromHostname(hostname) {
|
|
1296
|
+
return `https://${normalizePublicHostname(hostname)}`;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function tailscaleFunnelHttpsPort(publicBase) {
|
|
1300
|
+
const port = new URL(publicBase).port || '443';
|
|
1301
|
+
if (!['443', '8443', '10000'].includes(port)) {
|
|
1302
|
+
throw new Error('Tailscale Funnel HTTPS port must be 443, 8443, or 10000.');
|
|
1303
|
+
}
|
|
1304
|
+
return port;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function readTokenFile(filePath) {
|
|
1308
|
+
const resolved = path.resolve(expandHome(filePath));
|
|
1309
|
+
return fs.readFileSync(resolved, 'utf8').trim();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function normalizeMode(args) {
|
|
1313
|
+
const mode = args.mode ?? process.env.CODEXFLOW_MODE ?? 'agent';
|
|
1314
|
+
if (!['agent', 'handoff', 'pro'].includes(mode)) {
|
|
1315
|
+
throw new Error('--mode must be agent, handoff, or pro');
|
|
1316
|
+
}
|
|
1317
|
+
return mode;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function copyToClipboard(text) {
|
|
1321
|
+
const attempts = [];
|
|
1322
|
+
if (process.platform === 'darwin') attempts.push(['pbcopy', []]);
|
|
1323
|
+
else if (process.platform === 'win32') attempts.push(['cmd', ['/c', 'clip']]);
|
|
1324
|
+
else {
|
|
1325
|
+
attempts.push(['wl-copy', []]);
|
|
1326
|
+
attempts.push(['xclip', ['-selection', 'clipboard']]);
|
|
1327
|
+
attempts.push(['xsel', ['--clipboard', '--input']]);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
for (const [command, args] of attempts) {
|
|
1331
|
+
const exists = command === 'cmd' || commandExists(command);
|
|
1332
|
+
if (!exists) continue;
|
|
1333
|
+
const result = spawnSync(command, args, {
|
|
1334
|
+
input: text,
|
|
1335
|
+
encoding: 'utf8',
|
|
1336
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
1337
|
+
shell: false
|
|
1338
|
+
});
|
|
1339
|
+
if (result.status === 0) return { ok: true, command };
|
|
1340
|
+
}
|
|
1341
|
+
return { ok: false, command: '' };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function openUrl(url) {
|
|
1345
|
+
const command =
|
|
1346
|
+
process.platform === 'darwin'
|
|
1347
|
+
? ['open', [url]]
|
|
1348
|
+
: process.platform === 'win32'
|
|
1349
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
1350
|
+
: ['xdg-open', [url]];
|
|
1351
|
+
const [bin, args] = command;
|
|
1352
|
+
if (bin !== 'cmd' && !commandExists(bin)) return false;
|
|
1353
|
+
const result = spawnSync(bin, args, { stdio: 'ignore', shell: false });
|
|
1354
|
+
return result.status === 0;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function waitForProcessExit(child) {
|
|
1358
|
+
return new Promise((resolve) => {
|
|
1359
|
+
child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
async function waitForPublicHealth(publicBase, token, tunnelChild, tunnelLabel = 'tunnel') {
|
|
1364
|
+
const health = waitForHealth(`${publicBase}/healthz`, token, 60000);
|
|
1365
|
+
const exit = waitForProcessExit(tunnelChild).then(({ code, signal }) => {
|
|
1366
|
+
throw new Error(`${tunnelLabel} exited before ${publicBase}/healthz was reachable, code=${code} signal=${signal}`);
|
|
1367
|
+
});
|
|
1368
|
+
return Promise.race([health, exit]);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function isSubpath(child, parent) {
|
|
1372
|
+
const relative = path.relative(parent, child);
|
|
1373
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function contextDirFromArgs(args) {
|
|
1377
|
+
return args.contextDir ?? process.env.CODEXFLOW_CONTEXT_DIR ?? '.ai-bridge';
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function resolveWorkspaceFile(root, relativePath) {
|
|
1381
|
+
const absPath = path.resolve(root, relativePath);
|
|
1382
|
+
if (!isSubpath(absPath, root)) {
|
|
1383
|
+
throw new Error(`Path escapes workspace root: ${relativePath}`);
|
|
1384
|
+
}
|
|
1385
|
+
return absPath;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function readTextFileBounded(filePath, maxBytes) {
|
|
1389
|
+
const stat = fs.statSync(filePath);
|
|
1390
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${filePath}`);
|
|
1391
|
+
if (stat.size > maxBytes) throw new Error(`File is too large (${stat.size} bytes). Limit: ${maxBytes} bytes.`);
|
|
1392
|
+
const sample = fs.readFileSync(filePath, { encoding: null });
|
|
1393
|
+
if (sample.includes(0)) throw new Error(`Refusing to read binary file: ${filePath}`);
|
|
1394
|
+
return sample.toString('utf8');
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function numberOption(value, fallback, min, max) {
|
|
1398
|
+
const parsed = Number(value ?? fallback);
|
|
1399
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
1400
|
+
return Math.max(min, Math.min(max, Math.floor(parsed)));
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function handoffMaxReadBytes() {
|
|
1404
|
+
return numberOption(process.env.CODEXFLOW_MAX_READ_BYTES, 180_000, 4_000, 2_000_000);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function shellCommandPreview(parts) {
|
|
1408
|
+
return parts.map((part) => {
|
|
1409
|
+
const text = String(part);
|
|
1410
|
+
if (/^[A-Za-z0-9_./:@=+-]+$/.test(text)) return text;
|
|
1411
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
1412
|
+
}).join(' ');
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function redactForLog(value) {
|
|
1416
|
+
return String(value)
|
|
1417
|
+
.replace(/\bsk-[A-Za-z0-9_-]{10,}\b/g, '[REDACTED_SECRET]')
|
|
1418
|
+
.replace(/\b(?:sk-ant-[A-Za-z0-9_-]{10,}|gh[opsru]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9_-]{20,})\b/g, '[REDACTED_SECRET]')
|
|
1419
|
+
.replace(/\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi, '$1[REDACTED_SECRET]')
|
|
1420
|
+
.replace(/([?&](?:codexflow_token|token|access_token|auth_token|api[_-]?key)=)[^&\s"'`<>]{8,}/gi, '$1[REDACTED_SECRET]')
|
|
1421
|
+
.replace(/(["']?[A-Za-z0-9_]{0,64}(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY)[A-Za-z0-9_]{0,64}["']?\s*:\s*)(?:"[^"\r\n]{12,512}"|'[^'\r\n]{12,512}'|`[^`\r\n]{12,512}`|[A-Za-z0-9_./+=-]{20,512})/gi, '$1[REDACTED_SECRET]')
|
|
1422
|
+
.replace(/\b[A-Za-z0-9_]{0,64}(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY)[A-Za-z0-9_]{0,64}\s*=\s*(?:"[^"\r\n]{12,512}"|'[^'\r\n]{12,512}'|`[^`\r\n]{12,512}`|[A-Za-z0-9_./+=-]{20,512})/gi, (match) => {
|
|
1423
|
+
const index = match.indexOf('=');
|
|
1424
|
+
return index < 0 ? '[REDACTED_SECRET]' : `${match.slice(0, index).trimEnd()}= [REDACTED_SECRET]`;
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function redactEnvObject(env) {
|
|
1429
|
+
const out = {};
|
|
1430
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1431
|
+
out[key] = /(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY)/i.test(key)
|
|
1432
|
+
? '<redacted>'
|
|
1433
|
+
: redactForLog(String(value));
|
|
1434
|
+
}
|
|
1435
|
+
return out;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function trimBytes(value, maxBytes) {
|
|
1439
|
+
const redacted = redactForLog(value);
|
|
1440
|
+
const buffer = Buffer.from(redacted, 'utf8');
|
|
1441
|
+
if (buffer.byteLength <= maxBytes) return { text: redacted, truncated: false };
|
|
1442
|
+
return {
|
|
1443
|
+
text: `${buffer.subarray(0, maxBytes).toString('utf8')}\n...[output truncated to ${maxBytes} bytes]`,
|
|
1444
|
+
truncated: true
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function splitCommandTemplate(input) {
|
|
1449
|
+
const tokens = [];
|
|
1450
|
+
let current = '';
|
|
1451
|
+
let quote = '';
|
|
1452
|
+
let tokenStarted = false;
|
|
1453
|
+
const text = String(input);
|
|
1454
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
1455
|
+
const char = text[i];
|
|
1456
|
+
if (char === '\\') {
|
|
1457
|
+
const next = text[i + 1];
|
|
1458
|
+
tokenStarted = true;
|
|
1459
|
+
if (next && (next === quote || next === '\\' || (!quote && /\s|["']/.test(next)))) {
|
|
1460
|
+
current += next;
|
|
1461
|
+
i += 1;
|
|
1462
|
+
} else {
|
|
1463
|
+
current += char;
|
|
1464
|
+
}
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (quote) {
|
|
1468
|
+
if (char === quote) quote = '';
|
|
1469
|
+
else {
|
|
1470
|
+
tokenStarted = true;
|
|
1471
|
+
current += char;
|
|
1472
|
+
}
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
if (char === '"' || char === "'") {
|
|
1476
|
+
quote = char;
|
|
1477
|
+
tokenStarted = true;
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
if (/\s/.test(char)) {
|
|
1481
|
+
if (tokenStarted) {
|
|
1482
|
+
tokens.push(current);
|
|
1483
|
+
current = '';
|
|
1484
|
+
tokenStarted = false;
|
|
1485
|
+
}
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
tokenStarted = true;
|
|
1489
|
+
current += char;
|
|
1490
|
+
}
|
|
1491
|
+
if (quote) throw new Error('Custom command has an unterminated quote.');
|
|
1492
|
+
if (tokenStarted) tokens.push(current);
|
|
1493
|
+
return tokens;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function applyCommandTemplate(value, replacements) {
|
|
1497
|
+
return String(value).replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, (_, key) => replacements[key] ?? '');
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function buildExecutorCommand(args, root, planPath, planText) {
|
|
1501
|
+
const agent = String(args.agent ?? 'opencode').trim().toLowerCase();
|
|
1502
|
+
const model = String(args.model ?? process.env.CODEXFLOW_AGENT_MODEL ?? '').trim();
|
|
1503
|
+
const replacements = {
|
|
1504
|
+
model,
|
|
1505
|
+
plan_file: planPath,
|
|
1506
|
+
plan_text: planText,
|
|
1507
|
+
root
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
if (args.command) {
|
|
1511
|
+
const template = String(args.command);
|
|
1512
|
+
if (!/\{\{\s*(plan_file|plan_text)\s*\}\}/.test(template)) {
|
|
1513
|
+
throw new Error('Custom --command must include {{plan_file}} or {{plan_text}} so the agent receives the handoff.');
|
|
1514
|
+
}
|
|
1515
|
+
const parts = splitCommandTemplate(template).map((part) => applyCommandTemplate(part, replacements));
|
|
1516
|
+
const displayParts = splitCommandTemplate(template).map((part) => applyCommandTemplate(part, { ...replacements, plan_text: '<plan_text>' }));
|
|
1517
|
+
if (!parts.length) throw new Error('Custom --command is empty.');
|
|
1518
|
+
return { agent, model, command: parts[0], args: parts.slice(1), displayArgs: displayParts.slice(1), custom: true };
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
if (agent === 'opencode') {
|
|
1522
|
+
return {
|
|
1523
|
+
agent,
|
|
1524
|
+
model,
|
|
1525
|
+
command: 'opencode',
|
|
1526
|
+
args: ['run', ...(model ? ['--model', model] : []), planText],
|
|
1527
|
+
displayArgs: ['run', ...(model ? ['--model', model] : []), '<plan_text>'],
|
|
1528
|
+
custom: false
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
if (agent === 'pi') {
|
|
1532
|
+
return {
|
|
1533
|
+
agent,
|
|
1534
|
+
model,
|
|
1535
|
+
command: 'pi',
|
|
1536
|
+
args: [...(model ? ['--model', model] : []), '-p', planText],
|
|
1537
|
+
displayArgs: [...(model ? ['--model', model] : []), '-p', '<plan_text>'],
|
|
1538
|
+
custom: false
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
if (agent === 'codex') {
|
|
1542
|
+
const codexLastMessagePath = path.join(path.dirname(planPath), 'codex-last-message.md');
|
|
1543
|
+
const relativePlanPath = path.relative(root, planPath) || planPath;
|
|
1544
|
+
const codexPrompt = [
|
|
1545
|
+
`Read the handoff plan at ${relativePlanPath} and execute it in this workspace.`,
|
|
1546
|
+
'Keep changes scoped to that plan.',
|
|
1547
|
+
'Do not modify .ai-bridge/current-plan.md.',
|
|
1548
|
+
'When finished, summarize changed files and verification.'
|
|
1549
|
+
].join(' ');
|
|
1550
|
+
return {
|
|
1551
|
+
agent,
|
|
1552
|
+
model,
|
|
1553
|
+
command: resolveCodexCommand(),
|
|
1554
|
+
args: [
|
|
1555
|
+
'exec',
|
|
1556
|
+
'--ephemeral',
|
|
1557
|
+
'--sandbox',
|
|
1558
|
+
'workspace-write',
|
|
1559
|
+
'-c',
|
|
1560
|
+
'approval_policy="never"',
|
|
1561
|
+
'--output-last-message',
|
|
1562
|
+
codexLastMessagePath,
|
|
1563
|
+
...(model ? ['--model', model] : []),
|
|
1564
|
+
codexPrompt
|
|
1565
|
+
],
|
|
1566
|
+
displayArgs: [
|
|
1567
|
+
'exec',
|
|
1568
|
+
'--ephemeral',
|
|
1569
|
+
'--sandbox',
|
|
1570
|
+
'workspace-write',
|
|
1571
|
+
'-c',
|
|
1572
|
+
'approval_policy="never"',
|
|
1573
|
+
'--output-last-message',
|
|
1574
|
+
path.relative(root, codexLastMessagePath),
|
|
1575
|
+
...(model ? ['--model', model] : []),
|
|
1576
|
+
`<read ${relativePlanPath}>`
|
|
1577
|
+
],
|
|
1578
|
+
custom: false
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
if (agent === 'custom') {
|
|
1582
|
+
throw new Error('Custom agent execution requires --command.');
|
|
1583
|
+
}
|
|
1584
|
+
throw new Error(`Unsupported --agent ${agent}. Use opencode, pi, codex, or custom with --command.`);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function executorCommandPreview(commandInfo) {
|
|
1588
|
+
return shellCommandPreview([commandInfo.command, ...(commandInfo.displayArgs ?? commandInfo.args)]);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function quoteWindowsCmdArg(value) {
|
|
1592
|
+
const text = String(value).replace(/\r?\n/g, ' ');
|
|
1593
|
+
if (!text) return '""';
|
|
1594
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function processInvocation(command, args) {
|
|
1598
|
+
if (!isWindowsBatchFile(command)) return { command, args };
|
|
1599
|
+
const commandLine = ['call', quoteWindowsCmdArg(command), ...args.map(quoteWindowsCmdArg)].join(' ');
|
|
1600
|
+
return {
|
|
1601
|
+
command: process.env.ComSpec || 'cmd.exe',
|
|
1602
|
+
args: ['/d', '/s', '/c', commandLine],
|
|
1603
|
+
windowsVerbatimArguments: true
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function runProcessCaptured(command, args, options) {
|
|
1608
|
+
const timeoutMs = options.timeoutMs;
|
|
1609
|
+
const maxOutputBytes = options.maxOutputBytes;
|
|
1610
|
+
const retainedOutputBytes = maxOutputBytes + 1;
|
|
1611
|
+
const started = Date.now();
|
|
1612
|
+
return new Promise((resolve) => {
|
|
1613
|
+
const invocation = processInvocation(command, args);
|
|
1614
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
1615
|
+
cwd: options.cwd,
|
|
1616
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
1617
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1618
|
+
shell: false,
|
|
1619
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments
|
|
1620
|
+
});
|
|
1621
|
+
let stdout = '';
|
|
1622
|
+
let stderr = '';
|
|
1623
|
+
let timedOut = false;
|
|
1624
|
+
let closed = false;
|
|
1625
|
+
const appendBounded = (current, chunk) => {
|
|
1626
|
+
if (Buffer.byteLength(current, 'utf8') > retainedOutputBytes) return current;
|
|
1627
|
+
const next = current + String(chunk);
|
|
1628
|
+
const buffer = Buffer.from(next, 'utf8');
|
|
1629
|
+
return buffer.byteLength > retainedOutputBytes
|
|
1630
|
+
? buffer.subarray(0, retainedOutputBytes).toString('utf8')
|
|
1631
|
+
: next;
|
|
1632
|
+
};
|
|
1633
|
+
const timer = setTimeout(() => {
|
|
1634
|
+
timedOut = true;
|
|
1635
|
+
child.kill('SIGTERM');
|
|
1636
|
+
setTimeout(() => {
|
|
1637
|
+
if (!closed) child.kill('SIGKILL');
|
|
1638
|
+
}, 1500).unref();
|
|
1639
|
+
}, timeoutMs);
|
|
1640
|
+
timer.unref();
|
|
1641
|
+
|
|
1642
|
+
child.stdout.on('data', (chunk) => {
|
|
1643
|
+
stdout = appendBounded(stdout, chunk);
|
|
1644
|
+
});
|
|
1645
|
+
child.stderr.on('data', (chunk) => {
|
|
1646
|
+
stderr = appendBounded(stderr, chunk);
|
|
1647
|
+
});
|
|
1648
|
+
child.on('error', (error) => {
|
|
1649
|
+
clearTimeout(timer);
|
|
1650
|
+
resolve({
|
|
1651
|
+
exitCode: 127,
|
|
1652
|
+
signal: null,
|
|
1653
|
+
durationMs: Date.now() - started,
|
|
1654
|
+
timedOut,
|
|
1655
|
+
stdout: '',
|
|
1656
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1657
|
+
spawnError: true
|
|
1658
|
+
});
|
|
1659
|
+
});
|
|
1660
|
+
child.on('close', (exitCode, signal) => {
|
|
1661
|
+
closed = true;
|
|
1662
|
+
clearTimeout(timer);
|
|
1663
|
+
const out = trimBytes(stdout, maxOutputBytes);
|
|
1664
|
+
const err = trimBytes(`${stderr}${timedOut ? `\n[codexflow] Command timed out after ${timeoutMs} ms.` : ''}`, maxOutputBytes);
|
|
1665
|
+
resolve({
|
|
1666
|
+
exitCode,
|
|
1667
|
+
signal,
|
|
1668
|
+
durationMs: Date.now() - started,
|
|
1669
|
+
timedOut,
|
|
1670
|
+
stdout: out.text,
|
|
1671
|
+
stderr: err.text,
|
|
1672
|
+
truncated: out.truncated || err.truncated,
|
|
1673
|
+
spawnError: false
|
|
1674
|
+
});
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function readGitDiff(root, maxBytes) {
|
|
1680
|
+
const result = spawnSync('git', ['diff', '--no-ext-diff', '--'], {
|
|
1681
|
+
cwd: root,
|
|
1682
|
+
encoding: 'utf8',
|
|
1683
|
+
maxBuffer: Math.max(maxBytes * 2, 1_000_000),
|
|
1684
|
+
shell: false
|
|
1685
|
+
});
|
|
1686
|
+
if (result.status !== 0) {
|
|
1687
|
+
const reason = result.stderr || result.stdout || `git diff exited ${result.status}`;
|
|
1688
|
+
return `# git diff unavailable\n\n${redactForLog(reason).trim()}\n`;
|
|
1689
|
+
}
|
|
1690
|
+
const diff = result.stdout || '';
|
|
1691
|
+
if (!diff.trim()) return '';
|
|
1692
|
+
return trimBytes(diff, maxBytes).text;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function readGitStatus(root, maxBytes) {
|
|
1696
|
+
const result = spawnSync('git', ['status', '--short'], {
|
|
1697
|
+
cwd: root,
|
|
1698
|
+
encoding: 'utf8',
|
|
1699
|
+
maxBuffer: Math.max(maxBytes * 2, 1_000_000),
|
|
1700
|
+
shell: false
|
|
1701
|
+
});
|
|
1702
|
+
if (result.status !== 0) {
|
|
1703
|
+
const reason = result.stderr || result.stdout || `git status exited ${result.status}`;
|
|
1704
|
+
return `# git status unavailable\n\n${redactForLog(reason).trim()}\n`;
|
|
1705
|
+
}
|
|
1706
|
+
const status = result.stdout || '';
|
|
1707
|
+
return status.trim() ? trimBytes(status, maxBytes).text : '';
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function codeBlock(label, value) {
|
|
1711
|
+
return `## ${label}\n\n\`\`\`text\n${String(value || '').replace(/```/g, '`\\`\\`') || '(empty)'}\n\`\`\`\n`;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function writeExecutionOutputs(root, contextDir, commandInfo, result, diffText, gitStatusText) {
|
|
1715
|
+
const bridgeDir = resolveWorkspaceFile(root, contextDir);
|
|
1716
|
+
fs.mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
1717
|
+
const statusPath = path.join(bridgeDir, 'agent-status.md');
|
|
1718
|
+
const diffPath = path.join(bridgeDir, 'implementation-diff.patch');
|
|
1719
|
+
const logPath = path.join(bridgeDir, 'execution-log.jsonl');
|
|
1720
|
+
const commandText = executorCommandPreview(commandInfo);
|
|
1721
|
+
const status = [
|
|
1722
|
+
'# Agent Execution Status',
|
|
1723
|
+
'',
|
|
1724
|
+
`Updated: ${new Date().toISOString()}`,
|
|
1725
|
+
`Agent: ${commandInfo.agent}`,
|
|
1726
|
+
commandInfo.model ? `Model: ${commandInfo.model}` : '',
|
|
1727
|
+
`Command: ${commandText}`,
|
|
1728
|
+
`Exit code: ${result.exitCode ?? 'null'}`,
|
|
1729
|
+
result.signal ? `Signal: ${result.signal}` : '',
|
|
1730
|
+
`Timed out: ${result.timedOut ? 'yes' : 'no'}`,
|
|
1731
|
+
`Duration: ${result.durationMs} ms`,
|
|
1732
|
+
`Diff path: ${path.posix.join(contextDir, 'implementation-diff.patch')}`,
|
|
1733
|
+
`Execution log: ${path.posix.join(contextDir, 'execution-log.jsonl')}`,
|
|
1734
|
+
'',
|
|
1735
|
+
codeBlock('Git status excerpt', gitStatusText),
|
|
1736
|
+
'',
|
|
1737
|
+
codeBlock('Stdout excerpt', result.stdout),
|
|
1738
|
+
codeBlock('Stderr excerpt', result.stderr)
|
|
1739
|
+
].filter(Boolean).join('\n');
|
|
1740
|
+
fs.writeFileSync(statusPath, status, { mode: 0o600 });
|
|
1741
|
+
fs.writeFileSync(diffPath, diffText || '', { mode: 0o600 });
|
|
1742
|
+
const logEvent = {
|
|
1743
|
+
ts: new Date().toISOString(),
|
|
1744
|
+
event: 'execute_handoff',
|
|
1745
|
+
agent: commandInfo.agent,
|
|
1746
|
+
model: commandInfo.model || undefined,
|
|
1747
|
+
command: commandText,
|
|
1748
|
+
exit_code: result.exitCode,
|
|
1749
|
+
signal: result.signal,
|
|
1750
|
+
timed_out: result.timedOut,
|
|
1751
|
+
duration_ms: result.durationMs,
|
|
1752
|
+
stdout_excerpt: result.stdout,
|
|
1753
|
+
stderr_excerpt: result.stderr,
|
|
1754
|
+
git_status_excerpt: gitStatusText || undefined,
|
|
1755
|
+
diff_path: path.posix.join(contextDir, 'implementation-diff.patch'),
|
|
1756
|
+
status_path: path.posix.join(contextDir, 'agent-status.md')
|
|
1757
|
+
};
|
|
1758
|
+
fs.appendFileSync(logPath, `${JSON.stringify(logEvent)}\n`, { mode: 0o600 });
|
|
1759
|
+
return { statusPath, diffPath, logPath };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
async function confirmLocalExecution(args, root, commandInfo) {
|
|
1763
|
+
if (args.yes) return true;
|
|
1764
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1765
|
+
throw new Error('Use --yes to execute a local handoff in non-interactive shells, or use --dry-run to preview.');
|
|
1766
|
+
}
|
|
1767
|
+
printBox('Confirm local execution', [
|
|
1768
|
+
labelValue('Workspace', root),
|
|
1769
|
+
labelValue('Agent', commandInfo.agent),
|
|
1770
|
+
...(commandInfo.model ? [labelValue('Model', commandInfo.model)] : []),
|
|
1771
|
+
labelValue('Command', executorCommandPreview(commandInfo)),
|
|
1772
|
+
'This runs a local process in the workspace. CodexFlow will collect status, logs, and git diff into .ai-bridge.'
|
|
1773
|
+
]);
|
|
1774
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1775
|
+
try {
|
|
1776
|
+
const answer = await ask(rl, 'Run this local agent now?', 'no');
|
|
1777
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
1778
|
+
} finally {
|
|
1779
|
+
rl.close();
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function loadHandoffExecution(args) {
|
|
1784
|
+
const root = realDir(args.root ?? process.env.CODEXFLOW_ROOT ?? process.cwd());
|
|
1785
|
+
const contextDir = contextDirFromArgs(args);
|
|
1786
|
+
const bridgeDir = resolveWorkspaceFile(root, contextDir);
|
|
1787
|
+
const planPath = path.join(bridgeDir, 'current-plan.md');
|
|
1788
|
+
const maxReadBytes = handoffMaxReadBytes();
|
|
1789
|
+
const maxOutputBytes = numberOption(args.maxOutputBytes ?? process.env.CODEXFLOW_MAX_OUTPUT_BYTES, 120_000, 4_000, 2_000_000);
|
|
1790
|
+
const timeoutMs = numberOption(args.timeoutMs ?? args.timeout, 600_000, 1_000, 24 * 60 * 60_000);
|
|
1791
|
+
if (!fs.existsSync(planPath)) {
|
|
1792
|
+
throw new Error(`No handoff plan found at ${path.relative(root, planPath)}. Ask ChatGPT to call handoff_to_agent first.`);
|
|
1793
|
+
}
|
|
1794
|
+
const planText = readTextFileBounded(planPath, maxReadBytes);
|
|
1795
|
+
const commandInfo = buildExecutorCommand(args, root, planPath, planText);
|
|
1796
|
+
const commandText = executorCommandPreview(commandInfo);
|
|
1797
|
+
return {
|
|
1798
|
+
root,
|
|
1799
|
+
contextDir,
|
|
1800
|
+
bridgeDir,
|
|
1801
|
+
planPath,
|
|
1802
|
+
planText,
|
|
1803
|
+
commandInfo,
|
|
1804
|
+
commandText,
|
|
1805
|
+
maxOutputBytes,
|
|
1806
|
+
timeoutMs
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function printHandoffDryRun(request, title = 'CodexFlow execute-handoff dry run') {
|
|
1811
|
+
printBox(title, [
|
|
1812
|
+
labelValue('Workspace', request.root),
|
|
1813
|
+
labelValue('Plan', path.relative(request.root, request.planPath)),
|
|
1814
|
+
labelValue('Agent', request.commandInfo.agent),
|
|
1815
|
+
...(request.commandInfo.model ? [labelValue('Model', request.commandInfo.model)] : []),
|
|
1816
|
+
labelValue('Command', request.commandText),
|
|
1817
|
+
'No command was executed and no .ai-bridge result files were changed.'
|
|
1818
|
+
]);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
async function executeHandoffRequest(request, args, options = {}) {
|
|
1822
|
+
const confirmed = options.skipConfirmation ? true : await confirmLocalExecution(args, request.root, request.commandInfo);
|
|
1823
|
+
if (!confirmed) {
|
|
1824
|
+
statusLine('warn', 'Execution cancelled.');
|
|
1825
|
+
return { cancelled: true, result: null, outputs: null };
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
if (!commandAvailableFromRoot(request.commandInfo.command, request.root)) {
|
|
1829
|
+
throw new Error(`${request.commandInfo.command} was not found. Install it, add it to PATH, pass an absolute path, or use --command.`);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const iteration = Number.isFinite(options.iteration) ? options.iteration : 1;
|
|
1833
|
+
const runPlanHash = planHash(request.planText);
|
|
1834
|
+
const startedAt = new Date().toISOString();
|
|
1835
|
+
writeHandoffRunState(request.root, request.contextDir, {
|
|
1836
|
+
state: 'running',
|
|
1837
|
+
iteration,
|
|
1838
|
+
started_at: startedAt,
|
|
1839
|
+
finished_at: null,
|
|
1840
|
+
plan_hash: runPlanHash,
|
|
1841
|
+
executor: request.commandInfo.agent,
|
|
1842
|
+
model: request.commandInfo.model || undefined,
|
|
1843
|
+
pid: process.pid
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
statusLine('wait', `Running ${request.commandInfo.agent}: ${request.commandText}`);
|
|
1847
|
+
const result = await runProcessCaptured(request.commandInfo.command, request.commandInfo.args, {
|
|
1848
|
+
cwd: request.root,
|
|
1849
|
+
timeoutMs: request.timeoutMs,
|
|
1850
|
+
maxOutputBytes: request.maxOutputBytes
|
|
1851
|
+
});
|
|
1852
|
+
const diffText = readGitDiffExcludingContext(request.root, request.contextDir, request.maxOutputBytes);
|
|
1853
|
+
const gitStatusText = readGitStatus(request.root, request.maxOutputBytes);
|
|
1854
|
+
const outputs = writeExecutionOutputs(request.root, request.contextDir, request.commandInfo, result, diffText, gitStatusText);
|
|
1855
|
+
|
|
1856
|
+
const runState = result.timedOut ? 'timed_out' : (result.exitCode === 0 ? 'completed' : 'failed');
|
|
1857
|
+
const testsAbsPath = path.join(request.bridgeDir, 'loop-tests.txt');
|
|
1858
|
+
writeHandoffRunState(request.root, request.contextDir, {
|
|
1859
|
+
state: runState,
|
|
1860
|
+
iteration,
|
|
1861
|
+
started_at: startedAt,
|
|
1862
|
+
finished_at: new Date().toISOString(),
|
|
1863
|
+
plan_hash: runPlanHash,
|
|
1864
|
+
executor: request.commandInfo.agent,
|
|
1865
|
+
model: request.commandInfo.model || undefined,
|
|
1866
|
+
exit_code: result.exitCode ?? null,
|
|
1867
|
+
timed_out: Boolean(result.timedOut),
|
|
1868
|
+
duration_ms: result.durationMs,
|
|
1869
|
+
status_file: path.posix.join(request.contextDir, 'agent-status.md'),
|
|
1870
|
+
diff_file: path.posix.join(request.contextDir, 'implementation-diff.patch'),
|
|
1871
|
+
log_file: path.posix.join(request.contextDir, 'execution-log.jsonl'),
|
|
1872
|
+
...(fs.existsSync(testsAbsPath) ? { tests_file: path.posix.join(request.contextDir, 'loop-tests.txt') } : {})
|
|
1873
|
+
});
|
|
1874
|
+
statusLine(result.exitCode === 0 ? 'ok' : 'warn', `Agent exited with code ${result.exitCode ?? 'null'}${result.signal ? ` signal=${result.signal}` : ''}`);
|
|
1875
|
+
console.log(`Status: ${path.relative(request.root, outputs.statusPath)}`);
|
|
1876
|
+
console.log(`Diff: ${path.relative(request.root, outputs.diffPath)}`);
|
|
1877
|
+
console.log(`Log: ${path.relative(request.root, outputs.logPath)}`);
|
|
1878
|
+
return { cancelled: false, result, outputs };
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
async function runExecuteHandoff(argv) {
|
|
1882
|
+
const args = parseArgs(argv);
|
|
1883
|
+
if (args.help) {
|
|
1884
|
+
usage();
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
const request = loadHandoffExecution(args);
|
|
1888
|
+
|
|
1889
|
+
if (args.dryRun) {
|
|
1890
|
+
printHandoffDryRun(request);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
const execution = await executeHandoffRequest(request, args);
|
|
1895
|
+
if (execution.result && execution.result.exitCode !== 0) process.exitCode = execution.result.exitCode ?? 1;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function planHash(planText) {
|
|
1899
|
+
return createHash('sha256').update(planText).digest('hex');
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function isScaffoldedHandoffPlan(planText) {
|
|
1903
|
+
return String(planText).trim() === '# Current Plan\n\nNo plan written yet.';
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function readWatchState(statePath) {
|
|
1907
|
+
try {
|
|
1908
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
1909
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
1910
|
+
} catch {
|
|
1911
|
+
return {};
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function writeWatchState(statePath, state) {
|
|
1916
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
1917
|
+
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function handoffRunStatePath(root, contextDir) {
|
|
1921
|
+
return resolveWorkspaceFile(root, path.posix.join(contextDir, 'handoff-run-state.json'));
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// Machine-readable lifecycle state for an in-flight or completed handoff run.
|
|
1925
|
+
// ChatGPT-side tooling (the read-only wait_for_handoff MCP tool) polls this file
|
|
1926
|
+
// instead of inferring run state from markdown/log artifacts.
|
|
1927
|
+
function writeHandoffRunState(root, contextDir, state) {
|
|
1928
|
+
const statePath = handoffRunStatePath(root, contextDir);
|
|
1929
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
1930
|
+
const payload = { version: 1, updated_at: new Date().toISOString(), ...state };
|
|
1931
|
+
fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function appendBridgeLog(root, contextDir, event) {
|
|
1935
|
+
const bridgeDir = resolveWorkspaceFile(root, contextDir);
|
|
1936
|
+
fs.mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
1937
|
+
const logPath = path.join(bridgeDir, 'execution-log.jsonl');
|
|
1938
|
+
fs.appendFileSync(logPath, `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`, { mode: 0o600 });
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function waitForStablePlan(planPath, debounceMs) {
|
|
1942
|
+
try {
|
|
1943
|
+
const before = fs.statSync(planPath);
|
|
1944
|
+
await sleep(debounceMs);
|
|
1945
|
+
const after = fs.statSync(planPath);
|
|
1946
|
+
return before.isFile() && after.isFile() && before.size === after.size && before.mtimeMs === after.mtimeMs;
|
|
1947
|
+
} catch {
|
|
1948
|
+
return false;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
async function confirmWatchHandoff(args, root) {
|
|
1953
|
+
if (args.yes || args.noConfirm) return true;
|
|
1954
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1955
|
+
throw new Error('Use --yes to start watch-handoff in non-interactive shells.');
|
|
1956
|
+
}
|
|
1957
|
+
printBox('Confirm handoff watcher', [
|
|
1958
|
+
labelValue('Workspace', root),
|
|
1959
|
+
labelValue('Agent', args.agent ?? 'opencode'),
|
|
1960
|
+
...(args.model ? [labelValue('Model', args.model)] : []),
|
|
1961
|
+
'This starts a local-only watcher. Each new .ai-bridge/current-plan.md hash runs through the configured local agent.',
|
|
1962
|
+
'ChatGPT only writes the handoff plan; this terminal-owned process performs execution.'
|
|
1963
|
+
]);
|
|
1964
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1965
|
+
try {
|
|
1966
|
+
const answer = await ask(rl, 'Start automatic local handoff execution?', 'no');
|
|
1967
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
1968
|
+
} finally {
|
|
1969
|
+
rl.close();
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
async function runWatchHandoff(argv) {
|
|
1974
|
+
const args = parseArgs(argv);
|
|
1975
|
+
if (args.help) {
|
|
1976
|
+
usage();
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
const root = realDir(args.root ?? process.env.CODEXFLOW_ROOT ?? process.cwd());
|
|
1980
|
+
const contextDir = contextDirFromArgs(args);
|
|
1981
|
+
const bridgeDir = resolveWorkspaceFile(root, contextDir);
|
|
1982
|
+
const planPath = path.join(bridgeDir, 'current-plan.md');
|
|
1983
|
+
const statePath = resolveWorkspaceFile(root, args.stateFile ?? path.posix.join(contextDir, 'watch-handoff-state.json'));
|
|
1984
|
+
const pollIntervalMs = numberOption(args.pollIntervalMs ?? args.pollInterval, 2000, 250, 60_000);
|
|
1985
|
+
const debounceMs = numberOption(args.debounceMs, 500, 0, 30_000);
|
|
1986
|
+
let state = readWatchState(statePath);
|
|
1987
|
+
let lastDryRunHash = state.lastPlanHash ?? '';
|
|
1988
|
+
let lastSkippedHash = '';
|
|
1989
|
+
let stopped = false;
|
|
1990
|
+
|
|
1991
|
+
if (!args.dryRun) {
|
|
1992
|
+
const approved = await confirmWatchHandoff(args, root);
|
|
1993
|
+
if (!approved) {
|
|
1994
|
+
statusLine('warn', 'Watcher cancelled.');
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
printBox('CodexFlow watch-handoff', [
|
|
2000
|
+
labelValue('Workspace', root),
|
|
2001
|
+
labelValue('Plan', path.relative(root, planPath)),
|
|
2002
|
+
labelValue('State', path.relative(root, statePath)),
|
|
2003
|
+
labelValue('Agent', args.agent ?? 'opencode'),
|
|
2004
|
+
...(args.model ? [labelValue('Model', args.model)] : []),
|
|
2005
|
+
labelValue('Poll', `${pollIntervalMs} ms`),
|
|
2006
|
+
labelValue('Debounce', `${debounceMs} ms`),
|
|
2007
|
+
args.once ? 'Mode: check once and exit.' : 'Mode: watching until Ctrl+C.'
|
|
2008
|
+
]);
|
|
2009
|
+
|
|
2010
|
+
const stop = () => {
|
|
2011
|
+
stopped = true;
|
|
2012
|
+
statusLine('warn', 'Stopping handoff watcher...');
|
|
2013
|
+
};
|
|
2014
|
+
process.once('SIGINT', stop);
|
|
2015
|
+
process.once('SIGTERM', stop);
|
|
2016
|
+
|
|
2017
|
+
while (!stopped) {
|
|
2018
|
+
if (!fs.existsSync(planPath)) {
|
|
2019
|
+
if (args.once) throw new Error(`No handoff plan found at ${path.relative(root, planPath)}.`);
|
|
2020
|
+
await sleep(pollIntervalMs);
|
|
2021
|
+
continue;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
const stable = await waitForStablePlan(planPath, debounceMs);
|
|
2025
|
+
if (!stable) {
|
|
2026
|
+
if (args.once) throw new Error(`Handoff plan did not become stable at ${path.relative(root, planPath)}.`);
|
|
2027
|
+
await sleep(pollIntervalMs);
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
const request = loadHandoffExecution({ ...args, root, contextDir });
|
|
2032
|
+
const currentHash = planHash(request.planText);
|
|
2033
|
+
if (isScaffoldedHandoffPlan(request.planText)) {
|
|
2034
|
+
if (lastSkippedHash !== currentHash) statusLine('wait', 'Ignoring scaffolded empty handoff plan.');
|
|
2035
|
+
lastSkippedHash = currentHash;
|
|
2036
|
+
if (args.once) return;
|
|
2037
|
+
await sleep(pollIntervalMs);
|
|
2038
|
+
continue;
|
|
2039
|
+
}
|
|
2040
|
+
if (state.lastPlanHash === currentHash || lastDryRunHash === currentHash) {
|
|
2041
|
+
statusLine(args.once ? 'ok' : 'wait', `No new handoff plan: ${currentHash.slice(0, 12)}`);
|
|
2042
|
+
if (args.once) return;
|
|
2043
|
+
await sleep(pollIntervalMs);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
if (args.dryRun) {
|
|
2048
|
+
printHandoffDryRun(request, 'CodexFlow watch-handoff dry run');
|
|
2049
|
+
lastDryRunHash = currentHash;
|
|
2050
|
+
if (args.once) return;
|
|
2051
|
+
await sleep(pollIntervalMs);
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
appendBridgeLog(root, contextDir, {
|
|
2056
|
+
event: 'watch_handoff_started',
|
|
2057
|
+
plan_hash: currentHash,
|
|
2058
|
+
agent: request.commandInfo.agent,
|
|
2059
|
+
model: request.commandInfo.model || undefined,
|
|
2060
|
+
plan_path: path.posix.join(contextDir, 'current-plan.md')
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
const execution = await executeHandoffRequest(request, { ...args, yes: true }, { skipConfirmation: true });
|
|
2064
|
+
const exitCode = execution.result?.exitCode ?? null;
|
|
2065
|
+
state = {
|
|
2066
|
+
lastPlanHash: currentHash,
|
|
2067
|
+
lastRanAt: new Date().toISOString(),
|
|
2068
|
+
agent: request.commandInfo.agent,
|
|
2069
|
+
model: request.commandInfo.model || undefined,
|
|
2070
|
+
exitCode,
|
|
2071
|
+
planPath: path.posix.join(contextDir, 'current-plan.md')
|
|
2072
|
+
};
|
|
2073
|
+
writeWatchState(statePath, state);
|
|
2074
|
+
appendBridgeLog(root, contextDir, {
|
|
2075
|
+
event: 'watch_handoff_finished',
|
|
2076
|
+
plan_hash: currentHash,
|
|
2077
|
+
agent: request.commandInfo.agent,
|
|
2078
|
+
model: request.commandInfo.model || undefined,
|
|
2079
|
+
exit_code: exitCode,
|
|
2080
|
+
status_path: path.posix.join(contextDir, 'agent-status.md'),
|
|
2081
|
+
diff_path: path.posix.join(contextDir, 'implementation-diff.patch')
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
if (args.once) {
|
|
2085
|
+
if (execution.result && execution.result.exitCode !== 0) process.exitCode = execution.result.exitCode ?? 1;
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
await sleep(pollIntervalMs);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function loopArtifactPaths(root, contextDir) {
|
|
2094
|
+
const bridgeDir = resolveWorkspaceFile(root, contextDir);
|
|
2095
|
+
return {
|
|
2096
|
+
bridgeDir,
|
|
2097
|
+
planPath: path.join(bridgeDir, 'current-plan.md'),
|
|
2098
|
+
statusPath: path.join(bridgeDir, 'agent-status.md'),
|
|
2099
|
+
diffPath: path.join(bridgeDir, 'implementation-diff.patch'),
|
|
2100
|
+
logPath: path.join(bridgeDir, 'execution-log.jsonl'),
|
|
2101
|
+
testsPath: path.join(bridgeDir, 'loop-tests.txt'),
|
|
2102
|
+
reviewPath: path.join(bridgeDir, 'loop-review.md'),
|
|
2103
|
+
statePath: path.join(bridgeDir, 'loop-handoff-state.json')
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function buildTemplateCommand(template, replacements, displayReplacements, label) {
|
|
2108
|
+
const parts = splitCommandTemplate(template).map((part) => applyCommandTemplate(part, replacements));
|
|
2109
|
+
const displayParts = splitCommandTemplate(template).map((part) => applyCommandTemplate(part, displayReplacements ?? replacements));
|
|
2110
|
+
if (!parts.length) throw new Error(`${label} command is empty.`);
|
|
2111
|
+
return {
|
|
2112
|
+
command: parts[0],
|
|
2113
|
+
args: parts.slice(1),
|
|
2114
|
+
displayArgs: displayParts.slice(1),
|
|
2115
|
+
displayCommand: shellCommandPreview([displayParts[0], ...displayParts.slice(1)])
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function loopTemplateReplacements(root, contextDir, iteration, paths) {
|
|
2120
|
+
return {
|
|
2121
|
+
root,
|
|
2122
|
+
context_dir: resolveWorkspaceFile(root, contextDir),
|
|
2123
|
+
iteration: String(iteration),
|
|
2124
|
+
plan_file: paths.planPath,
|
|
2125
|
+
status_file: paths.statusPath,
|
|
2126
|
+
diff_file: paths.diffPath,
|
|
2127
|
+
log_file: paths.logPath,
|
|
2128
|
+
tests_file: paths.testsPath,
|
|
2129
|
+
review_file: paths.reviewPath,
|
|
2130
|
+
state_file: paths.statePath
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
function buildReviewerCommand(args, root, contextDir, iteration, paths) {
|
|
2135
|
+
const template = String(args.reviewCommand ?? '').trim();
|
|
2136
|
+
if (!template) throw new Error('loop-handoff requires --review-command <template>.');
|
|
2137
|
+
const replacements = loopTemplateReplacements(root, contextDir, iteration, paths);
|
|
2138
|
+
return buildTemplateCommand(template, replacements, replacements, 'Review');
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function buildTestCommand(args, root, contextDir, iteration, paths) {
|
|
2142
|
+
const template = String(args.runTests ?? '').trim();
|
|
2143
|
+
if (!template) return null;
|
|
2144
|
+
const replacements = loopTemplateReplacements(root, contextDir, iteration, paths);
|
|
2145
|
+
return buildTemplateCommand(template, replacements, replacements, 'Test');
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function commandDisplay(commandInfo) {
|
|
2149
|
+
return shellCommandPreview([commandInfo.command, ...(commandInfo.displayArgs ?? commandInfo.args)]);
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function gitStatusPorcelain(root, maxBytes = 1_000_000) {
|
|
2153
|
+
return runGitText(root, ['status', '--porcelain=v1', '--untracked-files=all', '--', '.'], maxBytes);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function normalizedContextDir(contextDir) {
|
|
2157
|
+
return String(contextDir || '.ai-bridge').replace(/\\/g, '/').replace(/^\.?\//, '').replace(/\/+$/, '');
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function normalizeStatusPath(value) {
|
|
2161
|
+
return String(value || '').replace(/^"|"$/g, '').replace(/\\"/g, '"');
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
function toPosixPath(value) {
|
|
2165
|
+
return String(value || '').replace(/\\/g, '/');
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function statusLinePaths(line) {
|
|
2169
|
+
const value = String(line || '').slice(3).trim();
|
|
2170
|
+
const renameIndex = value.indexOf(' -> ');
|
|
2171
|
+
if (renameIndex < 0) return [normalizeStatusPath(value)];
|
|
2172
|
+
return [
|
|
2173
|
+
normalizeStatusPath(value.slice(0, renameIndex)),
|
|
2174
|
+
normalizeStatusPath(value.slice(renameIndex + 4))
|
|
2175
|
+
];
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
function gitWorkspacePrefix(root) {
|
|
2179
|
+
const topLevel = runGitText(root, ['rev-parse', '--show-toplevel'], 100_000).trim();
|
|
2180
|
+
return toPosixPath(path.relative(topLevel, root)).replace(/\/+$/, '');
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function workspacePathFromGitPath(filePath, workspacePrefix) {
|
|
2184
|
+
const normalized = toPosixPath(filePath).replace(/^\.?\//, '');
|
|
2185
|
+
const prefix = toPosixPath(workspacePrefix).replace(/\/+$/, '');
|
|
2186
|
+
if (!prefix) return normalized;
|
|
2187
|
+
if (normalized === prefix) return '';
|
|
2188
|
+
if (normalized.startsWith(`${prefix}/`)) return normalized.slice(prefix.length + 1);
|
|
2189
|
+
return null;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function statusLineWorkspacePaths(line, workspacePrefix) {
|
|
2193
|
+
return statusLinePaths(line)
|
|
2194
|
+
.map((filePath) => workspacePathFromGitPath(filePath, workspacePrefix))
|
|
2195
|
+
.filter((filePath) => filePath !== null && filePath !== '');
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function workspaceStatusLine(line, workspacePaths) {
|
|
2199
|
+
return `${String(line || '').slice(0, 3)}${workspacePaths.join(' -> ')}`;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function isContextStatusLine(line, contextDir, workspacePrefix = '') {
|
|
2203
|
+
const context = normalizedContextDir(contextDir);
|
|
2204
|
+
const paths = statusLineWorkspacePaths(line, workspacePrefix);
|
|
2205
|
+
return paths.length > 0 && paths.every((filePath) => filePath === context || filePath.startsWith(`${context}/`));
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function assertCleanGitStart(root, contextDir) {
|
|
2209
|
+
const status = gitStatusPorcelain(root);
|
|
2210
|
+
const workspacePrefix = gitWorkspacePrefix(root);
|
|
2211
|
+
const nonContextStatus = status.split(/\r?\n/).map((line) => {
|
|
2212
|
+
if (!line.trim()) return '';
|
|
2213
|
+
const paths = statusLineWorkspacePaths(line, workspacePrefix);
|
|
2214
|
+
if (!paths.length || paths.every(contextPathPredicate(contextDir))) return '';
|
|
2215
|
+
return workspaceStatusLine(line, paths);
|
|
2216
|
+
}).filter(Boolean).join('\n');
|
|
2217
|
+
if (nonContextStatus.trim()) {
|
|
2218
|
+
throw new Error(`--require-clean-git-start refused to start because the workspace has non-handoff changes:\n${nonContextStatus}`);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function runGitText(root, args, maxBytes) {
|
|
2223
|
+
const result = spawnSync('git', args, {
|
|
2224
|
+
cwd: root,
|
|
2225
|
+
encoding: 'utf8',
|
|
2226
|
+
maxBuffer: Math.max(maxBytes * 2, 1_000_000),
|
|
2227
|
+
shell: false
|
|
2228
|
+
});
|
|
2229
|
+
if (result.status !== 0) {
|
|
2230
|
+
const reason = result.stderr || result.stdout || `git ${args.join(' ')} exited ${result.status}`;
|
|
2231
|
+
throw new Error(redactForLog(reason).trim());
|
|
2232
|
+
}
|
|
2233
|
+
return result.stdout || '';
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
function singleLineSummary(value) {
|
|
2237
|
+
return String(value).replace(/\r/g, '\\r').replace(/\n/g, '\\n');
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function fileSha256(filePath) {
|
|
2241
|
+
const hash = createHash('sha256');
|
|
2242
|
+
const fd = fs.openSync(filePath, 'r');
|
|
2243
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
2244
|
+
try {
|
|
2245
|
+
for (;;) {
|
|
2246
|
+
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
2247
|
+
if (!bytesRead) break;
|
|
2248
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
2249
|
+
}
|
|
2250
|
+
} finally {
|
|
2251
|
+
fs.closeSync(fd);
|
|
2252
|
+
}
|
|
2253
|
+
return hash.digest('hex');
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function boundedFileFingerprint(filePath, stat) {
|
|
2257
|
+
const hash = createHash('sha256');
|
|
2258
|
+
const fd = fs.openSync(filePath, 'r');
|
|
2259
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
2260
|
+
let remaining = Math.min(stat.size, UNTRACKED_FILE_HASH_BYTES);
|
|
2261
|
+
try {
|
|
2262
|
+
while (remaining > 0) {
|
|
2263
|
+
const bytesRead = fs.readSync(fd, buffer, 0, Math.min(buffer.length, remaining), null);
|
|
2264
|
+
if (!bytesRead) break;
|
|
2265
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
2266
|
+
remaining -= bytesRead;
|
|
2267
|
+
}
|
|
2268
|
+
} finally {
|
|
2269
|
+
fs.closeSync(fd);
|
|
2270
|
+
}
|
|
2271
|
+
const hashLabel = stat.size > UNTRACKED_FILE_HASH_BYTES ? `sha256_first_${UNTRACKED_FILE_HASH_BYTES}` : 'sha256';
|
|
2272
|
+
const truncated = stat.size > UNTRACKED_FILE_HASH_BYTES ? ', fingerprint_truncated=true' : '';
|
|
2273
|
+
return `${stat.size} bytes, ${hashLabel}=${hash.digest('hex')}${truncated}`;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
function untrackedEntrySummary(root, relPath) {
|
|
2277
|
+
const absPath = path.resolve(root, relPath);
|
|
2278
|
+
try {
|
|
2279
|
+
const stat = fs.lstatSync(absPath);
|
|
2280
|
+
if (stat.isSymbolicLink()) {
|
|
2281
|
+
const target = singleLineSummary(trimBytes(fs.readlinkSync(absPath), UNTRACKED_SYMLINK_TARGET_BYTES).text);
|
|
2282
|
+
return `- ${relPath} (symlink, target=${target})`;
|
|
2283
|
+
}
|
|
2284
|
+
if (!stat.isFile()) return `- ${relPath} (${stat.isDirectory() ? 'directory' : 'non-file'})`;
|
|
2285
|
+
return `- ${relPath} (${boundedFileFingerprint(absPath, stat)})`;
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
return `- ${relPath} (unavailable: ${singleLineSummary(redactForLog(error instanceof Error ? error.message : String(error)))})`;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function untrackedFilesSummary(root, contextDir, maxBytes) {
|
|
2292
|
+
const context = normalizedContextDir(contextDir);
|
|
2293
|
+
const output = runGitText(root, ['ls-files', '--others', '--exclude-standard', '-z', '--', '.'], 1_000_000);
|
|
2294
|
+
const entries = output.split('\0').filter(Boolean).filter((relPath) => relPath !== context && !relPath.startsWith(`${context}/`));
|
|
2295
|
+
if (!entries.length) return '';
|
|
2296
|
+
const lines = [];
|
|
2297
|
+
let usedBytes = 0;
|
|
2298
|
+
let omitted = 0;
|
|
2299
|
+
const budget = Math.max(1_024, maxBytes);
|
|
2300
|
+
for (const relPath of entries.sort()) {
|
|
2301
|
+
const line = untrackedEntrySummary(root, relPath);
|
|
2302
|
+
const lineBytes = Buffer.byteLength(`${line}\n`, 'utf8');
|
|
2303
|
+
if (usedBytes + lineBytes > budget) {
|
|
2304
|
+
omitted += 1;
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
lines.push(line);
|
|
2308
|
+
usedBytes += lineBytes;
|
|
2309
|
+
}
|
|
2310
|
+
if (omitted) lines.push(`- ... ${omitted} untracked entries omitted after ${budget} bytes`);
|
|
2311
|
+
return `${lines.join('\n')}\n`;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function contextPathPredicate(contextDir) {
|
|
2315
|
+
const context = normalizedContextDir(contextDir);
|
|
2316
|
+
return (filePath) => filePath === context || filePath.startsWith(`${context}/`);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
function pathStateForFingerprint(root, relPath, options = {}) {
|
|
2320
|
+
const absPath = path.resolve(root, relPath);
|
|
2321
|
+
try {
|
|
2322
|
+
const stat = fs.lstatSync(absPath);
|
|
2323
|
+
const type = stat.isSymbolicLink()
|
|
2324
|
+
? 'symlink'
|
|
2325
|
+
: stat.isFile()
|
|
2326
|
+
? 'file'
|
|
2327
|
+
: stat.isDirectory()
|
|
2328
|
+
? 'directory'
|
|
2329
|
+
: 'non-file';
|
|
2330
|
+
const parts = [
|
|
2331
|
+
`type=${type}`,
|
|
2332
|
+
`mode=${stat.mode}`,
|
|
2333
|
+
`size=${stat.size}`
|
|
2334
|
+
];
|
|
2335
|
+
if (stat.isSymbolicLink()) parts.push(`target=${singleLineSummary(fs.readlinkSync(absPath))}`);
|
|
2336
|
+
if (stat.isFile()) {
|
|
2337
|
+
parts.push(options.fullFileHash ? `sha256=${fileSha256(absPath)}` : boundedFileFingerprint(absPath, stat));
|
|
2338
|
+
}
|
|
2339
|
+
return parts.join(';');
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
return `unavailable:${singleLineSummary(redactForLog(error instanceof Error ? error.message : String(error)))}`;
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function changeFingerprintExcludingContext(root, contextDir) {
|
|
2346
|
+
const context = normalizedContextDir(contextDir);
|
|
2347
|
+
const isContextPath = contextPathPredicate(contextDir);
|
|
2348
|
+
const workspacePrefix = gitWorkspacePrefix(root);
|
|
2349
|
+
const status = gitStatusPorcelain(root, 25_000_000);
|
|
2350
|
+
const stagedRaw = runGitText(root, ['diff', '--cached', '--raw', '-z', '--no-ext-diff', '--', '.', `:(exclude)${context}`], 25_000_000);
|
|
2351
|
+
const hash = createHash('sha256');
|
|
2352
|
+
hash.update(`staged-raw\0${stagedRaw}\0`);
|
|
2353
|
+
for (const line of status.split(/\r?\n/).filter(Boolean).sort()) {
|
|
2354
|
+
const paths = statusLineWorkspacePaths(line, workspacePrefix);
|
|
2355
|
+
if (!paths.length) continue;
|
|
2356
|
+
if (paths.length && paths.every(isContextPath)) continue;
|
|
2357
|
+
hash.update(`status\0${workspaceStatusLine(line, paths)}\0`);
|
|
2358
|
+
const fullFileHash = !line.startsWith('?? ');
|
|
2359
|
+
for (const filePath of paths) {
|
|
2360
|
+
hash.update(`path\0${filePath}\0${pathStateForFingerprint(root, filePath, { fullFileHash })}\0`);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
return hash.digest('hex');
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function readGitDiffExcludingContext(root, contextDir, maxBytes) {
|
|
2367
|
+
const context = normalizedContextDir(contextDir);
|
|
2368
|
+
try {
|
|
2369
|
+
const staged = runGitText(root, ['diff', '--cached', '--no-ext-diff', '--', '.', `:(exclude)${context}`], maxBytes);
|
|
2370
|
+
const unstaged = runGitText(root, ['diff', '--no-ext-diff', '--', '.', `:(exclude)${context}`], maxBytes);
|
|
2371
|
+
const untracked = untrackedFilesSummary(root, contextDir, maxBytes);
|
|
2372
|
+
const sections = [];
|
|
2373
|
+
if (staged.trim()) sections.push(`# Staged diff\n\n${staged}`);
|
|
2374
|
+
if (unstaged.trim()) sections.push(`# Unstaged diff\n\n${unstaged}`);
|
|
2375
|
+
if (untracked.trim()) sections.push(`# Untracked files\n\n${untracked}`);
|
|
2376
|
+
if (!sections.length) return '';
|
|
2377
|
+
return trimBytes(sections.join('\n\n'), maxBytes).text;
|
|
2378
|
+
} catch (error) {
|
|
2379
|
+
return `# git changes unavailable\n\n${error instanceof Error ? error.message : String(error)}\n`;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function writeLoopTestOutput(paths, result, commandText) {
|
|
2384
|
+
fs.mkdirSync(paths.bridgeDir, { recursive: true, mode: 0o700 });
|
|
2385
|
+
const content = [
|
|
2386
|
+
'# Loop Test Output',
|
|
2387
|
+
'',
|
|
2388
|
+
`Updated: ${new Date().toISOString()}`,
|
|
2389
|
+
`Command: ${commandText}`,
|
|
2390
|
+
`Exit code: ${result.exitCode ?? 'null'}`,
|
|
2391
|
+
result.signal ? `Signal: ${result.signal}` : '',
|
|
2392
|
+
`Timed out: ${result.timedOut ? 'yes' : 'no'}`,
|
|
2393
|
+
`Duration: ${result.durationMs} ms`,
|
|
2394
|
+
'',
|
|
2395
|
+
codeBlock('Stdout excerpt', result.stdout),
|
|
2396
|
+
codeBlock('Stderr excerpt', result.stderr)
|
|
2397
|
+
].filter(Boolean).join('\n');
|
|
2398
|
+
fs.writeFileSync(paths.testsPath, content, { mode: 0o600 });
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function explicitReviewVerdict(text) {
|
|
2402
|
+
for (const rawLine of String(text || '').split(/\r?\n/)) {
|
|
2403
|
+
const line = rawLine.trim();
|
|
2404
|
+
const assignment = line.match(/^CODEXFLOW_REVIEW\s*=\s*(PASS|FAIL)\b/i);
|
|
2405
|
+
if (assignment) return assignment[1].toUpperCase();
|
|
2406
|
+
}
|
|
2407
|
+
return '';
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function writeLoopReviewOutput(paths, result, commandText, verdict, nextPlanChanged) {
|
|
2411
|
+
fs.mkdirSync(paths.bridgeDir, { recursive: true, mode: 0o700 });
|
|
2412
|
+
const content = [
|
|
2413
|
+
'# Loop Review',
|
|
2414
|
+
'',
|
|
2415
|
+
`Updated: ${new Date().toISOString()}`,
|
|
2416
|
+
`Command: ${commandText}`,
|
|
2417
|
+
`Verdict: ${verdict || 'unknown'}`,
|
|
2418
|
+
`Next plan changed: ${nextPlanChanged ? 'yes' : 'no'}`,
|
|
2419
|
+
`Exit code: ${result.exitCode ?? 'null'}`,
|
|
2420
|
+
result.signal ? `Signal: ${result.signal}` : '',
|
|
2421
|
+
`Timed out: ${result.timedOut ? 'yes' : 'no'}`,
|
|
2422
|
+
`Duration: ${result.durationMs} ms`,
|
|
2423
|
+
'',
|
|
2424
|
+
codeBlock('Stdout excerpt', result.stdout),
|
|
2425
|
+
codeBlock('Stderr excerpt', result.stderr)
|
|
2426
|
+
].filter(Boolean).join('\n');
|
|
2427
|
+
fs.writeFileSync(paths.reviewPath, content, { mode: 0o600 });
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
async function runLoopCommand(commandInfo, root, timeoutMs, maxOutputBytes, label) {
|
|
2431
|
+
if (!commandAvailableFromRoot(commandInfo.command, root)) {
|
|
2432
|
+
throw new Error(`${label} command was not found: ${commandInfo.command}`);
|
|
2433
|
+
}
|
|
2434
|
+
statusLine('wait', `Running ${label.toLowerCase()}: ${commandDisplay(commandInfo)}`);
|
|
2435
|
+
return runProcessCaptured(commandInfo.command, commandInfo.args, {
|
|
2436
|
+
cwd: root,
|
|
2437
|
+
timeoutMs,
|
|
2438
|
+
maxOutputBytes
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function assertLoopCommandAvailable(commandInfo, root, label) {
|
|
2443
|
+
if (!commandAvailableFromRoot(commandInfo.command, root)) {
|
|
2444
|
+
throw new Error(`${label} command was not found before starting loop-handoff: ${commandInfo.command}`);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function preflightLoopCommands(request, reviewCommand, testCommand) {
|
|
2449
|
+
assertLoopCommandAvailable(request.commandInfo, request.root, 'Executor');
|
|
2450
|
+
assertLoopCommandAvailable(reviewCommand, request.root, 'Review');
|
|
2451
|
+
if (testCommand) assertLoopCommandAvailable(testCommand, request.root, 'Test');
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
async function confirmLoopHandoff(args, root) {
|
|
2455
|
+
if (args.yes || args.noConfirm || args.dryRun) return true;
|
|
2456
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2457
|
+
throw new Error('Use --yes to start loop-handoff in non-interactive shells, or use --dry-run to preview.');
|
|
2458
|
+
}
|
|
2459
|
+
printBox('Confirm handoff loop', [
|
|
2460
|
+
labelValue('Workspace', root),
|
|
2461
|
+
labelValue('Agent', args.agent ?? 'opencode'),
|
|
2462
|
+
...(args.model ? [labelValue('Model', args.model)] : []),
|
|
2463
|
+
labelValue('Max iters', args.maxIters ?? '3'),
|
|
2464
|
+
labelValue('Reviewer', args.reviewCommand ?? ''),
|
|
2465
|
+
'This runs local executor and reviewer commands in a bounded loop. It does not automate ChatGPT or any browser session.'
|
|
2466
|
+
]);
|
|
2467
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2468
|
+
try {
|
|
2469
|
+
const answer = await ask(rl, 'Start local execute/review loop?', 'no');
|
|
2470
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
2471
|
+
} finally {
|
|
2472
|
+
rl.close();
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
async function confirmLoopContinuation(args, root, iteration, planPath) {
|
|
2477
|
+
if (!args.requireHumanConfirmation) return true;
|
|
2478
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2479
|
+
throw new Error('--require-human-confirmation needs an interactive terminal before running follow-up plans.');
|
|
2480
|
+
}
|
|
2481
|
+
printBox('Confirm follow-up plan', [
|
|
2482
|
+
labelValue('Workspace', root),
|
|
2483
|
+
labelValue('Iteration', String(iteration)),
|
|
2484
|
+
labelValue('Plan', path.relative(root, planPath)),
|
|
2485
|
+
'The reviewer wrote or kept a follow-up plan. Review it before continuing.'
|
|
2486
|
+
]);
|
|
2487
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2488
|
+
try {
|
|
2489
|
+
const answer = await ask(rl, 'Run the next local executor iteration?', 'no');
|
|
2490
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
2491
|
+
} finally {
|
|
2492
|
+
rl.close();
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
function printLoopDryRun(request, reviewCommand, testCommand, maxIters) {
|
|
2497
|
+
printBox('CodexFlow loop-handoff dry run', [
|
|
2498
|
+
labelValue('Workspace', request.root),
|
|
2499
|
+
labelValue('Plan', path.relative(request.root, request.planPath)),
|
|
2500
|
+
labelValue('Agent', request.commandInfo.agent),
|
|
2501
|
+
...(request.commandInfo.model ? [labelValue('Model', request.commandInfo.model)] : []),
|
|
2502
|
+
labelValue('Max iters', String(maxIters)),
|
|
2503
|
+
labelValue('Executor', request.commandText),
|
|
2504
|
+
...(testCommand ? [labelValue('Tests', commandDisplay(testCommand))] : []),
|
|
2505
|
+
labelValue('Reviewer', commandDisplay(reviewCommand)),
|
|
2506
|
+
'No command was executed and no .ai-bridge result files were changed.'
|
|
2507
|
+
]);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function writeLoopState(paths, state) {
|
|
2511
|
+
fs.mkdirSync(paths.bridgeDir, { recursive: true, mode: 0o700 });
|
|
2512
|
+
fs.writeFileSync(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
async function runLoopHandoff(argv) {
|
|
2516
|
+
const args = parseArgs(argv);
|
|
2517
|
+
if (args.help) {
|
|
2518
|
+
usage();
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
const root = realDir(args.root ?? process.env.CODEXFLOW_ROOT ?? process.cwd());
|
|
2523
|
+
const contextDir = contextDirFromArgs(args);
|
|
2524
|
+
const paths = loopArtifactPaths(root, contextDir);
|
|
2525
|
+
const maxIters = numberOption(args.maxIters ?? args.maxIterations, 3, 1, 25);
|
|
2526
|
+
const maxReadBytes = handoffMaxReadBytes();
|
|
2527
|
+
const maxOutputBytes = numberOption(args.maxOutputBytes ?? process.env.CODEXFLOW_MAX_OUTPUT_BYTES, 120_000, 4_000, 2_000_000);
|
|
2528
|
+
const reviewTimeoutMs = numberOption(args.reviewTimeoutMs, 600_000, 1_000, 24 * 60 * 60_000);
|
|
2529
|
+
const testTimeoutMs = numberOption(args.testTimeoutMs, 600_000, 1_000, 24 * 60 * 60_000);
|
|
2530
|
+
|
|
2531
|
+
if (args.requireCleanGitStart) assertCleanGitStart(root, contextDir);
|
|
2532
|
+
|
|
2533
|
+
let request = loadHandoffExecution({ ...args, root, contextDir });
|
|
2534
|
+
const reviewCommand = buildReviewerCommand(args, root, contextDir, 1, paths);
|
|
2535
|
+
const testCommand = buildTestCommand(args, root, contextDir, 1, paths);
|
|
2536
|
+
|
|
2537
|
+
if (args.dryRun) {
|
|
2538
|
+
printLoopDryRun(request, reviewCommand, testCommand, maxIters);
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
preflightLoopCommands(request, reviewCommand, testCommand);
|
|
2543
|
+
|
|
2544
|
+
const approved = await confirmLoopHandoff(args, root);
|
|
2545
|
+
if (!approved) {
|
|
2546
|
+
statusLine('warn', 'Loop cancelled.');
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
printBox('CodexFlow loop-handoff', [
|
|
2551
|
+
labelValue('Workspace', root),
|
|
2552
|
+
labelValue('Plan', path.relative(root, paths.planPath)),
|
|
2553
|
+
labelValue('Agent', request.commandInfo.agent),
|
|
2554
|
+
...(request.commandInfo.model ? [labelValue('Model', request.commandInfo.model)] : []),
|
|
2555
|
+
labelValue('Max iters', String(maxIters)),
|
|
2556
|
+
labelValue('Reviewer', commandDisplay(reviewCommand)),
|
|
2557
|
+
...(testCommand ? [labelValue('Tests', commandDisplay(testCommand))] : []),
|
|
2558
|
+
'Mode: local execute/review loop. No ChatGPT or browser session is automated.'
|
|
2559
|
+
]);
|
|
2560
|
+
|
|
2561
|
+
let previousChangeFingerprint = '';
|
|
2562
|
+
let finalVerdict = 'FAIL';
|
|
2563
|
+
let stopReason = 'max_iters';
|
|
2564
|
+
|
|
2565
|
+
for (let iteration = 1; iteration <= maxIters; iteration += 1) {
|
|
2566
|
+
if (iteration > 1) {
|
|
2567
|
+
const continueLoop = await confirmLoopContinuation(args, root, iteration, paths.planPath);
|
|
2568
|
+
if (!continueLoop) {
|
|
2569
|
+
stopReason = 'human_cancelled';
|
|
2570
|
+
break;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
request = loadHandoffExecution({ ...args, root, contextDir });
|
|
2575
|
+
const currentPlanHash = planHash(request.planText);
|
|
2576
|
+
if (isScaffoldedHandoffPlan(request.planText)) {
|
|
2577
|
+
stopReason = 'scaffolded_plan';
|
|
2578
|
+
statusLine('warn', 'Stopping because current-plan.md is still the empty scaffold.');
|
|
2579
|
+
break;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
appendBridgeLog(root, contextDir, {
|
|
2583
|
+
event: 'loop_handoff_iteration_started',
|
|
2584
|
+
iteration,
|
|
2585
|
+
plan_hash: currentPlanHash,
|
|
2586
|
+
agent: request.commandInfo.agent,
|
|
2587
|
+
model: request.commandInfo.model || undefined
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2590
|
+
const beforeExecutionFingerprint = changeFingerprintExcludingContext(root, contextDir);
|
|
2591
|
+
const execution = await executeHandoffRequest(request, { ...args, yes: true }, { skipConfirmation: true, iteration });
|
|
2592
|
+
const diffText = readGitDiffExcludingContext(root, contextDir, maxOutputBytes);
|
|
2593
|
+
fs.writeFileSync(paths.diffPath, diffText || '', { mode: 0o600 });
|
|
2594
|
+
const currentChangeFingerprint = changeFingerprintExcludingContext(root, contextDir);
|
|
2595
|
+
const changedThisIteration = currentChangeFingerprint !== beforeExecutionFingerprint;
|
|
2596
|
+
|
|
2597
|
+
if (args.stopIfNoFilesChanged && !changedThisIteration) {
|
|
2598
|
+
finalVerdict = 'FAIL';
|
|
2599
|
+
stopReason = 'no_files_changed';
|
|
2600
|
+
statusLine('warn', 'Stopping because the executor produced no new git changes.');
|
|
2601
|
+
break;
|
|
2602
|
+
}
|
|
2603
|
+
if (args.stopIfSameDiff && previousChangeFingerprint && currentChangeFingerprint === previousChangeFingerprint) {
|
|
2604
|
+
finalVerdict = 'FAIL';
|
|
2605
|
+
stopReason = 'same_diff';
|
|
2606
|
+
statusLine('warn', 'Stopping because the executor repeated the previous diff.');
|
|
2607
|
+
break;
|
|
2608
|
+
}
|
|
2609
|
+
previousChangeFingerprint = currentChangeFingerprint;
|
|
2610
|
+
|
|
2611
|
+
const iterationTestCommand = buildTestCommand(args, root, contextDir, iteration, paths);
|
|
2612
|
+
let testResult = null;
|
|
2613
|
+
if (iterationTestCommand) {
|
|
2614
|
+
testResult = await runLoopCommand(iterationTestCommand, root, testTimeoutMs, maxOutputBytes, 'Test');
|
|
2615
|
+
writeLoopTestOutput(paths, testResult, commandDisplay(iterationTestCommand));
|
|
2616
|
+
statusLine(testResult.exitCode === 0 ? 'ok' : 'warn', `Tests exited with code ${testResult.exitCode ?? 'null'}${testResult.signal ? ` signal=${testResult.signal}` : ''}`);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
const iterationReviewCommand = buildReviewerCommand(args, root, contextDir, iteration, paths);
|
|
2620
|
+
const beforeReviewPlanExists = fs.existsSync(paths.planPath);
|
|
2621
|
+
const beforeReviewPlan = beforeReviewPlanExists ? readTextFileBounded(paths.planPath, maxReadBytes) : '';
|
|
2622
|
+
const reviewResult = await runLoopCommand(iterationReviewCommand, root, reviewTimeoutMs, maxOutputBytes, 'Review');
|
|
2623
|
+
const afterReviewPlanExists = fs.existsSync(paths.planPath);
|
|
2624
|
+
const afterReviewPlan = afterReviewPlanExists ? readTextFileBounded(paths.planPath, maxReadBytes) : '';
|
|
2625
|
+
const planDeletedByReview = beforeReviewPlanExists && !afterReviewPlanExists;
|
|
2626
|
+
const nextPlanChanged = planDeletedByReview || (afterReviewPlanExists && planHash(afterReviewPlan) !== planHash(beforeReviewPlan));
|
|
2627
|
+
const hasUsableFollowupPlan = afterReviewPlanExists && afterReviewPlan.trim() && !isScaffoldedHandoffPlan(afterReviewPlan);
|
|
2628
|
+
let verdict = explicitReviewVerdict(`${reviewResult.stdout}\n${reviewResult.stderr}`);
|
|
2629
|
+
if (!verdict && args.allowImplicitReviewVerdict && nextPlanChanged && reviewResult.exitCode === 0) verdict = 'FAIL';
|
|
2630
|
+
if (!verdict && args.allowImplicitReviewVerdict && afterReviewPlanExists && reviewResult.exitCode === 0 && execution.result?.exitCode === 0 && (!testResult || testResult.exitCode === 0)) verdict = 'PASS';
|
|
2631
|
+
writeLoopReviewOutput(paths, reviewResult, commandDisplay(iterationReviewCommand), verdict, nextPlanChanged);
|
|
2632
|
+
let acceptedVerdict = verdict;
|
|
2633
|
+
let rejectedPassReason = '';
|
|
2634
|
+
if (verdict === 'PASS' && reviewResult.exitCode !== 0) {
|
|
2635
|
+
acceptedVerdict = 'FAIL';
|
|
2636
|
+
rejectedPassReason = 'reviewer_failed';
|
|
2637
|
+
} else if (verdict === 'PASS' && !args.allowReviewPassOnFailure && execution.result?.exitCode !== 0) {
|
|
2638
|
+
acceptedVerdict = 'FAIL';
|
|
2639
|
+
rejectedPassReason = 'executor_failed';
|
|
2640
|
+
} else if (verdict === 'PASS' && !args.allowReviewPassOnFailure && testResult && testResult.exitCode !== 0) {
|
|
2641
|
+
acceptedVerdict = 'FAIL';
|
|
2642
|
+
rejectedPassReason = 'tests_failed';
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
appendBridgeLog(root, contextDir, {
|
|
2646
|
+
event: 'loop_handoff_iteration_finished',
|
|
2647
|
+
iteration,
|
|
2648
|
+
plan_hash: currentPlanHash,
|
|
2649
|
+
agent: request.commandInfo.agent,
|
|
2650
|
+
model: request.commandInfo.model || undefined,
|
|
2651
|
+
executor_exit_code: execution.result?.exitCode ?? null,
|
|
2652
|
+
test_exit_code: testResult?.exitCode ?? null,
|
|
2653
|
+
reviewer_exit_code: reviewResult.exitCode,
|
|
2654
|
+
reviewer_verdict: verdict,
|
|
2655
|
+
verdict: acceptedVerdict,
|
|
2656
|
+
rejected_pass_reason: rejectedPassReason || undefined,
|
|
2657
|
+
next_plan_changed: nextPlanChanged,
|
|
2658
|
+
followup_plan_exists: afterReviewPlanExists,
|
|
2659
|
+
has_usable_followup_plan: Boolean(hasUsableFollowupPlan),
|
|
2660
|
+
changed_this_iteration: changedThisIteration,
|
|
2661
|
+
status_path: path.posix.join(contextDir, 'agent-status.md'),
|
|
2662
|
+
diff_path: path.posix.join(contextDir, 'implementation-diff.patch'),
|
|
2663
|
+
tests_path: iterationTestCommand ? path.posix.join(contextDir, 'loop-tests.txt') : undefined,
|
|
2664
|
+
review_path: path.posix.join(contextDir, 'loop-review.md')
|
|
2665
|
+
});
|
|
2666
|
+
writeLoopState(paths, {
|
|
2667
|
+
updatedAt: new Date().toISOString(),
|
|
2668
|
+
iteration,
|
|
2669
|
+
maxIters,
|
|
2670
|
+
reviewerVerdict: verdict,
|
|
2671
|
+
verdict: acceptedVerdict,
|
|
2672
|
+
rejectedPassReason: rejectedPassReason || undefined,
|
|
2673
|
+
planHash: currentPlanHash,
|
|
2674
|
+
nextPlanChanged,
|
|
2675
|
+
followupPlanExists: afterReviewPlanExists,
|
|
2676
|
+
hasUsableFollowupPlan: Boolean(hasUsableFollowupPlan),
|
|
2677
|
+
changedThisIteration,
|
|
2678
|
+
executorExitCode: execution.result?.exitCode ?? null,
|
|
2679
|
+
reviewerExitCode: reviewResult.exitCode
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
if (acceptedVerdict === 'PASS') {
|
|
2683
|
+
finalVerdict = 'PASS';
|
|
2684
|
+
stopReason = 'pass';
|
|
2685
|
+
statusLine('ok', `Reviewer passed on iteration ${iteration}.`);
|
|
2686
|
+
break;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
if (rejectedPassReason) {
|
|
2690
|
+
if (rejectedPassReason === 'reviewer_failed') {
|
|
2691
|
+
finalVerdict = 'FAIL';
|
|
2692
|
+
stopReason = 'reviewer_error';
|
|
2693
|
+
statusLine('warn', `Reviewer returned PASS, but reviewer process exited with code ${reviewResult.exitCode ?? 'null'}.`);
|
|
2694
|
+
break;
|
|
2695
|
+
}
|
|
2696
|
+
if (rejectedPassReason === 'executor_failed') {
|
|
2697
|
+
finalVerdict = 'FAIL';
|
|
2698
|
+
stopReason = 'executor_failed';
|
|
2699
|
+
statusLine('warn', `Reviewer returned PASS, but executor exited with code ${execution.result?.exitCode ?? 'null'}.`);
|
|
2700
|
+
break;
|
|
2701
|
+
}
|
|
2702
|
+
finalVerdict = 'FAIL';
|
|
2703
|
+
stopReason = 'tests_failed';
|
|
2704
|
+
statusLine('warn', `Reviewer returned PASS, but tests exited with code ${testResult?.exitCode ?? 'null'}.`);
|
|
2705
|
+
break;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
if (acceptedVerdict !== 'FAIL') {
|
|
2709
|
+
finalVerdict = 'FAIL';
|
|
2710
|
+
stopReason = reviewResult.exitCode === 0 ? 'unknown_verdict' : 'reviewer_error';
|
|
2711
|
+
statusLine('warn', `Stopping because reviewer did not return a usable verdict. Exit code: ${reviewResult.exitCode ?? 'null'}`);
|
|
2712
|
+
break;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
if (reviewResult.exitCode !== 0) {
|
|
2716
|
+
finalVerdict = 'FAIL';
|
|
2717
|
+
stopReason = 'reviewer_error';
|
|
2718
|
+
statusLine('warn', `Stopping because reviewer exited with code ${reviewResult.exitCode ?? 'null'}.`);
|
|
2719
|
+
break;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
if (!nextPlanChanged || !hasUsableFollowupPlan) {
|
|
2723
|
+
finalVerdict = 'FAIL';
|
|
2724
|
+
stopReason = 'no_followup_plan';
|
|
2725
|
+
statusLine('warn', 'Reviewer returned FAIL but did not update current-plan.md.');
|
|
2726
|
+
break;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
statusLine('wait', `Reviewer requested another iteration (${iteration}/${maxIters}).`);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
appendBridgeLog(root, contextDir, {
|
|
2733
|
+
event: 'loop_handoff_finished',
|
|
2734
|
+
verdict: finalVerdict,
|
|
2735
|
+
stop_reason: stopReason
|
|
2736
|
+
});
|
|
2737
|
+
statusLine(finalVerdict === 'PASS' ? 'ok' : 'warn', `Loop finished: ${finalVerdict} (${stopReason}).`);
|
|
2738
|
+
console.log(`Status: ${path.relative(root, paths.statusPath)}`);
|
|
2739
|
+
console.log(`Diff: ${path.relative(root, paths.diffPath)}`);
|
|
2740
|
+
console.log(`Review: ${path.relative(root, paths.reviewPath)}`);
|
|
2741
|
+
console.log(`Log: ${path.relative(root, paths.logPath)}`);
|
|
2742
|
+
if (finalVerdict !== 'PASS') process.exitCode = 1;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function createConnectorDetails(endpoint, token, localBase = '') {
|
|
2746
|
+
const serverUrl = endpointWithToken(endpoint, token);
|
|
2747
|
+
return {
|
|
2748
|
+
endpoint,
|
|
2749
|
+
token,
|
|
2750
|
+
serverUrl,
|
|
2751
|
+
localStatusUrl: localBase ? endpointWithToken(`${localBase}/`, token) : '',
|
|
2752
|
+
chatgptSettingsUrl: 'https://chatgpt.com/#settings/Connectors'
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function printCreateAppFields(details) {
|
|
2757
|
+
console.log('Create App fields:');
|
|
2758
|
+
console.log('');
|
|
2759
|
+
console.log(' Name: CodexFlow');
|
|
2760
|
+
console.log(' Description: Local coding workspace bridge for ChatGPT.');
|
|
2761
|
+
console.log(' Connection: Server URL');
|
|
2762
|
+
console.log(` Server URL: ${details.serverUrl}`);
|
|
2763
|
+
console.log(' Authentication: No Authentication / None');
|
|
2764
|
+
console.log('');
|
|
2765
|
+
if (details.token) {
|
|
2766
|
+
console.log('If your ChatGPT UI supports custom headers instead, you can use:');
|
|
2767
|
+
console.log('');
|
|
2768
|
+
console.log(` Authorization: Bearer ${details.token}`);
|
|
2769
|
+
} else {
|
|
2770
|
+
console.log('Authorization: disabled');
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
function printConnectorBlock(endpoint, token, options = {}) {
|
|
2775
|
+
const details = createConnectorDetails(endpoint, token, options.localBase ?? '');
|
|
2776
|
+
const { serverUrl } = details;
|
|
2777
|
+
const interactive = options.interactive !== false && options.nonInteractive !== true && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
2778
|
+
const publicHttps = serverUrl.startsWith('https://');
|
|
2779
|
+
const shouldCopy = options.copyUrl === true || (options.copyUrl !== false && publicHttps);
|
|
2780
|
+
const copied = shouldCopy ? copyToClipboard(serverUrl) : { ok: false, command: '' };
|
|
2781
|
+
const opened = options.openChatgpt ? openUrl(details.chatgptSettingsUrl) : false;
|
|
2782
|
+
|
|
2783
|
+
const mode = options.mode ?? 'agent';
|
|
2784
|
+
const modeTitle = mode === 'agent' ? 'Agent' : mode === 'handoff' ? 'Handoff' : 'Pro planning';
|
|
2785
|
+
console.log('');
|
|
2786
|
+
console.log(paint('bold', 'CodexFlow ready'));
|
|
2787
|
+
if (options.root) console.log(` Workspace ${options.root}`);
|
|
2788
|
+
console.log(` Mode ${modeTitle} tools=${options.toolMode ?? 'standard'} write=${options.write ?? 'workspace'} bash=${options.bash ?? 'safe'}`);
|
|
2789
|
+
console.log(` Transcript bash=${options.bashTranscript ?? 'compact'}`);
|
|
2790
|
+
if (options.codexSessions && options.codexSessions !== 'off') console.log(` Codex sessions=${options.codexSessions}`);
|
|
2791
|
+
if (options.bashSession) console.log(` Bash session=${options.bashSession}${options.requireBashSession ? ' required' : ''}`);
|
|
2792
|
+
console.log(` Connector ${publicHttps ? 'public HTTPS' : 'local HTTP'}`);
|
|
2793
|
+
if (copied.ok) {
|
|
2794
|
+
console.log(` URL copied with ${copied.command}`);
|
|
2795
|
+
console.log(` Server URL ${serverUrl}`);
|
|
2796
|
+
} else if (shouldCopy) {
|
|
2797
|
+
console.log(' URL copy failed; copy manually:');
|
|
2798
|
+
console.log(serverUrl);
|
|
2799
|
+
} else if (options.copyUrl === false && publicHttps) {
|
|
2800
|
+
console.log(' URL not copied; press c to copy or u to show');
|
|
2801
|
+
} else if (!publicHttps) {
|
|
2802
|
+
console.log(' URL local HTTP only');
|
|
2803
|
+
console.log(serverUrl);
|
|
2804
|
+
}
|
|
2805
|
+
if (options.openChatgpt) {
|
|
2806
|
+
statusLine(opened ? 'ok' : 'warn', opened ? 'Opened ChatGPT connector settings' : 'Could not open ChatGPT automatically');
|
|
2807
|
+
}
|
|
2808
|
+
console.log('');
|
|
2809
|
+
if (options.connectionTest) {
|
|
2810
|
+
console.log(paint('bold', 'Connection test'));
|
|
2811
|
+
console.log(' 1. In ChatGPT, open Settings -> Plugins and create a development plugin.');
|
|
2812
|
+
console.log(' 2. Paste the Server URL above and choose Authentication: No Authentication.');
|
|
2813
|
+
console.log(' 3. Watch this terminal for: [CodexFlow] POST /mcp received');
|
|
2814
|
+
console.log('');
|
|
2815
|
+
console.log(' No POST /mcp ChatGPT or the tunnel did not reach CodexFlow.');
|
|
2816
|
+
console.log(' POST /mcp -> 401 The full Server URL, including codexflow_token, was not used.');
|
|
2817
|
+
console.log(' POST /mcp -> 2xx The MCP connection reached CodexFlow successfully.');
|
|
2818
|
+
console.log('');
|
|
2819
|
+
}
|
|
2820
|
+
if (interactive) {
|
|
2821
|
+
console.log('Next: press Enter to open ChatGPT, paste the copied Server URL, choose Authentication: None.');
|
|
2822
|
+
console.log('Keys: Enter open | c copy | o status | h help | q quit');
|
|
2823
|
+
} else {
|
|
2824
|
+
console.log('Non-interactive mode: the connector stays running until SIGINT/SIGTERM.');
|
|
2825
|
+
console.log(`Check it with: ${shellCommandPreview(['codexflow', 'status', ...(options.root ? ['--root', options.root] : [])])}`);
|
|
2826
|
+
}
|
|
2827
|
+
return { ...details, copied, opened, mode, toolMode: options.toolMode ?? 'standard' };
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
function printControlHelp() {
|
|
2831
|
+
console.log('');
|
|
2832
|
+
console.log('Controls');
|
|
2833
|
+
console.log(' Enter open ChatGPT connector settings in your browser');
|
|
2834
|
+
console.log(' c copy Server URL again');
|
|
2835
|
+
console.log(' u print Server URL only');
|
|
2836
|
+
console.log(' o open local status/settings dashboard');
|
|
2837
|
+
console.log(' p print Create App fields');
|
|
2838
|
+
console.log(' m print mode help');
|
|
2839
|
+
console.log(' h show controls');
|
|
2840
|
+
console.log(' q stop CodexFlow');
|
|
2841
|
+
console.log('');
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function printModeHelp() {
|
|
2845
|
+
console.log('');
|
|
2846
|
+
console.log('Modes');
|
|
2847
|
+
console.log(' codexflow agent mode: read/write/edit/apply_patch/search/bash');
|
|
2848
|
+
console.log(' codexflow --no-bash agent mode without ChatGPT-triggered shell commands');
|
|
2849
|
+
console.log(' codexflow --bash-session main --require-bash-session');
|
|
2850
|
+
console.log(' codexflow --mode handoff planning-only .ai-bridge handoff');
|
|
2851
|
+
console.log(' codexflow --mode pro export context for models without MCP tools');
|
|
2852
|
+
console.log(' codexflow --tool-mode minimal expose only the tight coding loop');
|
|
2853
|
+
console.log(' codexflow --tool-mode full expose every advanced compatibility tool');
|
|
2854
|
+
console.log('');
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
function printStableUrlHelp() {
|
|
2858
|
+
console.log('');
|
|
2859
|
+
console.log('Stable URL setup');
|
|
2860
|
+
console.log('');
|
|
2861
|
+
console.log('Quick tunnels change every restart. ChatGPT apps should use a stable URL.');
|
|
2862
|
+
console.log('');
|
|
2863
|
+
console.log('One-time Cloudflare setup with your domain:');
|
|
2864
|
+
console.log(' codexflow install-cloudflared');
|
|
2865
|
+
console.log(' ~/.codexflow/bin/cloudflared tunnel login');
|
|
2866
|
+
console.log(' ~/.codexflow/bin/cloudflared tunnel create codexflow');
|
|
2867
|
+
console.log(' ~/.codexflow/bin/cloudflared tunnel route dns codexflow codexflow.example.com');
|
|
2868
|
+
console.log('');
|
|
2869
|
+
console.log('Daily start:');
|
|
2870
|
+
console.log(' codexflow stable --hostname codexflow.example.com --tunnel-name codexflow --token keep-this-stable-token');
|
|
2871
|
+
console.log('');
|
|
2872
|
+
console.log('Ngrok alternative with a reserved domain:');
|
|
2873
|
+
console.log(' ngrok config add-authtoken <your-ngrok-token>');
|
|
2874
|
+
console.log(' codexflow ngrok --hostname your-domain.ngrok-free.dev --token keep-this-stable-token');
|
|
2875
|
+
console.log('');
|
|
2876
|
+
console.log('Tailscale Funnel alternative:');
|
|
2877
|
+
console.log(' tailscale funnel 8787');
|
|
2878
|
+
console.log(' codexflow tailscale --hostname your-device.your-tailnet.ts.net --token keep-this-stable-token');
|
|
2879
|
+
console.log('');
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
function compareMajorVersion(version, minimumMajor) {
|
|
2883
|
+
const major = Number(String(version).split('.')[0]);
|
|
2884
|
+
return Number.isFinite(major) && major >= minimumMajor;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
function browserOpenCommand() {
|
|
2888
|
+
if (process.platform === 'darwin') return commandExists('open') ? 'open' : '';
|
|
2889
|
+
if (process.platform === 'win32') return 'cmd start';
|
|
2890
|
+
return commandExists('xdg-open') ? 'xdg-open' : '';
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
function clipboardCommand() {
|
|
2894
|
+
if (process.platform === 'darwin') return commandExists('pbcopy') ? 'pbcopy' : '';
|
|
2895
|
+
if (process.platform === 'win32') return 'clip';
|
|
2896
|
+
for (const command of ['wl-copy', 'xclip', 'xsel']) {
|
|
2897
|
+
if (commandExists(command)) return command;
|
|
2898
|
+
}
|
|
2899
|
+
return '';
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
function localOrPathCommand(command, localPath) {
|
|
2903
|
+
if (command && commandAvailable(command)) return command;
|
|
2904
|
+
if (localPath && executableFileExists(localPath)) return localPath;
|
|
2905
|
+
return '';
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
function doctorLine(status, label, detail = '') {
|
|
2909
|
+
const marker = status === 'ok' ? paint('green', 'OK') : status === 'warn' ? paint('yellow', 'WARN') : paint('red', 'FAIL');
|
|
2910
|
+
console.log(`${marker} ${label.padEnd(18)} ${detail}`);
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
async function runDoctor(argv) {
|
|
2914
|
+
const args = parseArgs(argv);
|
|
2915
|
+
if (args.help) {
|
|
2916
|
+
usage();
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
const root = realDir(args.root ?? process.env.CODEXFLOW_ROOT ?? process.cwd());
|
|
2921
|
+
const profile = args.noProfile ? {} : loadWorkspaceProfile(root);
|
|
2922
|
+
const effectiveArgs = { ...profile, ...args };
|
|
2923
|
+
const tunnel = optionValue(args, profile, 'tunnel', ['CODEXFLOW_TUNNEL'], 'cloudflare');
|
|
2924
|
+
const host = optionValue(args, profile, 'host', ['CODEXFLOW_HOST'], '127.0.0.1');
|
|
2925
|
+
const port = String(optionValue(args, profile, 'port', ['CODEXFLOW_PORT'], '8787'));
|
|
2926
|
+
const mode = optionValue(args, profile, 'mode', ['CODEXFLOW_MODE'], 'agent');
|
|
2927
|
+
const bash = optionValue(args, profile, 'bash', ['CODEXFLOW_BASH_MODE'], 'safe');
|
|
2928
|
+
const rawWrite = optionValue(args, profile, 'write', ['CODEXFLOW_WRITE_MODE'], mode === 'agent' ? 'workspace' : 'handoff');
|
|
2929
|
+
let write = String(rawWrite);
|
|
2930
|
+
let writeError = '';
|
|
2931
|
+
try {
|
|
2932
|
+
write = effectiveWriteMode(mode, rawWrite);
|
|
2933
|
+
} catch (error) {
|
|
2934
|
+
writeError = error instanceof Error ? error.message : String(error);
|
|
2935
|
+
}
|
|
2936
|
+
const toolMode = optionValue(args, profile, 'toolMode', ['CODEXFLOW_TOOL_MODE'], 'standard');
|
|
2937
|
+
const stableHostname = args.hostname
|
|
2938
|
+
?? args.url
|
|
2939
|
+
?? process.env.CODEXFLOW_PUBLIC_HOSTNAME
|
|
2940
|
+
?? process.env.CODEXFLOW_HOSTNAME
|
|
2941
|
+
?? process.env.NGROK_DOMAIN
|
|
2942
|
+
?? profile.hostname
|
|
2943
|
+
?? '';
|
|
2944
|
+
const httpPath = path.join(projectRoot, 'dist', 'http.js');
|
|
2945
|
+
const serverPath = path.join(projectRoot, 'dist', 'server.js');
|
|
2946
|
+
const cloudflaredPath = localOrPathCommand(
|
|
2947
|
+
effectiveArgs.cloudflared ?? process.env.CLOUDFLARED_BIN ?? 'cloudflared',
|
|
2948
|
+
localCloudflaredPath()
|
|
2949
|
+
);
|
|
2950
|
+
const ngrokPath = localOrPathCommand(effectiveArgs.ngrok ?? process.env.NGROK_BIN ?? 'ngrok', '');
|
|
2951
|
+
const tailscalePath = localOrPathCommand(effectiveArgs.tailscale ?? process.env.TAILSCALE_BIN ?? 'tailscale', '');
|
|
2952
|
+
const clipboard = clipboardCommand();
|
|
2953
|
+
const browser = browserOpenCommand();
|
|
2954
|
+
const checks = [];
|
|
2955
|
+
|
|
2956
|
+
function record(status, label, detail) {
|
|
2957
|
+
checks.push(status);
|
|
2958
|
+
doctorLine(status, label, detail);
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
console.log('');
|
|
2962
|
+
printBox('CodexFlow doctor', [
|
|
2963
|
+
labelValue('Workspace', root),
|
|
2964
|
+
labelValue('Mode', `${mode} tools=${toolMode} write=${write} bash=${bash}`),
|
|
2965
|
+
labelValue('Tunnel', tunnel),
|
|
2966
|
+
...(stableHostname ? [labelValue('Hostname', stableHostname)] : []),
|
|
2967
|
+
...(profile.profilePath ? [labelValue('Profile', profile.profilePath)] : [])
|
|
2968
|
+
]);
|
|
2969
|
+
|
|
2970
|
+
record(compareMajorVersion(process.versions.node, 20) ? 'ok' : 'fail', 'Node', `v${process.versions.node} (requires >=20)`);
|
|
2971
|
+
record(fs.existsSync(httpPath) && fs.existsSync(serverPath) ? 'ok' : 'fail', 'Build artifacts', fs.existsSync(httpPath) ? 'dist ready' : 'missing dist/http.js; run npm install && npm run build');
|
|
2972
|
+
record(fs.existsSync(path.join(projectRoot, 'package.json')) ? 'ok' : 'fail', 'Package root', projectRoot);
|
|
2973
|
+
record(profile.profilePath ? 'ok' : 'warn', 'Saved profile', profile.profilePath ? profileSummary(profile) || profile.profilePath : 'none for this workspace');
|
|
2974
|
+
record(['agent', 'handoff', 'pro'].includes(mode) ? 'ok' : 'fail', 'Mode', ['agent', 'handoff', 'pro'].includes(mode) ? mode : '--mode must be agent, handoff, or pro');
|
|
2975
|
+
record(['off', 'safe', 'full'].includes(bash) ? 'ok' : 'fail', 'Bash mode', ['off', 'safe', 'full'].includes(bash) ? bash : '--bash must be off, safe, or full');
|
|
2976
|
+
record(!writeError && ['off', 'handoff', 'workspace'].includes(write) ? 'ok' : 'fail', 'Write mode', writeError || write);
|
|
2977
|
+
record(['minimal', 'standard', 'full'].includes(toolMode) ? 'ok' : 'fail', 'Tool mode', ['minimal', 'standard', 'full'].includes(toolMode) ? toolMode : '--tool-mode must be minimal, standard, or full');
|
|
2978
|
+
record(clipboard ? 'ok' : 'warn', 'Clipboard', clipboard || 'not found; URL will be printed for manual copy');
|
|
2979
|
+
record(browser ? 'ok' : 'warn', 'Browser open', browser || 'not found; open ChatGPT manually');
|
|
2980
|
+
|
|
2981
|
+
try {
|
|
2982
|
+
await assertPortAvailable(host, port);
|
|
2983
|
+
record('ok', 'Local port', `${host}:${port} available`);
|
|
2984
|
+
} catch (error) {
|
|
2985
|
+
record('fail', 'Local port', error instanceof Error ? error.message.split('\n')[0] : String(error));
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
if (tunnel === 'none') {
|
|
2989
|
+
record('ok', 'Tunnel', 'local-only mode');
|
|
2990
|
+
} else if (tunnel === 'cloudflare') {
|
|
2991
|
+
record(cloudflaredPath ? 'ok' : 'warn', 'cloudflared', cloudflaredPath || 'missing now; codexflow can auto-install unless --no-install-cloudflared is used');
|
|
2992
|
+
} else if (tunnel === 'cloudflare-named') {
|
|
2993
|
+
record(stableHostname ? 'ok' : 'fail', 'Hostname', stableHostname || 'required for Cloudflare stable mode');
|
|
2994
|
+
record(cloudflaredPath ? 'ok' : 'warn', 'cloudflared', cloudflaredPath || 'missing now; run codexflow install-cloudflared or pass --cloudflared');
|
|
2995
|
+
record(
|
|
2996
|
+
optionValue(args, profile, 'tunnelName', ['CLOUDFLARE_TUNNEL_NAME', 'CODEXFLOW_TUNNEL_NAME'], '') ||
|
|
2997
|
+
optionValue(args, profile, 'cloudflareTokenFile', ['CLOUDFLARE_TUNNEL_TOKEN_FILE', 'CODEXFLOW_CLOUDFLARE_TUNNEL_TOKEN_FILE'], '') ||
|
|
2998
|
+
optionValue(args, profile, 'cloudflareConfig', ['CLOUDFLARE_TUNNEL_CONFIG', 'CODEXFLOW_CLOUDFLARE_CONFIG'], '') ||
|
|
2999
|
+
optionValue(args, profile, 'cloudflareToken', ['CLOUDFLARE_TUNNEL_TOKEN', 'CODEXFLOW_CLOUDFLARE_TUNNEL_TOKEN'], '')
|
|
3000
|
+
? 'ok'
|
|
3001
|
+
: 'fail',
|
|
3002
|
+
'Cloudflare setup',
|
|
3003
|
+
'needs tunnel name, config, token file, or tunnel token'
|
|
3004
|
+
);
|
|
3005
|
+
} else if (tunnel === 'ngrok') {
|
|
3006
|
+
record(stableHostname ? 'ok' : 'fail', 'Hostname', stableHostname || 'required for ngrok mode');
|
|
3007
|
+
record(ngrokPath ? 'ok' : 'fail', 'ngrok', ngrokPath || 'not found on PATH; install ngrok and run ngrok config add-authtoken <token>');
|
|
3008
|
+
} else if (tunnel === 'tailscale') {
|
|
3009
|
+
record(stableHostname ? 'ok' : 'fail', 'Hostname', stableHostname || 'required for Tailscale Funnel mode');
|
|
3010
|
+
record(tailscalePath ? 'ok' : 'fail', 'tailscale', tailscalePath || 'not found on PATH; install Tailscale and enable Funnel');
|
|
3011
|
+
} else {
|
|
3012
|
+
record('fail', 'Tunnel', `unknown tunnel mode: ${tunnel}`);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
const failures = checks.filter((status) => status === 'fail').length;
|
|
3016
|
+
const warnings = checks.filter((status) => status === 'warn').length;
|
|
3017
|
+
console.log('');
|
|
3018
|
+
if (failures) {
|
|
3019
|
+
statusLine('warn', `${failures} blocker${failures === 1 ? '' : 's'} and ${warnings} warning${warnings === 1 ? '' : 's'} found.`);
|
|
3020
|
+
process.exitCode = 1;
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
statusLine('ok', warnings ? `Ready with ${warnings} warning${warnings === 1 ? '' : 's'}.` : 'Ready.');
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
function normalizeSetupChoice(value, allowed, fallback) {
|
|
3027
|
+
const normalized = value.trim().toLowerCase();
|
|
3028
|
+
if (!normalized) return fallback;
|
|
3029
|
+
const match = allowed.find((item) => item === normalized || item.startsWith(normalized));
|
|
3030
|
+
return match ?? fallback;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
async function ask(rl, question, fallback = '') {
|
|
3034
|
+
const suffix = fallback ? ` ${paint('dim', `[${fallback}]`)}` : '';
|
|
3035
|
+
const hint = fallback ? `${paint('dim', '> Enter to proceed with default')}\n` : '';
|
|
3036
|
+
const answer = await rl.question(`${paint('cyan', '?')} ${question}${suffix}\n${hint}> `);
|
|
3037
|
+
return answer.trim() || fallback;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
function tunnelChoiceFromProfile(profile, fallback = 'cloudflare') {
|
|
3041
|
+
if (profile?.tunnel === 'ngrok') return 'ngrok';
|
|
3042
|
+
if (profile?.tunnel === 'cloudflare-named') return 'stable';
|
|
3043
|
+
if (profile?.tunnel === 'tailscale') return 'tailscale';
|
|
3044
|
+
if (profile?.tunnel === 'none') return 'local';
|
|
3045
|
+
if (profile?.tunnel === 'cloudflare') return 'cloudflare';
|
|
3046
|
+
return fallback;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
function tunnelModeFromChoice(choice) {
|
|
3050
|
+
if (choice === 'quick' || choice === 'cloudflare') return 'cloudflare';
|
|
3051
|
+
if (choice === 'stable') return 'cloudflare-named';
|
|
3052
|
+
if (choice === 'tailscale') return 'tailscale';
|
|
3053
|
+
if (choice === 'local') return 'none';
|
|
3054
|
+
return choice;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function hasExplicitTunnelInput(args) {
|
|
3058
|
+
return Boolean(
|
|
3059
|
+
args.tunnel ||
|
|
3060
|
+
args.noProfile ||
|
|
3061
|
+
process.env.CODEXFLOW_TUNNEL
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
async function collectTunnelPreference(rl, defaults, profile, options = {}) {
|
|
3066
|
+
const defaultTunnel = options.defaultTunnel ?? tunnelChoiceFromProfile(profile, 'cloudflare');
|
|
3067
|
+
const tunnelAnswer = await ask(rl, 'Tunnel: cloudflare, ngrok, tailscale, stable, or local?', defaultTunnel);
|
|
3068
|
+
const tunnelChoice = normalizeSetupChoice(tunnelAnswer, ['cloudflare', 'quick', 'ngrok', 'tailscale', 'stable', 'local'], defaultTunnel);
|
|
3069
|
+
const tunnel = tunnelModeFromChoice(tunnelChoice);
|
|
3070
|
+
let hostname = '';
|
|
3071
|
+
let tunnelName = '';
|
|
3072
|
+
let ngrokConfig = '';
|
|
3073
|
+
let cloudflareConfig = '';
|
|
3074
|
+
let cloudflareTokenFile = '';
|
|
3075
|
+
|
|
3076
|
+
if (tunnel === 'ngrok') {
|
|
3077
|
+
hostname = await ask(
|
|
3078
|
+
rl,
|
|
3079
|
+
'Ngrok domain or URL, without /mcp',
|
|
3080
|
+
optionValue(defaults, profile, 'hostname', ['CODEXFLOW_PUBLIC_HOSTNAME', 'CODEXFLOW_HOSTNAME', 'NGROK_DOMAIN'], '')
|
|
3081
|
+
);
|
|
3082
|
+
if (!hostname) throw new Error('Ngrok setup needs your reserved domain, for example name.ngrok-free.dev.');
|
|
3083
|
+
hostname = normalizePublicHostname(hostname);
|
|
3084
|
+
ngrokConfig = optionValue(defaults, profile, 'ngrokConfig', ['NGROK_CONFIG', 'CODEXFLOW_NGROK_CONFIG'], '');
|
|
3085
|
+
} else if (tunnel === 'cloudflare-named') {
|
|
3086
|
+
hostname = await ask(
|
|
3087
|
+
rl,
|
|
3088
|
+
'Stable Cloudflare hostname, without /mcp',
|
|
3089
|
+
optionValue(defaults, profile, 'hostname', ['CODEXFLOW_PUBLIC_HOSTNAME', 'CODEXFLOW_HOSTNAME'], '')
|
|
3090
|
+
);
|
|
3091
|
+
if (!hostname) throw new Error('Stable public URL setup needs a real hostname, for example codexflow.yourdomain.com.');
|
|
3092
|
+
hostname = normalizePublicHostname(hostname);
|
|
3093
|
+
tunnelName = await ask(rl, 'Cloudflare tunnel name', optionValue(defaults, profile, 'tunnelName', ['CODEXFLOW_TUNNEL_NAME', 'CLOUDFLARE_TUNNEL_NAME'], 'codexflow'));
|
|
3094
|
+
cloudflareConfig = optionValue(defaults, profile, 'cloudflareConfig', ['CODEXFLOW_CLOUDFLARE_CONFIG', 'CLOUDFLARE_TUNNEL_CONFIG'], '');
|
|
3095
|
+
cloudflareTokenFile = optionValue(defaults, profile, 'cloudflareTokenFile', ['CODEXFLOW_CLOUDFLARE_TUNNEL_TOKEN_FILE', 'CLOUDFLARE_TUNNEL_TOKEN_FILE'], '');
|
|
3096
|
+
} else if (tunnel === 'tailscale') {
|
|
3097
|
+
hostname = await ask(
|
|
3098
|
+
rl,
|
|
3099
|
+
'Tailscale Funnel hostname, without /mcp',
|
|
3100
|
+
optionValue(defaults, profile, 'hostname', ['CODEXFLOW_PUBLIC_HOSTNAME', 'CODEXFLOW_HOSTNAME', 'TAILSCALE_FUNNEL_HOSTNAME'], '')
|
|
3101
|
+
);
|
|
3102
|
+
if (!hostname) throw new Error('Tailscale setup needs your Funnel hostname, for example machine.tailnet.ts.net.');
|
|
3103
|
+
hostname = normalizePublicHostname(hostname);
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
return {
|
|
3107
|
+
tunnel,
|
|
3108
|
+
hostname,
|
|
3109
|
+
tunnelName,
|
|
3110
|
+
ngrokConfig,
|
|
3111
|
+
cloudflareConfig,
|
|
3112
|
+
cloudflareTokenFile
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
function applyTunnelPreferenceToArgs(args, preference) {
|
|
3117
|
+
args.tunnel = preference.tunnel;
|
|
3118
|
+
if (preference.hostname) args.hostname = preference.hostname;
|
|
3119
|
+
if (preference.tunnelName) args.tunnelName = preference.tunnelName;
|
|
3120
|
+
if (preference.ngrokConfig) args.ngrokConfig = preference.ngrokConfig;
|
|
3121
|
+
if (preference.cloudflareConfig) args.cloudflareConfig = preference.cloudflareConfig;
|
|
3122
|
+
if (preference.cloudflareTokenFile) args.cloudflareTokenFile = preference.cloudflareTokenFile;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
function profileFromPreference(root, args, profile, preference) {
|
|
3126
|
+
const mode = optionValue(args, profile, 'mode', ['CODEXFLOW_MODE'], 'agent');
|
|
3127
|
+
const port = String(optionValue(args, profile, 'port', ['CODEXFLOW_PORT'], '8787'));
|
|
3128
|
+
const bash = optionValue(args, profile, 'bash', ['CODEXFLOW_BASH_MODE'], '');
|
|
3129
|
+
const bashTranscript = bashTranscriptOption(args, profile);
|
|
3130
|
+
const codexSessions = codexSessionsOption(args, profile);
|
|
3131
|
+
const codexDir = optionValue(args, profile, 'codexDir', ['CODEXFLOW_CODEX_DIR'], '');
|
|
3132
|
+
const { bashSession, requireBashSession } = bashSessionOptions(args, profile);
|
|
3133
|
+
const write = optionalWriteOption(args, profile, mode);
|
|
3134
|
+
const toolMode = optionValue(args, profile, 'toolMode', ['CODEXFLOW_TOOL_MODE'], '');
|
|
3135
|
+
const widgetDomain = optionValue(args, profile, 'widgetDomain', ['CODEXFLOW_WIDGET_DOMAIN'], '');
|
|
3136
|
+
const existingToken = optionValue(args, profile, 'token', ['CODEXFLOW_HTTP_TOKEN', 'CODEBASE_BRIDGE_HTTP_TOKEN'], '');
|
|
3137
|
+
const token = preference.tunnel === 'none' ? existingToken : stableToken(existingToken);
|
|
3138
|
+
return {
|
|
3139
|
+
...((args.allowRoots?.length || profile.allowRoots?.length) ? { allowRoots: [...new Set([...(profile.allowRoots ?? []), ...(args.allowRoots ?? [])].map(realDir))] } : {}),
|
|
3140
|
+
port,
|
|
3141
|
+
mode,
|
|
3142
|
+
tunnel: preference.tunnel,
|
|
3143
|
+
...(preference.hostname ? { hostname: preference.hostname } : {}),
|
|
3144
|
+
...(preference.tunnelName ? { tunnelName: preference.tunnelName } : {}),
|
|
3145
|
+
...(preference.ngrokConfig ? { ngrokConfig: preference.ngrokConfig } : {}),
|
|
3146
|
+
...(preference.cloudflareConfig ? { cloudflareConfig: preference.cloudflareConfig } : {}),
|
|
3147
|
+
...(preference.cloudflareTokenFile ? { cloudflareTokenFile: preference.cloudflareTokenFile } : {}),
|
|
3148
|
+
...(token ? { token } : {}),
|
|
3149
|
+
...(bash ? { bash } : {}),
|
|
3150
|
+
...(bashTranscript !== 'compact' ? { bashTranscript } : {}),
|
|
3151
|
+
...(codexSessions !== 'off' ? { codexSessions } : {}),
|
|
3152
|
+
...(codexDir ? { codexDir } : {}),
|
|
3153
|
+
...(bashSession ? { bashSession } : {}),
|
|
3154
|
+
...(requireBashSession ? { requireBashSession: true } : {}),
|
|
3155
|
+
...(write ? { write } : {}),
|
|
3156
|
+
...(toolMode ? { toolMode } : {}),
|
|
3157
|
+
...(widgetDomain ? { widgetDomain } : {}),
|
|
3158
|
+
...toolCardsProfileEntry(args, profile),
|
|
3159
|
+
...(args.noInstallCloudflared ? { noInstallCloudflared: true } : {}),
|
|
3160
|
+
root
|
|
3161
|
+
};
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
async function maybeConfigureFirstRun(root, args, profile) {
|
|
3165
|
+
if (profile.profilePath || args.nonInteractive || !process.stdin.isTTY || !process.stdout.isTTY || process.env.CI || hasExplicitTunnelInput(args)) {
|
|
3166
|
+
return profile;
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
const reusableProfiles = listWorkspaceProfiles().filter((item) => item.root !== root);
|
|
3170
|
+
if (reusableProfiles.length) {
|
|
3171
|
+
const shown = reusableProfiles.slice(0, 9);
|
|
3172
|
+
printBox('Saved setups', [
|
|
3173
|
+
'No saved settings exist for this workspace, but CodexFlow found saved setups from other workspaces.',
|
|
3174
|
+
...shown.map((item, index) => profileOneLine(item, index + 1)),
|
|
3175
|
+
'Use a number to reuse one here, or type new to choose a fresh tunnel.'
|
|
3176
|
+
]);
|
|
3177
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3178
|
+
try {
|
|
3179
|
+
const answer = await ask(rl, 'Use saved setup number, or new?', shown.length === 1 ? '1' : 'new');
|
|
3180
|
+
const normalized = answer.trim().toLowerCase();
|
|
3181
|
+
const selectedIndex = Number(normalized);
|
|
3182
|
+
if (Number.isInteger(selectedIndex) && selectedIndex >= 1 && selectedIndex <= shown.length) {
|
|
3183
|
+
const selected = shown[selectedIndex - 1];
|
|
3184
|
+
const payload = reusableProfilePayload(selected, {
|
|
3185
|
+
port: String(optionValue(args, selected, 'port', ['CODEXFLOW_PORT'], selected.port ?? '8787')),
|
|
3186
|
+
mode: optionValue(args, selected, 'mode', ['CODEXFLOW_MODE'], selected.mode ?? 'agent')
|
|
3187
|
+
});
|
|
3188
|
+
const savedPath = saveWorkspaceProfile(root, payload);
|
|
3189
|
+
statusLine('ok', `Saved workspace settings from ${selected.root}: ${savedPath}`);
|
|
3190
|
+
return loadWorkspaceProfile(root);
|
|
3191
|
+
}
|
|
3192
|
+
} finally {
|
|
3193
|
+
rl.close();
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
printBox('Automatic start', [
|
|
3198
|
+
'No saved tunnel preference exists for this workspace.',
|
|
3199
|
+
'Choose once now. CodexFlow will reuse this choice on future codexflow runs until you change or delete it with codexflow settings.'
|
|
3200
|
+
]);
|
|
3201
|
+
|
|
3202
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3203
|
+
try {
|
|
3204
|
+
const preference = await collectTunnelPreference(rl, args, profile, { defaultTunnel: 'cloudflare' });
|
|
3205
|
+
applyTunnelPreferenceToArgs(args, preference);
|
|
3206
|
+
const saveAnswer = await ask(rl, 'Save this as the default for this workspace?', 'yes');
|
|
3207
|
+
if (!['n', 'no'].includes(saveAnswer.trim().toLowerCase())) {
|
|
3208
|
+
const savedPath = saveWorkspaceProfile(root, profileFromPreference(root, args, profile, preference));
|
|
3209
|
+
statusLine('ok', `Saved workspace settings: ${savedPath}`);
|
|
3210
|
+
return loadWorkspaceProfile(root);
|
|
3211
|
+
}
|
|
3212
|
+
return profileFromPreference(root, args, profile, preference);
|
|
3213
|
+
} finally {
|
|
3214
|
+
rl.close();
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
function commandPreview(args) {
|
|
3219
|
+
return shellCommandPreview(['codexflow', ...args]);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
async function runSetupWizard(argv) {
|
|
3223
|
+
if (!process.stdin.isTTY) {
|
|
3224
|
+
throw new Error('codexflow needs an interactive terminal. Use codexflow --root /path/to/repo for non-interactive scripts.');
|
|
3225
|
+
}
|
|
3226
|
+
const defaults = parseArgs(argv);
|
|
3227
|
+
const defaultRoot = path.resolve(expandHome(defaults.root ?? process.env.CODEXFLOW_ROOT ?? process.cwd()));
|
|
3228
|
+
|
|
3229
|
+
printBox('CodexFlow setup', [
|
|
3230
|
+
'This wizard prepares a ChatGPT connector for the folder you choose.',
|
|
3231
|
+
'Press Enter to accept defaults. Stable tunnel choices are saved per workspace under ~/.codexflow.'
|
|
3232
|
+
]);
|
|
3233
|
+
|
|
3234
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3235
|
+
try {
|
|
3236
|
+
const rootInput = await ask(rl, 'Where is your project located?', defaultRoot);
|
|
3237
|
+
const root = realDir(rootInput);
|
|
3238
|
+
const profile = defaults.noProfile ? {} : loadWorkspaceProfile(root);
|
|
3239
|
+
if (profile.profilePath) {
|
|
3240
|
+
statusLine('ok', `Loaded saved profile: ${profile.profilePath}`);
|
|
3241
|
+
printSavedProfileHint(profile);
|
|
3242
|
+
}
|
|
3243
|
+
const savedProjectRoot = profile.allowRoots?.[0] || defaults.allowRoots?.[0] || path.dirname(root);
|
|
3244
|
+
const projectRootInput = await ask(rl, 'Folder containing the projects you want in the web picker', savedProjectRoot);
|
|
3245
|
+
const projectRoot = realDir(projectRootInput);
|
|
3246
|
+
defaults.allowRoots = [...new Set([...(defaults.allowRoots ?? []), ...(profile.allowRoots ?? []), projectRoot])];
|
|
3247
|
+
|
|
3248
|
+
const savedTunnel = optionValue(defaults, profile, 'tunnel', ['CODEXFLOW_TUNNEL'], 'cloudflare');
|
|
3249
|
+
const defaultTunnel = savedTunnel === 'cloudflare-named'
|
|
3250
|
+
? 'stable'
|
|
3251
|
+
: savedTunnel === 'ngrok'
|
|
3252
|
+
? 'ngrok'
|
|
3253
|
+
: savedTunnel === 'tailscale'
|
|
3254
|
+
? 'tailscale'
|
|
3255
|
+
: savedTunnel === 'none'
|
|
3256
|
+
? 'local'
|
|
3257
|
+
: 'quick';
|
|
3258
|
+
const defaultPort = String(optionValue(defaults, profile, 'port', ['CODEXFLOW_PORT'], '8787'));
|
|
3259
|
+
const defaultMode = normalizeSetupChoice(optionValue(defaults, profile, 'mode', ['CODEXFLOW_MODE'], 'agent'), ['agent', 'handoff', 'pro'], 'agent');
|
|
3260
|
+
|
|
3261
|
+
const port = normalizePort(await ask(rl, 'Which local port should CodexFlow use?', defaultPort));
|
|
3262
|
+
const modeAnswer = await ask(rl, 'Mode: agent, handoff, or pro?', defaultMode);
|
|
3263
|
+
const mode = normalizeSetupChoice(modeAnswer, ['agent', 'handoff', 'pro'], defaultMode);
|
|
3264
|
+
|
|
3265
|
+
printBox('Public URL', [
|
|
3266
|
+
'ChatGPT needs an HTTPS URL it can reach.',
|
|
3267
|
+
'quick = CodexFlow creates a Cloudflare quick tunnel for demos and local work.',
|
|
3268
|
+
'stable = use your own domain with a Cloudflare named tunnel so the ChatGPT app URL does not change.',
|
|
3269
|
+
'ngrok = use your ngrok free dev domain, for example https://name.ngrok-free.dev.',
|
|
3270
|
+
'tailscale = use Tailscale Funnel, for example https://device.tailnet.ts.net.',
|
|
3271
|
+
'local = no tunnel, only useful for local MCP clients that can reach 127.0.0.1.'
|
|
3272
|
+
]);
|
|
3273
|
+
|
|
3274
|
+
const tunnelAnswer = await ask(rl, 'Public access: quick, stable, ngrok, tailscale, or local?', defaultTunnel);
|
|
3275
|
+
const tunnelChoice = normalizeSetupChoice(tunnelAnswer, ['quick', 'stable', 'ngrok', 'tailscale', 'local'], defaultTunnel);
|
|
3276
|
+
const args = ['start', '--root', root, '--port', port, '--mode', mode];
|
|
3277
|
+
for (const allowRoot of defaults.allowRoots) args.push('--allow-root', allowRoot);
|
|
3278
|
+
const bash = optionValue(defaults, profile, 'bash', ['CODEXFLOW_BASH_MODE'], '');
|
|
3279
|
+
const bashTranscript = bashTranscriptOption(defaults, profile);
|
|
3280
|
+
const codexSessions = codexSessionsOption(defaults, profile);
|
|
3281
|
+
const codexDir = optionValue(defaults, profile, 'codexDir', ['CODEXFLOW_CODEX_DIR'], '');
|
|
3282
|
+
const write = optionalWriteOption(defaults, profile, mode);
|
|
3283
|
+
const toolMode = optionalChoice('tool-mode', optionValue(defaults, profile, 'toolMode', ['CODEXFLOW_TOOL_MODE'], ''), ['minimal', 'standard', 'full']);
|
|
3284
|
+
const widgetDomain = optionValue(defaults, profile, 'widgetDomain', ['CODEXFLOW_WIDGET_DOMAIN'], '');
|
|
3285
|
+
const toolCardsEntry = toolCardsProfileEntry(defaults, profile);
|
|
3286
|
+
if (bash) args.push('--bash', bash);
|
|
3287
|
+
if (bashTranscript !== 'compact') args.push('--bash-transcript', bashTranscript);
|
|
3288
|
+
if (codexSessions !== 'off') args.push('--codex-sessions', codexSessions);
|
|
3289
|
+
if (codexDir) args.push('--codex-dir', codexDir);
|
|
3290
|
+
const { bashSession, requireBashSession } = bashSessionOptions(defaults, profile);
|
|
3291
|
+
if (bashSession) args.push('--bash-session', bashSession);
|
|
3292
|
+
if (requireBashSession) args.push('--require-bash-session');
|
|
3293
|
+
if (write) args.push('--write', write);
|
|
3294
|
+
if (toolMode) args.push('--tool-mode', toolMode);
|
|
3295
|
+
if (widgetDomain) args.push('--widget-domain', widgetDomain);
|
|
3296
|
+
args.push(...toolCardsCliArgs(defaults, profile));
|
|
3297
|
+
if (defaults.noInstallCloudflared) args.push('--no-install-cloudflared');
|
|
3298
|
+
if (defaults.openChatgpt) args.push('--open-chatgpt');
|
|
3299
|
+
if (defaults.noCopyUrl) args.push('--no-copy-url');
|
|
3300
|
+
|
|
3301
|
+
let profileTunnel = 'cloudflare';
|
|
3302
|
+
let profileHostname = '';
|
|
3303
|
+
let profileTunnelName = '';
|
|
3304
|
+
let profileNgrokConfig = '';
|
|
3305
|
+
let profileCloudflareConfig = '';
|
|
3306
|
+
let profileCloudflareTokenFile = '';
|
|
3307
|
+
let profileToken = optionValue(defaults, profile, 'token', ['CODEXFLOW_HTTP_TOKEN', 'CODEBASE_BRIDGE_HTTP_TOKEN'], '');
|
|
3308
|
+
|
|
3309
|
+
if (tunnelChoice === 'local') {
|
|
3310
|
+
profileTunnel = 'none';
|
|
3311
|
+
args.push('--tunnel', 'none');
|
|
3312
|
+
} else if (tunnelChoice === 'stable') {
|
|
3313
|
+
profileTunnel = 'cloudflare-named';
|
|
3314
|
+
let hostname = await ask(
|
|
3315
|
+
rl,
|
|
3316
|
+
'Stable Cloudflare hostname, without /mcp',
|
|
3317
|
+
optionValue(defaults, profile, 'hostname', ['CODEXFLOW_PUBLIC_HOSTNAME', 'CODEXFLOW_HOSTNAME'], '')
|
|
3318
|
+
);
|
|
3319
|
+
if (!hostname) throw new Error('Stable public URL setup needs a real hostname, for example codexflow.yourdomain.com.');
|
|
3320
|
+
hostname = normalizePublicHostname(hostname);
|
|
3321
|
+
profileHostname = hostname;
|
|
3322
|
+
const tunnelName = await ask(rl, 'Cloudflare tunnel name', optionValue(defaults, profile, 'tunnelName', ['CODEXFLOW_TUNNEL_NAME', 'CLOUDFLARE_TUNNEL_NAME'], 'codexflow'));
|
|
3323
|
+
profileTunnelName = tunnelName;
|
|
3324
|
+
args.push('--tunnel', 'cloudflare-named', '--hostname', hostname, '--tunnel-name', tunnelName);
|
|
3325
|
+
profileCloudflareConfig = optionValue(defaults, profile, 'cloudflareConfig', ['CODEXFLOW_CLOUDFLARE_CONFIG', 'CLOUDFLARE_TUNNEL_CONFIG'], '');
|
|
3326
|
+
profileCloudflareTokenFile = optionValue(defaults, profile, 'cloudflareTokenFile', ['CODEXFLOW_CLOUDFLARE_TUNNEL_TOKEN_FILE', 'CLOUDFLARE_TUNNEL_TOKEN_FILE'], '');
|
|
3327
|
+
if (profileCloudflareConfig) args.push('--cloudflare-config', profileCloudflareConfig);
|
|
3328
|
+
if (profileCloudflareTokenFile) args.push('--cloudflare-token-file', profileCloudflareTokenFile);
|
|
3329
|
+
} else if (tunnelChoice === 'ngrok') {
|
|
3330
|
+
profileTunnel = 'ngrok';
|
|
3331
|
+
let hostname = await ask(
|
|
3332
|
+
rl,
|
|
3333
|
+
'Ngrok domain or URL, without /mcp',
|
|
3334
|
+
optionValue(defaults, profile, 'hostname', ['CODEXFLOW_PUBLIC_HOSTNAME', 'CODEXFLOW_HOSTNAME', 'NGROK_DOMAIN'], '')
|
|
3335
|
+
);
|
|
3336
|
+
if (!hostname) throw new Error('Ngrok setup needs your reserved domain, for example name.ngrok-free.dev.');
|
|
3337
|
+
hostname = normalizePublicHostname(hostname);
|
|
3338
|
+
profileHostname = hostname;
|
|
3339
|
+
args.push('--tunnel', 'ngrok', '--hostname', hostname);
|
|
3340
|
+
const ngrokConfig = optionValue(defaults, profile, 'ngrokConfig', ['NGROK_CONFIG', 'CODEXFLOW_NGROK_CONFIG'], '');
|
|
3341
|
+
if (ngrokConfig) {
|
|
3342
|
+
profileNgrokConfig = ngrokConfig;
|
|
3343
|
+
args.push('--ngrok-config', ngrokConfig);
|
|
3344
|
+
}
|
|
3345
|
+
} else if (tunnelChoice === 'tailscale') {
|
|
3346
|
+
profileTunnel = 'tailscale';
|
|
3347
|
+
let hostname = await ask(
|
|
3348
|
+
rl,
|
|
3349
|
+
'Tailscale Funnel hostname, without /mcp',
|
|
3350
|
+
optionValue(defaults, profile, 'hostname', ['CODEXFLOW_PUBLIC_HOSTNAME', 'CODEXFLOW_HOSTNAME', 'TAILSCALE_FUNNEL_HOSTNAME'], '')
|
|
3351
|
+
);
|
|
3352
|
+
if (!hostname) throw new Error('Tailscale setup needs your Funnel hostname, for example machine.tailnet.ts.net.');
|
|
3353
|
+
hostname = normalizePublicHostname(hostname);
|
|
3354
|
+
profileHostname = hostname;
|
|
3355
|
+
args.push('--tunnel', 'tailscale', '--hostname', hostname);
|
|
3356
|
+
} else {
|
|
3357
|
+
profileTunnel = 'cloudflare';
|
|
3358
|
+
args.push('--tunnel', 'cloudflare');
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
if (profileTunnel !== 'none') {
|
|
3362
|
+
profileToken = await ask(rl, 'CodexFlow auth token for this workspace', stableToken(profileToken));
|
|
3363
|
+
if (profileToken) args.push('--token', profileToken);
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
const saveDefault = defaults.noSaveConfig ? 'no' : 'yes';
|
|
3367
|
+
const saveAnswer = await ask(rl, 'Save this setup for future runs from this workspace?', saveDefault);
|
|
3368
|
+
const shouldSave = !['n', 'no'].includes(saveAnswer.trim().toLowerCase());
|
|
3369
|
+
if (shouldSave) {
|
|
3370
|
+
const savedPath = saveWorkspaceProfile(root, {
|
|
3371
|
+
allowRoots: defaults.allowRoots.map(realDir),
|
|
3372
|
+
port,
|
|
3373
|
+
mode,
|
|
3374
|
+
tunnel: profileTunnel,
|
|
3375
|
+
...(profileHostname ? { hostname: profileHostname } : {}),
|
|
3376
|
+
...(profileTunnelName ? { tunnelName: profileTunnelName } : {}),
|
|
3377
|
+
...(profileNgrokConfig ? { ngrokConfig: profileNgrokConfig } : {}),
|
|
3378
|
+
...(profileCloudflareConfig ? { cloudflareConfig: profileCloudflareConfig } : {}),
|
|
3379
|
+
...(profileCloudflareTokenFile ? { cloudflareTokenFile: profileCloudflareTokenFile } : {}),
|
|
3380
|
+
...(profileToken ? { token: profileToken } : {}),
|
|
3381
|
+
...(bash ? { bash } : {}),
|
|
3382
|
+
...(bashTranscript !== 'compact' ? { bashTranscript } : {}),
|
|
3383
|
+
...(codexSessions !== 'off' ? { codexSessions } : {}),
|
|
3384
|
+
...(codexDir ? { codexDir } : {}),
|
|
3385
|
+
...(bashSession ? { bashSession } : {}),
|
|
3386
|
+
...(requireBashSession ? { requireBashSession: true } : {}),
|
|
3387
|
+
...(write ? { write } : {}),
|
|
3388
|
+
...(toolMode ? { toolMode } : {}),
|
|
3389
|
+
...(widgetDomain ? { widgetDomain } : {}),
|
|
3390
|
+
...toolCardsEntry,
|
|
3391
|
+
...(defaults.noInstallCloudflared ? { noInstallCloudflared: true } : {})
|
|
3392
|
+
});
|
|
3393
|
+
statusLine('ok', `Saved workspace profile: ${savedPath}`);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
const startAnswer = await ask(rl, 'Start CodexFlow now?', 'yes');
|
|
3397
|
+
const shouldStart = !['n', 'no'].includes(startAnswer.trim().toLowerCase());
|
|
3398
|
+
console.log('');
|
|
3399
|
+
console.log(paint('bold', 'Command'));
|
|
3400
|
+
console.log(` ${commandPreview(args)}`);
|
|
3401
|
+
console.log('');
|
|
3402
|
+
if (!shouldStart) {
|
|
3403
|
+
console.log('Setup complete. Run the command above when you are ready.');
|
|
3404
|
+
return null;
|
|
3405
|
+
}
|
|
3406
|
+
return args;
|
|
3407
|
+
} finally {
|
|
3408
|
+
rl.close();
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function printProfile(root, profile) {
|
|
3413
|
+
if (!profile.profilePath) {
|
|
3414
|
+
printBox('CodexFlow settings', [
|
|
3415
|
+
labelValue('Workspace', root),
|
|
3416
|
+
'No saved settings for this workspace.',
|
|
3417
|
+
'Run codexflow settings set or codexflow to save a tunnel preference.'
|
|
3418
|
+
]);
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
const safe = sanitizedProfile(profile);
|
|
3422
|
+
printBox('CodexFlow settings', [
|
|
3423
|
+
labelValue('Workspace', root),
|
|
3424
|
+
labelValue('Profile', profile.profilePath),
|
|
3425
|
+
labelValue('Tunnel', safe.tunnel ?? 'cloudflare'),
|
|
3426
|
+
...(safe.hostname ? [labelValue('Hostname', safe.hostname)] : []),
|
|
3427
|
+
...(safe.tunnelName ? [labelValue('Tunnel name', safe.tunnelName)] : []),
|
|
3428
|
+
...(safe.ngrokConfig ? [labelValue('Ngrok config', safe.ngrokConfig)] : []),
|
|
3429
|
+
...(safe.cloudflareConfig ? [labelValue('Cloudflare cfg', safe.cloudflareConfig)] : []),
|
|
3430
|
+
...(safe.cloudflareTokenFile ? [labelValue('CF token file', safe.cloudflareTokenFile)] : []),
|
|
3431
|
+
...(safe.port ? [labelValue('Port', safe.port)] : []),
|
|
3432
|
+
...(safe.mode ? [labelValue('Mode', safe.mode)] : []),
|
|
3433
|
+
...(safe.allowRoots?.length ? [labelValue('Project roots', safe.allowRoots.join(', '))] : []),
|
|
3434
|
+
...(safe.bash ? [labelValue('Bash', safe.bash)] : []),
|
|
3435
|
+
...(safe.write ? [labelValue('Write', safe.write)] : []),
|
|
3436
|
+
...(safe.toolMode ? [labelValue('Tool mode', safe.toolMode)] : []),
|
|
3437
|
+
...(safe.toolCards !== undefined ? [labelValue('Tool cards', safe.toolCards ? 'on' : 'off')] : []),
|
|
3438
|
+
labelValue('Bash transcript', safe.bashTranscript ?? 'compact'),
|
|
3439
|
+
labelValue('Codex sessions', safe.codexSessions ?? 'off'),
|
|
3440
|
+
...(safe.codexDir ? [labelValue('Codex dir', safe.codexDir)] : []),
|
|
3441
|
+
...(safe.bashSession ? [labelValue('Bash session', `${safe.bashSession}${safe.requireBashSession ? ' required' : ''}`)] : []),
|
|
3442
|
+
...(safe.widgetDomain ? [labelValue('Widget origin', safe.widgetDomain)] : []),
|
|
3443
|
+
...(safe.noInstallCloudflared ? [labelValue('cloudflared', 'manual install only')] : []),
|
|
3444
|
+
...(safe.token ? [labelValue('Token', safe.token)] : []),
|
|
3445
|
+
...(safe.cloudflareToken ? [labelValue('Cloudflare token', safe.cloudflareToken)] : [])
|
|
3446
|
+
]);
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
function processIsAlive(pid) {
|
|
3450
|
+
const numericPid = Number(pid);
|
|
3451
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
|
|
3452
|
+
try {
|
|
3453
|
+
process.kill(numericPid, 0);
|
|
3454
|
+
return true;
|
|
3455
|
+
} catch (error) {
|
|
3456
|
+
return Boolean(error && typeof error === 'object' && error.code === 'EPERM');
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
function safeStatusUrl(value) {
|
|
3461
|
+
if (!value) return '';
|
|
3462
|
+
try {
|
|
3463
|
+
const url = new URL(String(value));
|
|
3464
|
+
url.search = '';
|
|
3465
|
+
url.hash = '';
|
|
3466
|
+
return url.toString().replace(/\/$/, '');
|
|
3467
|
+
} catch {
|
|
3468
|
+
return redactForLog(String(value));
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
async function probeRuntimeHealth(runtime, profile) {
|
|
3473
|
+
const localBase = typeof runtime?.localBase === 'string' ? runtime.localBase.trim().replace(/\/+$/, '') : '';
|
|
3474
|
+
if (!localBase) return { status: 'unknown', detail: 'No local health endpoint was recorded.' };
|
|
3475
|
+
|
|
3476
|
+
let healthUrl;
|
|
3477
|
+
try {
|
|
3478
|
+
const parsed = new URL(`${localBase}/healthz`);
|
|
3479
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('unsupported protocol');
|
|
3480
|
+
healthUrl = parsed.toString();
|
|
3481
|
+
} catch {
|
|
3482
|
+
return { status: 'unknown', detail: 'The saved local health endpoint is invalid.' };
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
const token = typeof profile?.token === 'string' && profile.token
|
|
3486
|
+
? profile.token
|
|
3487
|
+
: process.env.CODEXFLOW_HTTP_TOKEN || process.env.CODEBASE_BRIDGE_HTTP_TOKEN || '';
|
|
3488
|
+
try {
|
|
3489
|
+
const response = await fetch(healthUrl, {
|
|
3490
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
3491
|
+
signal: AbortSignal.timeout(2000)
|
|
3492
|
+
});
|
|
3493
|
+
if (response.status === 401) {
|
|
3494
|
+
return { status: 'auth', http_status: response.status, detail: 'Local MCP is reachable but the saved auth token is unavailable.' };
|
|
3495
|
+
}
|
|
3496
|
+
if (!response.ok) {
|
|
3497
|
+
return { status: 'error', http_status: response.status, detail: `Local health endpoint returned HTTP ${response.status}.` };
|
|
3498
|
+
}
|
|
3499
|
+
return { status: 'ok', http_status: response.status, detail: 'Local MCP server is healthy.' };
|
|
3500
|
+
} catch (error) {
|
|
3501
|
+
return {
|
|
3502
|
+
status: 'unreachable',
|
|
3503
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
async function runStatus(argv) {
|
|
3509
|
+
const args = parseArgs(argv);
|
|
3510
|
+
if (args.help) {
|
|
3511
|
+
usage();
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
const root = realDir(args.root ?? process.env.CODEXFLOW_ROOT ?? process.cwd());
|
|
3516
|
+
const profile = args.noProfile ? {} : loadWorkspaceProfile(root);
|
|
3517
|
+
const runtimePath = runtimeStatusPathForRoot(root);
|
|
3518
|
+
let runtime = readJsonFile(runtimePath);
|
|
3519
|
+
if (!runtime || typeof runtime !== 'object' || Array.isArray(runtime) || (runtime.root && runtime.root !== root)) runtime = {};
|
|
3520
|
+
|
|
3521
|
+
const runtimeExists = Object.keys(runtime).length > 0;
|
|
3522
|
+
const active = runtimeExists && processIsAlive(runtime.pid);
|
|
3523
|
+
const state = !runtimeExists ? 'not_running' : active ? 'active' : 'stale';
|
|
3524
|
+
const health = active
|
|
3525
|
+
? await probeRuntimeHealth(runtime, profile)
|
|
3526
|
+
: { status: state === 'stale' ? 'stale' : 'not_running', detail: state === 'stale' ? 'The launcher process is no longer running.' : 'No active runtime record was found.' };
|
|
3527
|
+
const payload = {
|
|
3528
|
+
schema_version: 1,
|
|
3529
|
+
root,
|
|
3530
|
+
state,
|
|
3531
|
+
active,
|
|
3532
|
+
profile: {
|
|
3533
|
+
exists: Boolean(profile.profilePath),
|
|
3534
|
+
path: profile.profilePath ?? null,
|
|
3535
|
+
tunnel: profile.tunnel ?? null,
|
|
3536
|
+
hostname: profile.hostname ?? null,
|
|
3537
|
+
port: profile.port ?? null,
|
|
3538
|
+
mode: profile.mode ?? null,
|
|
3539
|
+
summary: profileSummary(profile) || null
|
|
3540
|
+
},
|
|
3541
|
+
runtime: {
|
|
3542
|
+
exists: runtimeExists,
|
|
3543
|
+
path: runtimeExists ? runtimePath : null,
|
|
3544
|
+
pid: runtime.pid ?? null,
|
|
3545
|
+
updated_at: runtime.updatedAt ?? null,
|
|
3546
|
+
endpoint: safeStatusUrl(runtime.endpoint),
|
|
3547
|
+
local_base: safeStatusUrl(runtime.localBase),
|
|
3548
|
+
status_url: safeStatusUrl(runtime.localStatusUrl),
|
|
3549
|
+
tunnel: runtime.tunnel ?? null,
|
|
3550
|
+
mode: runtime.mode ?? null,
|
|
3551
|
+
bash: runtime.bash ?? null,
|
|
3552
|
+
write: runtime.write ?? null,
|
|
3553
|
+
tool_mode: runtime.toolMode ?? null,
|
|
3554
|
+
tool_cards: runtime.toolCards ?? null
|
|
3555
|
+
},
|
|
3556
|
+
health
|
|
3557
|
+
};
|
|
3558
|
+
|
|
3559
|
+
if (args.json) {
|
|
3560
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3561
|
+
} else {
|
|
3562
|
+
const stateLabel = state === 'active' ? 'active' : state === 'stale' ? 'stale runtime record' : 'not running';
|
|
3563
|
+
printBox('CodexFlow status', [
|
|
3564
|
+
labelValue('Workspace', root),
|
|
3565
|
+
labelValue('State', stateLabel),
|
|
3566
|
+
labelValue('Profile', profile.profilePath ? profileSummary(profile) || profile.profilePath : 'none saved'),
|
|
3567
|
+
...(runtime.pid ? [labelValue('PID', String(runtime.pid))] : []),
|
|
3568
|
+
...(runtime.updatedAt ? [labelValue('Updated', String(runtime.updatedAt))] : []),
|
|
3569
|
+
...(runtime.endpoint ? [labelValue('Server URL', safeStatusUrl(runtime.endpoint))] : []),
|
|
3570
|
+
...(runtime.tunnel ? [labelValue('Tunnel', String(runtime.tunnel))] : []),
|
|
3571
|
+
labelValue('Health', `${health.status} — ${health.detail}`),
|
|
3572
|
+
state === 'active'
|
|
3573
|
+
? 'Use Ctrl+C in the original terminal to stop the active connector.'
|
|
3574
|
+
: 'Run codexflow from this workspace to create a new runtime connection.'
|
|
3575
|
+
]);
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
if (state !== 'active' || !['ok', 'auth', 'unknown'].includes(health.status)) process.exitCode = 1;
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
function printProfileList(profiles = listWorkspaceProfiles()) {
|
|
3582
|
+
if (!profiles.length) {
|
|
3583
|
+
printBox('CodexFlow saved setups', [
|
|
3584
|
+
'No saved workspace settings found.',
|
|
3585
|
+
'Run codexflow or codexflow settings set to create one.'
|
|
3586
|
+
]);
|
|
3587
|
+
return;
|
|
3588
|
+
}
|
|
3589
|
+
printBox('CodexFlow saved setups', profiles.slice(0, 50).map((profile, index) => profileOneLine(profile, index + 1)));
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
function saveSettingsFromArgs(root, args, profile) {
|
|
3593
|
+
if (args.cloudflareToken !== undefined) {
|
|
3594
|
+
throw new Error('codexflow settings set does not save raw --cloudflare-token. Save it to a local file and use --cloudflare-token-file <path>; start still accepts --cloudflare-token for a single launch.');
|
|
3595
|
+
}
|
|
3596
|
+
const tunnel = optionValue(args, profile, 'tunnel', ['CODEXFLOW_TUNNEL'], profile.tunnel ?? 'cloudflare');
|
|
3597
|
+
if (!['none', 'cloudflare', 'cloudflare-named', 'ngrok', 'tailscale'].includes(tunnel)) {
|
|
3598
|
+
throw new Error('--tunnel must be none, cloudflare, cloudflare-named, ngrok, or tailscale');
|
|
3599
|
+
}
|
|
3600
|
+
const needsHostname = tunnel === 'ngrok' || tunnel === 'cloudflare-named' || tunnel === 'tailscale';
|
|
3601
|
+
const rawHostname = needsHostname ? (args.hostname ?? args.url ?? profile.hostname ?? '') : '';
|
|
3602
|
+
const hostname = needsHostname ? normalizePublicHostname(rawHostname) : String(rawHostname ?? '').trim();
|
|
3603
|
+
if (needsHostname && !hostname) {
|
|
3604
|
+
throw new Error('--hostname is required for ngrok, cloudflare-named, and tailscale settings.');
|
|
3605
|
+
}
|
|
3606
|
+
const mode = optionValue(args, profile, 'mode', ['CODEXFLOW_MODE'], profile.mode ?? 'agent');
|
|
3607
|
+
if (!['agent', 'handoff', 'pro'].includes(mode)) {
|
|
3608
|
+
throw new Error('--mode must be agent, handoff, or pro');
|
|
3609
|
+
}
|
|
3610
|
+
const toolMode = optionalChoice('tool-mode', optionValue(args, profile, 'toolMode', ['CODEXFLOW_TOOL_MODE'], profile.toolMode ?? ''), ['minimal', 'standard', 'full']);
|
|
3611
|
+
const widgetDomain = optionValue(args, profile, 'widgetDomain', ['CODEXFLOW_WIDGET_DOMAIN'], profile.widgetDomain ?? '');
|
|
3612
|
+
const port = normalizePort(optionValue(args, profile, 'port', ['CODEXFLOW_PORT'], profile.port ?? '8787'));
|
|
3613
|
+
const bashTranscript = bashTranscriptOption(args, profile);
|
|
3614
|
+
const codexSessions = codexSessionsOption(args, profile);
|
|
3615
|
+
const codexDir = optionValue(args, profile, 'codexDir', ['CODEXFLOW_CODEX_DIR'], profile.codexDir ?? '');
|
|
3616
|
+
const { bashSession, requireBashSession } = bashSessionOptions(args, profile);
|
|
3617
|
+
const write = writeOption(args, profile, mode);
|
|
3618
|
+
const bash = optionalChoice('bash', optionValue(args, profile, 'bash', ['CODEXFLOW_BASH_MODE'], profile.bash ?? ''), ['off', 'safe', 'full']);
|
|
3619
|
+
const tunnelName = tunnel === 'cloudflare-named' ? (args.tunnelName ?? profile.tunnelName ?? '') : '';
|
|
3620
|
+
const ngrokConfig = tunnel === 'ngrok'
|
|
3621
|
+
? resolveConfigPath(root, optionValue(args, profile, 'ngrokConfig', ['NGROK_CONFIG', 'CODEXFLOW_NGROK_CONFIG'], ''))
|
|
3622
|
+
: '';
|
|
3623
|
+
const cloudflareConfig = tunnel === 'cloudflare-named'
|
|
3624
|
+
? resolveConfigPath(root, optionValue(args, profile, 'cloudflareConfig', ['CODEXFLOW_CLOUDFLARE_CONFIG', 'CLOUDFLARE_TUNNEL_CONFIG'], ''))
|
|
3625
|
+
: '';
|
|
3626
|
+
const cloudflareTokenFile = tunnel === 'cloudflare-named'
|
|
3627
|
+
? resolveConfigPath(root, optionValue(args, profile, 'cloudflareTokenFile', ['CODEXFLOW_CLOUDFLARE_TUNNEL_TOKEN_FILE', 'CLOUDFLARE_TUNNEL_TOKEN_FILE'], ''))
|
|
3628
|
+
: '';
|
|
3629
|
+
const token = tunnel === 'none'
|
|
3630
|
+
? optionValue(args, profile, 'token', ['CODEXFLOW_HTTP_TOKEN', 'CODEBASE_BRIDGE_HTTP_TOKEN'], profile.token ?? '')
|
|
3631
|
+
: stableToken(optionValue(args, profile, 'token', ['CODEXFLOW_HTTP_TOKEN', 'CODEBASE_BRIDGE_HTTP_TOKEN'], profile.token ?? ''));
|
|
3632
|
+
const allowRoots = [...new Set([...(profile.allowRoots ?? []), ...(args.allowRoots ?? [])].map(realDir))];
|
|
3633
|
+
const savedPath = saveWorkspaceProfile(root, {
|
|
3634
|
+
...(allowRoots.length ? { allowRoots } : {}),
|
|
3635
|
+
port,
|
|
3636
|
+
mode,
|
|
3637
|
+
tunnel,
|
|
3638
|
+
...(hostname ? { hostname } : {}),
|
|
3639
|
+
...(tunnelName ? { tunnelName } : {}),
|
|
3640
|
+
...(ngrokConfig ? { ngrokConfig } : {}),
|
|
3641
|
+
...(cloudflareConfig ? { cloudflareConfig } : {}),
|
|
3642
|
+
...(cloudflareTokenFile ? { cloudflareTokenFile } : {}),
|
|
3643
|
+
...(token ? { token } : {}),
|
|
3644
|
+
...(bash ? { bash } : {}),
|
|
3645
|
+
...(bashTranscript !== 'compact' ? { bashTranscript } : {}),
|
|
3646
|
+
...(codexSessions !== 'off' ? { codexSessions } : {}),
|
|
3647
|
+
...(codexDir ? { codexDir } : {}),
|
|
3648
|
+
...(bashSession ? { bashSession } : {}),
|
|
3649
|
+
...(requireBashSession ? { requireBashSession: true } : {}),
|
|
3650
|
+
...(mode !== 'agent' || args.write !== undefined || profile.write ? { write } : {}),
|
|
3651
|
+
...(toolMode ? { toolMode } : {}),
|
|
3652
|
+
...(widgetDomain ? { widgetDomain } : {}),
|
|
3653
|
+
...toolCardsProfileEntry(args, profile),
|
|
3654
|
+
...(args.noInstallCloudflared ?? profile.noInstallCloudflared ? { noInstallCloudflared: true } : {})
|
|
3655
|
+
});
|
|
3656
|
+
statusLine('ok', `Saved workspace settings: ${savedPath}`);
|
|
3657
|
+
printProfile(root, loadWorkspaceProfile(root));
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
async function chooseReusableProfile(rl, currentRoot, profiles = listWorkspaceProfiles()) {
|
|
3661
|
+
const reusable = profiles.filter((item) => item.root !== currentRoot);
|
|
3662
|
+
if (!reusable.length) return null;
|
|
3663
|
+
printProfileList(reusable);
|
|
3664
|
+
const answer = await ask(rl, 'Use saved setup number?', reusable.length === 1 ? '1' : '');
|
|
3665
|
+
const selectedIndex = Number(answer.trim());
|
|
3666
|
+
if (!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > reusable.length) {
|
|
3667
|
+
throw new Error('Invalid saved setup number.');
|
|
3668
|
+
}
|
|
3669
|
+
return reusable[selectedIndex - 1];
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
async function runSettings(argv) {
|
|
3673
|
+
const action = argv[0] && !argv[0].startsWith('--') ? argv[0] : '';
|
|
3674
|
+
const args = parseArgs(action ? argv.slice(1) : argv);
|
|
3675
|
+
if (args.help) {
|
|
3676
|
+
usage();
|
|
3677
|
+
return;
|
|
3678
|
+
}
|
|
3679
|
+
const root = realDir(args.root ?? process.env.CODEXFLOW_ROOT ?? process.cwd());
|
|
3680
|
+
const profile = args.noProfile ? {} : loadWorkspaceProfile(root);
|
|
3681
|
+
|
|
3682
|
+
if (action === 'list' || action === 'ls') {
|
|
3683
|
+
printProfileList();
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
if (action === 'show' || (!action && !process.stdin.isTTY)) {
|
|
3688
|
+
printProfile(root, profile);
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
if (action === 'delete' || action === 'reset' || action === 'remove') {
|
|
3693
|
+
if (!profile.profilePath) {
|
|
3694
|
+
statusLine('warn', 'No saved settings exist for this workspace.');
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
if (!args.yes && process.stdin.isTTY) {
|
|
3698
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3699
|
+
try {
|
|
3700
|
+
const answer = await ask(rl, `Delete saved settings for ${root}?`, 'no');
|
|
3701
|
+
if (!['y', 'yes'].includes(answer.trim().toLowerCase())) {
|
|
3702
|
+
statusLine('warn', 'Settings delete cancelled.');
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
} finally {
|
|
3706
|
+
rl.close();
|
|
3707
|
+
}
|
|
3708
|
+
} else if (!args.yes) {
|
|
3709
|
+
throw new Error('Use codexflow settings delete --yes in non-interactive shells.');
|
|
3710
|
+
}
|
|
3711
|
+
deleteWorkspaceProfile(root);
|
|
3712
|
+
statusLine('ok', 'Deleted saved settings for this workspace.');
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
if (action === 'set') {
|
|
3717
|
+
saveSettingsFromArgs(root, args, profile);
|
|
3718
|
+
return;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
if (action === 'use' || action === 'copy') {
|
|
3722
|
+
const fromRoot = args.fromRoot ? realDir(args.fromRoot) : '';
|
|
3723
|
+
let source = fromRoot ? loadWorkspaceProfile(fromRoot) : null;
|
|
3724
|
+
if (fromRoot && !source.profilePath) {
|
|
3725
|
+
throw new Error(`No saved settings found for --from-root ${fromRoot}`);
|
|
3726
|
+
}
|
|
3727
|
+
if (!source) {
|
|
3728
|
+
if (!process.stdin.isTTY) throw new Error('Use --from-root in non-interactive shells.');
|
|
3729
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3730
|
+
try {
|
|
3731
|
+
source = await chooseReusableProfile(rl, root);
|
|
3732
|
+
} finally {
|
|
3733
|
+
rl.close();
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
if (!source) {
|
|
3737
|
+
statusLine('warn', 'No reusable saved settings found.');
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3740
|
+
const savedPath = saveWorkspaceProfile(root, reusableProfilePayload(source));
|
|
3741
|
+
statusLine('ok', `Saved workspace settings from ${source.root}: ${savedPath}`);
|
|
3742
|
+
printProfile(root, loadWorkspaceProfile(root));
|
|
3743
|
+
return;
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
if (action && !['change', 'edit'].includes(action)) {
|
|
3747
|
+
throw new Error(`Unknown settings action: ${action}`);
|
|
3748
|
+
}
|
|
3749
|
+
|
|
3750
|
+
if (!process.stdin.isTTY) {
|
|
3751
|
+
printProfile(root, profile);
|
|
3752
|
+
return;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
printProfile(root, profile);
|
|
3756
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3757
|
+
try {
|
|
3758
|
+
const selected = await ask(rl, 'Action: set, use, delete, show, list, or exit?', profile.profilePath ? 'show' : 'set');
|
|
3759
|
+
const normalized = normalizeSetupChoice(selected, ['set', 'use', 'delete', 'show', 'list', 'exit'], profile.profilePath ? 'show' : 'set');
|
|
3760
|
+
if (normalized === 'exit') return;
|
|
3761
|
+
if (normalized === 'list') {
|
|
3762
|
+
printProfileList();
|
|
3763
|
+
return;
|
|
3764
|
+
}
|
|
3765
|
+
if (normalized === 'show') {
|
|
3766
|
+
printProfile(root, profile);
|
|
3767
|
+
return;
|
|
3768
|
+
}
|
|
3769
|
+
if (normalized === 'use') {
|
|
3770
|
+
const source = await chooseReusableProfile(rl, root);
|
|
3771
|
+
if (!source) {
|
|
3772
|
+
statusLine('warn', 'No reusable saved settings found.');
|
|
3773
|
+
return;
|
|
3774
|
+
}
|
|
3775
|
+
const savedPath = saveWorkspaceProfile(root, reusableProfilePayload(source));
|
|
3776
|
+
statusLine('ok', `Saved workspace settings from ${source.root}: ${savedPath}`);
|
|
3777
|
+
printProfile(root, loadWorkspaceProfile(root));
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
if (normalized === 'delete') {
|
|
3781
|
+
if (!profile.profilePath) {
|
|
3782
|
+
statusLine('warn', 'No saved settings exist for this workspace.');
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
const answer = await ask(rl, `Delete saved settings for ${root}?`, 'no');
|
|
3786
|
+
if (!['y', 'yes'].includes(answer.trim().toLowerCase())) {
|
|
3787
|
+
statusLine('warn', 'Settings delete cancelled.');
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
deleteWorkspaceProfile(root);
|
|
3791
|
+
statusLine('ok', 'Deleted saved settings for this workspace.');
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
const preference = await collectTunnelPreference(rl, args, profile);
|
|
3796
|
+
const payload = profileFromPreference(root, args, profile, preference);
|
|
3797
|
+
const savedPath = saveWorkspaceProfile(root, payload);
|
|
3798
|
+
statusLine('ok', `Saved workspace settings: ${savedPath}`);
|
|
3799
|
+
printProfile(root, loadWorkspaceProfile(root));
|
|
3800
|
+
} finally {
|
|
3801
|
+
rl.close();
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
function writeControlPrompt() {
|
|
3806
|
+
process.stdout.write('codexflow> ');
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
function runControlPanel(details, cleanup = cleanupChildren, options = {}) {
|
|
3810
|
+
if (options.nonInteractive || !process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve();
|
|
3811
|
+
|
|
3812
|
+
writeControlPrompt();
|
|
3813
|
+
|
|
3814
|
+
process.stdin.setEncoding('utf8');
|
|
3815
|
+
if (typeof process.stdin.setRawMode === 'function') process.stdin.setRawMode(true);
|
|
3816
|
+
process.stdin.resume();
|
|
3817
|
+
|
|
3818
|
+
return new Promise(() => {
|
|
3819
|
+
process.stdin.on('data', (key) => {
|
|
3820
|
+
if (key === '\u0003') {
|
|
3821
|
+
console.log('\nStopping CodexFlow...');
|
|
3822
|
+
cleanup();
|
|
3823
|
+
process.exit(130);
|
|
3824
|
+
}
|
|
3825
|
+
const normalized = key.toLowerCase();
|
|
3826
|
+
if (key === '\r' || key === '\n') {
|
|
3827
|
+
const opened = openUrl(details.chatgptSettingsUrl);
|
|
3828
|
+
console.log(opened ? '\nOpened ChatGPT connector settings. The Server URL is already copied; paste it into Server URL.' : '\nCould not open ChatGPT automatically.');
|
|
3829
|
+
writeControlPrompt();
|
|
3830
|
+
} else if (normalized === 'c') {
|
|
3831
|
+
const copied = copyToClipboard(details.serverUrl);
|
|
3832
|
+
console.log(copied.ok ? `\nServer URL copied with ${copied.command}.` : '\nCould not copy automatically.');
|
|
3833
|
+
writeControlPrompt();
|
|
3834
|
+
} else if (normalized === 'u') {
|
|
3835
|
+
console.log(`\n${details.serverUrl}`);
|
|
3836
|
+
writeControlPrompt();
|
|
3837
|
+
} else if (normalized === 'o') {
|
|
3838
|
+
if (!details.localStatusUrl) {
|
|
3839
|
+
console.log('\nNo local status page URL is available for this run.');
|
|
3840
|
+
} else {
|
|
3841
|
+
const opened = openUrl(details.localStatusUrl);
|
|
3842
|
+
console.log(opened ? '\nOpened local CodexFlow status/settings dashboard.' : `\nCould not open automatically. Open this URL:\n${details.localStatusUrl}`);
|
|
3843
|
+
}
|
|
3844
|
+
writeControlPrompt();
|
|
3845
|
+
} else if (normalized === 'p') {
|
|
3846
|
+
console.log('');
|
|
3847
|
+
printCreateAppFields(details);
|
|
3848
|
+
console.log('');
|
|
3849
|
+
writeControlPrompt();
|
|
3850
|
+
} else if (normalized === 'm') {
|
|
3851
|
+
printModeHelp();
|
|
3852
|
+
console.log('');
|
|
3853
|
+
writeControlPrompt();
|
|
3854
|
+
} else if (normalized === 'h' || normalized === '?') {
|
|
3855
|
+
printControlHelp();
|
|
3856
|
+
writeControlPrompt();
|
|
3857
|
+
} else if (normalized === 'q') {
|
|
3858
|
+
console.log('\nStopping CodexFlow...');
|
|
3859
|
+
cleanup();
|
|
3860
|
+
process.exit(0);
|
|
3861
|
+
}
|
|
3862
|
+
});
|
|
3863
|
+
});
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
async function main() {
|
|
3867
|
+
let argv = process.argv.slice(2);
|
|
3868
|
+
let connectionTest = false;
|
|
3869
|
+
if (argv[0] === '--version' || argv[0] === '-v' || argv[0] === 'version') {
|
|
3870
|
+
console.log(packageVersion());
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
let subcommand = argv[0];
|
|
3874
|
+
if (subcommand === 'inspect' || subcommand === 'review') {
|
|
3875
|
+
await runAnalysisCli(subcommand, argv.slice(1));
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
if (subcommand === 'stable-help') {
|
|
3879
|
+
printStableUrlHelp();
|
|
3880
|
+
return;
|
|
3881
|
+
}
|
|
3882
|
+
if (subcommand === 'setup' || subcommand === 'onboard') {
|
|
3883
|
+
argv = argv.slice(1);
|
|
3884
|
+
subcommand = argv[0];
|
|
3885
|
+
}
|
|
3886
|
+
if (subcommand === 'settings' || subcommand === 'config') {
|
|
3887
|
+
await runSettings(argv.slice(1));
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3890
|
+
if (subcommand === 'status' || subcommand === 'state') {
|
|
3891
|
+
await runStatus(argv.slice(1));
|
|
3892
|
+
return;
|
|
3893
|
+
}
|
|
3894
|
+
if (subcommand === 'execute-handoff' || subcommand === 'execute' || subcommand === 'run-handoff') {
|
|
3895
|
+
await runExecuteHandoff(argv.slice(1));
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
if (subcommand === 'watch-handoff' || subcommand === 'watch') {
|
|
3899
|
+
await runWatchHandoff(argv.slice(1));
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
if (subcommand === 'loop-handoff' || subcommand === 'loop') {
|
|
3903
|
+
await runLoopHandoff(argv.slice(1));
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3906
|
+
if (subcommand === 'pro-bundle' || subcommand === 'bundle') {
|
|
3907
|
+
runHelperScript('pro-bundle.mjs', argv.slice(1));
|
|
3908
|
+
}
|
|
3909
|
+
if (subcommand === 'pro-apply' || subcommand === 'apply') {
|
|
3910
|
+
runHelperScript('pro-apply.mjs', argv.slice(1));
|
|
3911
|
+
}
|
|
3912
|
+
if (subcommand === 'install-cloudflared') {
|
|
3913
|
+
const installArgs = parseArgs(argv.slice(1));
|
|
3914
|
+
if (installArgs.help) {
|
|
3915
|
+
usage();
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
const installedCloudflared = await installCloudflaredLocal();
|
|
3919
|
+
console.log(`cloudflared ready: ${installedCloudflared}`);
|
|
3920
|
+
return;
|
|
3921
|
+
}
|
|
3922
|
+
if (subcommand === 'doctor') {
|
|
3923
|
+
await runDoctor(argv.slice(1));
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
if (argv[0] === 'stable') {
|
|
3927
|
+
argv.shift();
|
|
3928
|
+
argv.unshift('--tunnel', 'cloudflare-named');
|
|
3929
|
+
}
|
|
3930
|
+
if (argv[0] === 'ngrok') {
|
|
3931
|
+
argv.shift();
|
|
3932
|
+
argv.unshift('--tunnel', 'ngrok');
|
|
3933
|
+
}
|
|
3934
|
+
if (argv[0] === 'tailscale') {
|
|
3935
|
+
argv.shift();
|
|
3936
|
+
argv.unshift('--tunnel', 'tailscale');
|
|
3937
|
+
}
|
|
3938
|
+
if (argv[0] === 'connection-test') {
|
|
3939
|
+
connectionTest = true;
|
|
3940
|
+
argv.shift();
|
|
3941
|
+
}
|
|
3942
|
+
if (argv[0] === 'start' || argv[0] === 'connect') argv.shift();
|
|
3943
|
+
if (argv[0] === '--version' || argv[0] === '-v' || argv[0] === 'version') {
|
|
3944
|
+
console.log(packageVersion());
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
if (argv[0] === 'help') argv[0] = '--help';
|
|
3948
|
+
const args = parseArgs(argv);
|
|
3949
|
+
if (connectionTest) {
|
|
3950
|
+
args.mode = 'agent';
|
|
3951
|
+
args.toolMode = 'standard';
|
|
3952
|
+
args.write = 'off';
|
|
3953
|
+
args.bash = 'off';
|
|
3954
|
+
args.toolCards = 'off';
|
|
3955
|
+
args.logRequests = true;
|
|
3956
|
+
}
|
|
3957
|
+
if (args.help) {
|
|
3958
|
+
usage();
|
|
3959
|
+
return;
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
const initialCodexDir = path.resolve(expandHome(args.codexDir ?? process.env.CODEXFLOW_CODEX_DIR ?? path.join(os.homedir(), '.codex')));
|
|
3963
|
+
const discoveredProjects = await discoverCodexProjectDirectories(initialCodexDir);
|
|
3964
|
+
const root = realDir(args.root ?? process.env.CODEXFLOW_ROOT ?? discoveredProjects[0] ?? process.cwd());
|
|
3965
|
+
let profile = args.noProfile ? {} : loadWorkspaceProfile(root);
|
|
3966
|
+
const effectiveArgs = { ...profile, ...args };
|
|
3967
|
+
if (profile.profilePath && !args.noProfile) {
|
|
3968
|
+
statusLine('ok', `Using saved profile: ${profile.profilePath}`);
|
|
3969
|
+
const summary = profileSummary(profile);
|
|
3970
|
+
if (summary) statusLine('ok', `${summary}.`);
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
const tunnel = optionValue(args, profile, 'tunnel', ['CODEXFLOW_TUNNEL'], 'cloudflare');
|
|
3974
|
+
if (!['none', 'cloudflare', 'cloudflare-named', 'ngrok', 'tailscale'].includes(tunnel)) {
|
|
3975
|
+
throw new Error('--tunnel must be none, cloudflare, cloudflare-named, ngrok, or tailscale');
|
|
3976
|
+
}
|
|
3977
|
+
const stableHostname = args.hostname
|
|
3978
|
+
?? args.url
|
|
3979
|
+
?? process.env.CODEXFLOW_PUBLIC_HOSTNAME
|
|
3980
|
+
?? process.env.CODEXFLOW_HOSTNAME
|
|
3981
|
+
?? process.env.NGROK_DOMAIN
|
|
3982
|
+
?? profile.hostname
|
|
3983
|
+
?? '';
|
|
3984
|
+
if (tunnel === 'cloudflare-named' && !stableHostname) {
|
|
3985
|
+
printStableUrlHelp();
|
|
3986
|
+
throw new Error('--hostname is required with stable URL mode.');
|
|
3987
|
+
}
|
|
3988
|
+
if (tunnel === 'ngrok' && !stableHostname) {
|
|
3989
|
+
throw new Error('--hostname is required with ngrok tunnel mode. Example: codexflow ngrok --hostname your-domain.ngrok-free.dev');
|
|
3990
|
+
}
|
|
3991
|
+
if (tunnel === 'tailscale' && !stableHostname) {
|
|
3992
|
+
throw new Error('--hostname is required with Tailscale Funnel mode. Example: codexflow tailscale --hostname your-device.your-tailnet.ts.net');
|
|
3993
|
+
}
|
|
3994
|
+
const mode = optionValue(args, profile, 'mode', ['CODEXFLOW_MODE'], 'agent');
|
|
3995
|
+
if (!['agent', 'handoff', 'pro'].includes(mode)) {
|
|
3996
|
+
throw new Error('--mode must be agent, handoff, or pro');
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
const allowRoots = [...new Set([root, ...discoveredProjects, ...(profile.allowRoots ?? []), ...(args.allowRoots ?? [])].map(realDir))];
|
|
4000
|
+
const host = optionValue(args, profile, 'host', ['CODEXFLOW_HOST'], '127.0.0.1');
|
|
4001
|
+
if (args.noAuth && (tunnel !== 'none' || !isLoopbackHost(host))) {
|
|
4002
|
+
throw new Error('--no-auth is only allowed with --tunnel none on a loopback host.');
|
|
4003
|
+
}
|
|
4004
|
+
const port = String(optionValue(args, profile, 'port', ['CODEXFLOW_PORT'], '8787'));
|
|
4005
|
+
const bash = optionValue(args, profile, 'bash', ['CODEXFLOW_BASH_MODE'], 'safe');
|
|
4006
|
+
const bashTranscript = bashTranscriptOption(args, profile);
|
|
4007
|
+
const codexSessions = codexSessionsOption(args, profile);
|
|
4008
|
+
const codexDir = resolveCodexDir(root, optionValue(args, profile, 'codexDir', ['CODEXFLOW_CODEX_DIR'], ''));
|
|
4009
|
+
const { bashSession, requireBashSession } = bashSessionOptions(args, profile);
|
|
4010
|
+
const write = writeOption(args, profile, mode);
|
|
4011
|
+
const toolMode = optionValue(args, profile, 'toolMode', ['CODEXFLOW_TOOL_MODE'], 'standard');
|
|
4012
|
+
const widgetDomain = optionValue(args, profile, 'widgetDomain', ['CODEXFLOW_WIDGET_DOMAIN'], 'https://tarunspandit.github.io');
|
|
4013
|
+
const toolCards = optionBool(args, profile, 'toolCards', ['CODEXFLOW_TOOL_CARDS'], false);
|
|
4014
|
+
validateChoice('bash', bash, ['off', 'safe', 'full']);
|
|
4015
|
+
validateChoice('write', write, ['off', 'handoff', 'workspace']);
|
|
4016
|
+
validateChoice('tool-mode', toolMode, ['minimal', 'standard', 'full']);
|
|
4017
|
+
|
|
4018
|
+
let token = args.noAuth ? '' : optionValue(args, profile, 'token', ['CODEXFLOW_HTTP_TOKEN', 'CODEBASE_BRIDGE_HTTP_TOKEN'], '');
|
|
4019
|
+
if (!token && !args.noAuth) token = stableToken();
|
|
4020
|
+
|
|
4021
|
+
const serverEnv = {
|
|
4022
|
+
...process.env,
|
|
4023
|
+
CODEXFLOW_ROOT: root,
|
|
4024
|
+
CODEXFLOW_ALLOWED_ROOTS: allowRoots.join(path.delimiter),
|
|
4025
|
+
CODEXFLOW_HOST: host,
|
|
4026
|
+
CODEXFLOW_PORT: port,
|
|
4027
|
+
CODEXFLOW_BASH_MODE: bash,
|
|
4028
|
+
CODEXFLOW_BASH_TRANSCRIPT: bashTranscript,
|
|
4029
|
+
CODEXFLOW_BASH_SESSION_ID: bashSession,
|
|
4030
|
+
CODEXFLOW_REQUIRE_BASH_SESSION: requireBashSession ? '1' : '0',
|
|
4031
|
+
CODEXFLOW_CODEX_SESSIONS: codexSessions,
|
|
4032
|
+
CODEXFLOW_WRITE_MODE: write,
|
|
4033
|
+
CODEXFLOW_TOOL_MODE: toolMode,
|
|
4034
|
+
CODEXFLOW_WIDGET_DOMAIN: widgetDomain,
|
|
4035
|
+
CODEXFLOW_TOOL_CARDS: toolCards ? '1' : '0',
|
|
4036
|
+
CODEXFLOW_CONNECTION_TEST: connectionTest ? '1' : '0',
|
|
4037
|
+
CODEXFLOW_MODE: mode,
|
|
4038
|
+
CODEXFLOW_TUNNEL_MODE: tunnel === 'none' ? '0' : '1',
|
|
4039
|
+
CODEXFLOW_ALLOW_NO_HTTP_TOKEN: args.noAuth ? '1' : '0'
|
|
4040
|
+
};
|
|
4041
|
+
if (codexDir) serverEnv.CODEXFLOW_CODEX_DIR = codexDir;
|
|
4042
|
+
if (args.logRequests || process.env.CODEXFLOW_LOG_REQUESTS === '1') serverEnv.CODEXFLOW_LOG_REQUESTS = '1';
|
|
4043
|
+
if (args.allowHome) serverEnv.CODEXFLOW_ALLOW_HOME = '1';
|
|
4044
|
+
if (token) serverEnv.CODEXFLOW_HTTP_TOKEN = token;
|
|
4045
|
+
else delete serverEnv.CODEXFLOW_HTTP_TOKEN;
|
|
4046
|
+
|
|
4047
|
+
if (args.printEnv) {
|
|
4048
|
+
console.log(JSON.stringify(redactEnvObject(serverEnv), null, 2));
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
const httpPath = path.join(projectRoot, 'dist', 'http.js');
|
|
4052
|
+
if (!fs.existsSync(httpPath)) {
|
|
4053
|
+
throw new Error(`Missing ${httpPath}. Run npm install && npm run build first.`);
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
await assertPortAvailable(host, port);
|
|
4057
|
+
|
|
4058
|
+
printBox('CodexFlow', [
|
|
4059
|
+
labelValue('Default project', root),
|
|
4060
|
+
labelValue('Projects found', String(allowRoots.length)),
|
|
4061
|
+
labelValue('Mode', `${mode} tools=${toolMode} write=${write} bash=${bash}`),
|
|
4062
|
+
labelValue('Bash transcript', bashTranscript),
|
|
4063
|
+
labelValue('Codex sessions', codexSessions),
|
|
4064
|
+
...(bashSession ? [labelValue('Bash session', `${bashSession}${requireBashSession ? ' required' : ''}`)] : []),
|
|
4065
|
+
labelValue('Local URL', `http://${host}:${port}/mcp`),
|
|
4066
|
+
labelValue(
|
|
4067
|
+
'Tunnel',
|
|
4068
|
+
tunnel === 'cloudflare'
|
|
4069
|
+
? 'Cloudflare quick tunnel'
|
|
4070
|
+
: tunnel === 'cloudflare-named'
|
|
4071
|
+
? `Cloudflare named tunnel for ${stableHostname}`
|
|
4072
|
+
: tunnel === 'ngrok'
|
|
4073
|
+
? `ngrok endpoint for ${stableHostname}`
|
|
4074
|
+
: tunnel === 'tailscale'
|
|
4075
|
+
? `Tailscale Funnel endpoint for ${stableHostname}`
|
|
4076
|
+
: 'none'
|
|
4077
|
+
)
|
|
4078
|
+
]);
|
|
4079
|
+
|
|
4080
|
+
const verboseLogs = Boolean(args.logRequests || process.env.CODEXFLOW_LOG_REQUESTS === '1');
|
|
4081
|
+
statusLine('wait', 'Starting local MCP server');
|
|
4082
|
+
const server = spawnLogged('codexflow', process.execPath, [httpPath], { cwd: projectRoot, env: serverEnv, verbose: verboseLogs });
|
|
4083
|
+
let cloudflared;
|
|
4084
|
+
let cleanupTunnelCredentials = () => {};
|
|
4085
|
+
const cleanup = () => {
|
|
4086
|
+
cleanupTunnelCredentials();
|
|
4087
|
+
cleanupChildren();
|
|
4088
|
+
clearRuntimeConnection(root);
|
|
4089
|
+
};
|
|
4090
|
+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
4091
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
4092
|
+
|
|
4093
|
+
const localBase = `http://${host}:${port}`;
|
|
4094
|
+
await waitForHealth(`${localBase}/healthz`, token);
|
|
4095
|
+
statusLine('ok', `Local MCP ready at ${localBase}/mcp`);
|
|
4096
|
+
const runtimeOptions = {
|
|
4097
|
+
localBase,
|
|
4098
|
+
tunnel,
|
|
4099
|
+
mode,
|
|
4100
|
+
toolMode,
|
|
4101
|
+
write,
|
|
4102
|
+
bash,
|
|
4103
|
+
bashTranscript,
|
|
4104
|
+
codexSessions,
|
|
4105
|
+
bashSession,
|
|
4106
|
+
requireBashSession,
|
|
4107
|
+
toolCards,
|
|
4108
|
+
connectionTest
|
|
4109
|
+
};
|
|
4110
|
+
|
|
4111
|
+
if (tunnel === 'none') {
|
|
4112
|
+
if (effectiveArgs.installCloudflared) {
|
|
4113
|
+
const installedCloudflared = await resolveCloudflared(effectiveArgs);
|
|
4114
|
+
if (installedCloudflared) console.log(`cloudflared ready: ${installedCloudflared}`);
|
|
4115
|
+
}
|
|
4116
|
+
const details = printConnectorBlock(`${localBase}/mcp`, token, {
|
|
4117
|
+
localBase,
|
|
4118
|
+
copyUrl: args.copyUrl ? true : args.noCopyUrl ? false : undefined,
|
|
4119
|
+
openChatgpt: Boolean(args.openChatgpt),
|
|
4120
|
+
mode,
|
|
4121
|
+
toolMode,
|
|
4122
|
+
root,
|
|
4123
|
+
write,
|
|
4124
|
+
bash,
|
|
4125
|
+
bashTranscript,
|
|
4126
|
+
codexSessions,
|
|
4127
|
+
bashSession,
|
|
4128
|
+
requireBashSession,
|
|
4129
|
+
connectionTest,
|
|
4130
|
+
nonInteractive: Boolean(args.nonInteractive)
|
|
4131
|
+
});
|
|
4132
|
+
saveRuntimeConnection(root, details, runtimeOptions);
|
|
4133
|
+
await runControlPanel(details, cleanup, { nonInteractive: Boolean(args.nonInteractive) });
|
|
4134
|
+
return;
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
if (tunnel === 'ngrok') {
|
|
4138
|
+
const ngrokPath = resolveNgrok(effectiveArgs);
|
|
4139
|
+
const publicBase = publicBaseFromHostname(stableHostname);
|
|
4140
|
+
const ngrokArgs = ['http', localBase, '--url', publicBase];
|
|
4141
|
+
const configPath = ngrokConfigPath(root, args, profile);
|
|
4142
|
+
if (configPath) ngrokArgs.push('--config', configPath);
|
|
4143
|
+
statusLine('wait', `Opening ngrok endpoint for ${publicBase}`);
|
|
4144
|
+
cloudflared = spawnLogged('ngrok', ngrokPath, ngrokArgs, { cwd: root, env: process.env, verbose: verboseLogs });
|
|
4145
|
+
try {
|
|
4146
|
+
await waitForPublicHealth(publicBase, token, cloudflared, 'ngrok');
|
|
4147
|
+
} catch (error) {
|
|
4148
|
+
const tail = typeof cloudflared.codexflowLogTail === 'function' ? cloudflared.codexflowLogTail() : '';
|
|
4149
|
+
const hint = [
|
|
4150
|
+
'',
|
|
4151
|
+
'Ngrok stable domains need one-time setup before this can succeed:',
|
|
4152
|
+
'',
|
|
4153
|
+
' ngrok config add-authtoken <your-ngrok-token>',
|
|
4154
|
+
' find your free ngrok dev domain in the ngrok dashboard',
|
|
4155
|
+
' codexflow ngrok --hostname your-domain.ngrok-free.dev --token keep-this-stable-token',
|
|
4156
|
+
'',
|
|
4157
|
+
'If the domain is already in use, stop the other ngrok process or choose another reserved domain.'
|
|
4158
|
+
].join('\n');
|
|
4159
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${tail ? `\n\nRecent ngrok output:\n${tail}` : ''}${hint}`);
|
|
4160
|
+
}
|
|
4161
|
+
const details = printConnectorBlock(`${publicBase}/mcp`, token, {
|
|
4162
|
+
localBase,
|
|
4163
|
+
copyUrl: args.noCopyUrl ? false : true,
|
|
4164
|
+
openChatgpt: Boolean(args.openChatgpt),
|
|
4165
|
+
mode,
|
|
4166
|
+
toolMode,
|
|
4167
|
+
root,
|
|
4168
|
+
write,
|
|
4169
|
+
bash,
|
|
4170
|
+
bashTranscript,
|
|
4171
|
+
codexSessions,
|
|
4172
|
+
bashSession,
|
|
4173
|
+
requireBashSession,
|
|
4174
|
+
connectionTest,
|
|
4175
|
+
nonInteractive: Boolean(args.nonInteractive)
|
|
4176
|
+
});
|
|
4177
|
+
saveRuntimeConnection(root, details, runtimeOptions);
|
|
4178
|
+
await runControlPanel(details, cleanup, { nonInteractive: Boolean(args.nonInteractive) });
|
|
4179
|
+
return;
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
if (tunnel === 'tailscale') {
|
|
4183
|
+
const tailscalePath = resolveTailscale(effectiveArgs);
|
|
4184
|
+
const publicBase = publicBaseFromHostname(stableHostname);
|
|
4185
|
+
const httpsPort = tailscaleFunnelHttpsPort(publicBase);
|
|
4186
|
+
const tailscaleArgs = ['funnel'];
|
|
4187
|
+
if (httpsPort !== '443') tailscaleArgs.push(`--https=${httpsPort}`);
|
|
4188
|
+
tailscaleArgs.push(localBase);
|
|
4189
|
+
statusLine('wait', `Opening Tailscale Funnel for ${publicBase}`);
|
|
4190
|
+
cloudflared = spawnLogged('tailscale', tailscalePath, tailscaleArgs, { cwd: root, env: process.env, verbose: verboseLogs });
|
|
4191
|
+
try {
|
|
4192
|
+
await waitForPublicHealth(publicBase, token, cloudflared, 'Tailscale Funnel');
|
|
4193
|
+
} catch (error) {
|
|
4194
|
+
const tail = typeof cloudflared.codexflowLogTail === 'function' ? cloudflared.codexflowLogTail() : '';
|
|
4195
|
+
const hint = [
|
|
4196
|
+
'',
|
|
4197
|
+
'Tailscale Funnel needs one-time setup before this can succeed:',
|
|
4198
|
+
'',
|
|
4199
|
+
' install and log in to Tailscale',
|
|
4200
|
+
' enable MagicDNS, HTTPS certificates, and Funnel for this tailnet',
|
|
4201
|
+
' codexflow tailscale --hostname your-device.your-tailnet.ts.net --token keep-this-stable-token',
|
|
4202
|
+
'',
|
|
4203
|
+
'Funnel exposes this connector publicly. Keep the CodexFlow token enabled.'
|
|
4204
|
+
].join('\n');
|
|
4205
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${tail ? `\n\nRecent tailscale output:\n${tail}` : ''}${hint}`);
|
|
4206
|
+
}
|
|
4207
|
+
const details = printConnectorBlock(`${publicBase}/mcp`, token, {
|
|
4208
|
+
localBase,
|
|
4209
|
+
copyUrl: args.noCopyUrl ? false : true,
|
|
4210
|
+
openChatgpt: Boolean(args.openChatgpt),
|
|
4211
|
+
mode,
|
|
4212
|
+
toolMode,
|
|
4213
|
+
root,
|
|
4214
|
+
write,
|
|
4215
|
+
bash,
|
|
4216
|
+
bashTranscript,
|
|
4217
|
+
codexSessions,
|
|
4218
|
+
bashSession,
|
|
4219
|
+
requireBashSession,
|
|
4220
|
+
connectionTest,
|
|
4221
|
+
nonInteractive: Boolean(args.nonInteractive)
|
|
4222
|
+
});
|
|
4223
|
+
saveRuntimeConnection(root, details, runtimeOptions);
|
|
4224
|
+
await runControlPanel(details, cleanup, { nonInteractive: Boolean(args.nonInteractive) });
|
|
4225
|
+
return;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
const cloudflaredPath = await resolveCloudflared(effectiveArgs);
|
|
4229
|
+
if (!cloudflaredPath) {
|
|
4230
|
+
console.error('\ncloudflared was not found. The local MCP server is still running.');
|
|
4231
|
+
console.error('Install Cloudflare Tunnel, rerun without --no-install-cloudflared, or run with --tunnel none for local clients.');
|
|
4232
|
+
console.error('Downloads: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/');
|
|
4233
|
+
const details = printConnectorBlock(`${localBase}/mcp`, token, {
|
|
4234
|
+
localBase,
|
|
4235
|
+
copyUrl: args.copyUrl ? true : false,
|
|
4236
|
+
openChatgpt: Boolean(args.openChatgpt),
|
|
4237
|
+
mode,
|
|
4238
|
+
toolMode,
|
|
4239
|
+
root,
|
|
4240
|
+
write,
|
|
4241
|
+
bash,
|
|
4242
|
+
bashTranscript,
|
|
4243
|
+
codexSessions,
|
|
4244
|
+
bashSession,
|
|
4245
|
+
requireBashSession,
|
|
4246
|
+
connectionTest,
|
|
4247
|
+
nonInteractive: Boolean(args.nonInteractive)
|
|
4248
|
+
});
|
|
4249
|
+
saveRuntimeConnection(root, details, runtimeOptions);
|
|
4250
|
+
await runControlPanel(details, cleanup, { nonInteractive: Boolean(args.nonInteractive) });
|
|
4251
|
+
return;
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4254
|
+
if (tunnel === 'cloudflare') {
|
|
4255
|
+
statusLine('wait', 'Opening Cloudflare quick tunnel');
|
|
4256
|
+
const proxyUrl = outboundProxyFromEnv(process.env);
|
|
4257
|
+
let publicBase = '';
|
|
4258
|
+
if (proxyUrl) {
|
|
4259
|
+
const quickTunnel = requestQuickTunnelViaCurl(proxyUrl);
|
|
4260
|
+
const { tmpRoot, credentialsPath } = writeQuickTunnelCredentials(quickTunnel);
|
|
4261
|
+
const removeCredentials = () => fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
4262
|
+
cleanupTunnelCredentials = removeCredentials;
|
|
4263
|
+
try {
|
|
4264
|
+
cloudflared = spawnLogged('cloudflared', cloudflaredPath, ['tunnel', '--url', localBase, '--credentials-file', credentialsPath, 'run', quickTunnel.id], { cwd: root, env: process.env, verbose: verboseLogs });
|
|
4265
|
+
} catch (error) {
|
|
4266
|
+
removeCredentials();
|
|
4267
|
+
throw error;
|
|
4268
|
+
}
|
|
4269
|
+
cloudflared.once('exit', removeCredentials);
|
|
4270
|
+
cloudflared.once('error', removeCredentials);
|
|
4271
|
+
await waitForTunnelStartup(cloudflared, 'cloudflared');
|
|
4272
|
+
publicBase = `https://${quickTunnel.hostname}`;
|
|
4273
|
+
} else {
|
|
4274
|
+
cloudflared = spawnLogged('cloudflared', cloudflaredPath, ['tunnel', '--url', localBase], { cwd: root, env: process.env, verbose: verboseLogs });
|
|
4275
|
+
publicBase = await waitForCloudflareUrl(cloudflared);
|
|
4276
|
+
}
|
|
4277
|
+
const details = printConnectorBlock(`${publicBase}/mcp`, token, {
|
|
4278
|
+
localBase,
|
|
4279
|
+
copyUrl: args.noCopyUrl ? false : true,
|
|
4280
|
+
openChatgpt: Boolean(args.openChatgpt),
|
|
4281
|
+
mode,
|
|
4282
|
+
toolMode,
|
|
4283
|
+
root,
|
|
4284
|
+
write,
|
|
4285
|
+
bash,
|
|
4286
|
+
bashTranscript,
|
|
4287
|
+
codexSessions,
|
|
4288
|
+
bashSession,
|
|
4289
|
+
requireBashSession,
|
|
4290
|
+
connectionTest,
|
|
4291
|
+
nonInteractive: Boolean(args.nonInteractive)
|
|
4292
|
+
});
|
|
4293
|
+
saveRuntimeConnection(root, details, runtimeOptions);
|
|
4294
|
+
await runControlPanel(details, cleanup, { nonInteractive: Boolean(args.nonInteractive) });
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
const publicBase = publicBaseFromHostname(stableHostname);
|
|
4299
|
+
const tunnelName = optionValue(args, profile, 'tunnelName', ['CLOUDFLARE_TUNNEL_NAME', 'CODEXFLOW_TUNNEL_NAME'], '');
|
|
4300
|
+
const cloudflareConfig = resolveConfigPath(root, optionValue(args, profile, 'cloudflareConfig', ['CLOUDFLARE_TUNNEL_CONFIG', 'CODEXFLOW_CLOUDFLARE_CONFIG'], ''));
|
|
4301
|
+
const cloudflareTokenFile = resolveConfigPath(root, optionValue(args, profile, 'cloudflareTokenFile', ['CLOUDFLARE_TUNNEL_TOKEN_FILE', 'CODEXFLOW_CLOUDFLARE_TUNNEL_TOKEN_FILE'], ''));
|
|
4302
|
+
const cloudflareToken = optionValue(args, profile, 'cloudflareToken', ['CLOUDFLARE_TUNNEL_TOKEN', 'CODEXFLOW_CLOUDFLARE_TUNNEL_TOKEN'], '');
|
|
4303
|
+
|
|
4304
|
+
const cloudflaredArgs = ['tunnel'];
|
|
4305
|
+
if (cloudflareConfig) {
|
|
4306
|
+
cloudflaredArgs.push('--config', cloudflareConfig, 'run');
|
|
4307
|
+
if (tunnelName) cloudflaredArgs.push(tunnelName);
|
|
4308
|
+
} else {
|
|
4309
|
+
cloudflaredArgs.push('run', '--url', localBase);
|
|
4310
|
+
if (cloudflareTokenFile) {
|
|
4311
|
+
cloudflaredArgs.push('--token-file', cloudflareTokenFile);
|
|
4312
|
+
} else if (cloudflareToken) {
|
|
4313
|
+
// Passed to cloudflared through the child environment below.
|
|
4314
|
+
} else {
|
|
4315
|
+
if (!tunnelName) {
|
|
4316
|
+
throw new Error('--tunnel-name, --cloudflare-token, --cloudflare-token-file, or --cloudflare-config is required with --tunnel cloudflare-named.');
|
|
4317
|
+
}
|
|
4318
|
+
cloudflaredArgs.push(tunnelName);
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
statusLine('wait', `Starting Cloudflare named tunnel for ${publicBase}`);
|
|
4323
|
+
const cloudflaredEnv = cloudflareToken && !cloudflareTokenFile
|
|
4324
|
+
? { ...process.env, TUNNEL_TOKEN: cloudflareToken }
|
|
4325
|
+
: process.env;
|
|
4326
|
+
cloudflared = spawnLogged('cloudflared', cloudflaredPath, cloudflaredArgs, { cwd: root, env: cloudflaredEnv, verbose: verboseLogs });
|
|
4327
|
+
try {
|
|
4328
|
+
await waitForPublicHealth(publicBase, token, cloudflared);
|
|
4329
|
+
} catch (error) {
|
|
4330
|
+
const tail = typeof cloudflared.codexflowLogTail === 'function' ? cloudflared.codexflowLogTail() : '';
|
|
4331
|
+
const hint = [
|
|
4332
|
+
'',
|
|
4333
|
+
'Named Cloudflare tunnels need one-time setup before this can succeed:',
|
|
4334
|
+
'',
|
|
4335
|
+
' cloudflared tunnel login',
|
|
4336
|
+
' cloudflared tunnel create <tunnel-name>',
|
|
4337
|
+
' cloudflared tunnel route dns <tunnel-name> <hostname>',
|
|
4338
|
+
'',
|
|
4339
|
+
'Or create a remotely managed tunnel in the Cloudflare dashboard and pass:',
|
|
4340
|
+
'',
|
|
4341
|
+
' --cloudflare-token-file ~/.codexflow/cloudflare-tunnel-token',
|
|
4342
|
+
'',
|
|
4343
|
+
'Quick tunnels do not support a permanent hostname. Use --tunnel cloudflare only for demos.'
|
|
4344
|
+
].join('\n');
|
|
4345
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${tail ? `\n\nRecent cloudflared output:\n${tail}` : ''}${hint}`);
|
|
4346
|
+
}
|
|
4347
|
+
const details = printConnectorBlock(`${publicBase}/mcp`, token, {
|
|
4348
|
+
localBase,
|
|
4349
|
+
copyUrl: args.noCopyUrl ? false : true,
|
|
4350
|
+
openChatgpt: Boolean(args.openChatgpt),
|
|
4351
|
+
mode,
|
|
4352
|
+
toolMode,
|
|
4353
|
+
root,
|
|
4354
|
+
write,
|
|
4355
|
+
bash,
|
|
4356
|
+
bashTranscript,
|
|
4357
|
+
codexSessions,
|
|
4358
|
+
bashSession,
|
|
4359
|
+
requireBashSession,
|
|
4360
|
+
connectionTest,
|
|
4361
|
+
nonInteractive: Boolean(args.nonInteractive)
|
|
4362
|
+
});
|
|
4363
|
+
saveRuntimeConnection(root, details, runtimeOptions);
|
|
4364
|
+
await runControlPanel(details, cleanup, { nonInteractive: Boolean(args.nonInteractive) });
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
main().catch((error) => {
|
|
4368
|
+
cleanupChildren();
|
|
4369
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4370
|
+
console.error(`Error: ${message}`);
|
|
4371
|
+
if (process.env.CODEXFLOW_DEBUG === '1' && error instanceof Error && error.stack) {
|
|
4372
|
+
console.error(error.stack);
|
|
4373
|
+
}
|
|
4374
|
+
process.exit(1);
|
|
4375
|
+
});
|