ocx-cursor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +218 -21
- package/bin/ocx-cursor.mjs +29 -19
- package/package.json +1 -1
- package/src/setup.mjs +77 -0
package/README.md
CHANGED
|
@@ -3,27 +3,101 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/ocx-cursor)
|
|
4
4
|
[](https://github.com/hiddenest/opencodex-cursor-bridge/actions/workflows/ci.yml)
|
|
5
5
|
|
|
6
|
-
Use active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through its custom OpenAI endpoint. The package runs a local gateway and keeps Cursor's custom model list in sync.
|
|
6
|
+
Use active [OpenCodex](https://github.com/lidge-jun/opencodex) models in Cursor through its custom OpenAI endpoint. The package runs a local gateway, registers the endpoint in Cursor, and keeps Cursor's custom model list in sync.
|
|
7
7
|
|
|
8
8
|
## Requirements
|
|
9
9
|
|
|
10
10
|
- macOS with Cursor installed at `/Applications/Cursor.app`
|
|
11
11
|
- Node.js 22.5 or newer
|
|
12
|
-
-
|
|
13
|
-
- An HTTPS hostname that forwards to `http://127.0.0.1:10101`
|
|
12
|
+
- A domain using Cloudflare DNS, or another HTTPS reverse proxy
|
|
14
13
|
|
|
15
14
|
Launch Cursor and sign in once before setup. Cursor creates the Safe Storage key that the installer uses to encrypt the gateway API key.
|
|
16
15
|
|
|
17
|
-
##
|
|
16
|
+
## Setup
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
The commands below use `cursor-api.example.com`. Replace it with a hostname under your own Cloudflare-managed domain.
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
### 1. Install OpenCodex
|
|
21
|
+
|
|
22
|
+
[OpenCodex](https://github.com/lidge-jun/opencodex) requires Node.js 18 or newer and bundles its own Bun runtime. This bridge requires Node.js 22.5 or newer, so install Node.js 22 or later before continuing.
|
|
23
|
+
|
|
24
|
+
Install OpenCodex without `sudo` from a user-owned Node.js installation:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install --global @bitkyc08/opencodex
|
|
28
|
+
ocx init
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The bridge service looks for `ocx` at `~/.local/bin/ocx` and `/opt/homebrew/bin/ocx`. If `command -v ocx` prints another path, link it into `~/.local/bin`:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
mkdir -p ~/.local/bin
|
|
35
|
+
if [[ "$(command -v ocx)" != "$HOME/.local/bin/ocx" ]]; then
|
|
36
|
+
ln -sf "$(command -v ocx)" ~/.local/bin/ocx
|
|
37
|
+
fi
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`ocx init` opens the interactive provider setup. You can also open the dashboard and add or sign in to a provider there:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
ocx gui
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
OAuth-backed providers can also be connected from the terminal:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ocx login <provider>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Install OpenCodex as a login service, then check it:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ocx service install
|
|
56
|
+
ocx service status
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
OpenCodex listens on `http://127.0.0.1:10100` by default. Confirm that its model endpoint responds:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
curl http://127.0.0.1:10100/v1/models
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If you configured OpenCodex with a service API token, include that token:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl \
|
|
69
|
+
--header "Authorization: Bearer $(cat ~/.opencodex/service-api-token)" \
|
|
70
|
+
http://127.0.0.1:10100/v1/models
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
See the [OpenCodex documentation](https://lidge-jun.github.io/opencodex/) for provider-specific login and model configuration.
|
|
74
|
+
|
|
75
|
+
### 2. Create a Cloudflare Tunnel
|
|
76
|
+
|
|
77
|
+
Cursor requires an HTTPS custom OpenAI endpoint. A [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/) can publish the bridge on HTTPS without opening an inbound port on your router.
|
|
78
|
+
|
|
79
|
+
Add your domain to Cloudflare and point its nameservers to Cloudflare. Then install `cloudflared` on the Mac that runs OpenCodex and Cursor:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
brew install cloudflared
|
|
83
|
+
cloudflared tunnel login
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The login command opens Cloudflare in your browser and writes `~/.cloudflared/cert.pem`. Create a [locally-managed named tunnel](https://developers.cloudflare.com/tunnel/advanced/local-management/create-local-tunnel/):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cloudflared tunnel create ocx-cursor
|
|
90
|
+
cloudflared tunnel list
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Copy the tunnel UUID printed by the command. It also creates a credentials file named `<TUNNEL_UUID>.json` under `~/.cloudflared`.
|
|
94
|
+
|
|
95
|
+
Create `~/.cloudflared/config.yml`. Replace the UUID, macOS username, and hostname in this example:
|
|
22
96
|
|
|
23
97
|
```yaml
|
|
24
98
|
# ~/.cloudflared/config.yml
|
|
25
|
-
tunnel:
|
|
26
|
-
credentials-file: /Users/
|
|
99
|
+
tunnel: YOUR_TUNNEL_UUID
|
|
100
|
+
credentials-file: /Users/YOUR_MACOS_USERNAME/.cloudflared/YOUR_TUNNEL_UUID.json
|
|
27
101
|
|
|
28
102
|
ingress:
|
|
29
103
|
- hostname: cursor-api.example.com
|
|
@@ -31,18 +105,54 @@ ingress:
|
|
|
31
105
|
- service: http_status:404
|
|
32
106
|
```
|
|
33
107
|
|
|
34
|
-
Create the DNS
|
|
108
|
+
Create the DNS CNAME for the public hostname. The command adds the record to Cloudflare, so you do not need to create it separately in the dashboard:
|
|
35
109
|
|
|
36
110
|
```bash
|
|
37
|
-
cloudflared tunnel route dns
|
|
38
|
-
cloudflared tunnel run YOUR_TUNNEL_NAME
|
|
111
|
+
cloudflared tunnel route dns ocx-cursor cursor-api.example.com
|
|
39
112
|
```
|
|
40
113
|
|
|
41
|
-
|
|
114
|
+
Validate the configuration and start the tunnel in the foreground:
|
|
42
115
|
|
|
43
|
-
|
|
116
|
+
```bash
|
|
117
|
+
cloudflared tunnel ingress validate
|
|
118
|
+
cloudflared tunnel ingress rule https://cursor-api.example.com
|
|
119
|
+
cloudflared tunnel run ocx-cursor
|
|
120
|
+
```
|
|
44
121
|
|
|
45
|
-
|
|
122
|
+
The public hostname returns `502 Bad Gateway` until the bridge is installed in the next step. Keep this terminal open while testing.
|
|
123
|
+
|
|
124
|
+
For login-time startup on macOS, stop the foreground process and install the `cloudflared` launch agent:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cloudflared service install
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Use `sudo cloudflared service install` only if you want a system launch daemon that starts at boot. That mode reads its configuration from `/etc/cloudflared`, not your home directory. See Cloudflare's [macOS service guide](https://developers.cloudflare.com/tunnel/advanced/local-management/as-a-service/macos/) for the required file locations.
|
|
131
|
+
|
|
132
|
+
This package does not create, modify, or remove the Cloudflare Tunnel.
|
|
133
|
+
|
|
134
|
+
### 3. Install the Cursor bridge
|
|
135
|
+
|
|
136
|
+
Quit Cursor completely, confirm that OpenCodex is running, then initialize the bridge:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npx ocx-cursor init
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Enter the Cloudflare Tunnel hostname when prompted. The `https://` prefix and `/v1` suffix are optional:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
Cloudflare Tunnel URL (for example, https://cursor-api.example.com): https://cursor-api.example.com
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The installer starts the local bridge and tests two routes through Cloudflare before it changes Cursor:
|
|
149
|
+
|
|
150
|
+
- `GET /healthz` confirms that the hostname reaches this bridge.
|
|
151
|
+
- Authenticated `GET /v1/models` confirms that request headers reach the bridge and OpenCodex responds.
|
|
152
|
+
|
|
153
|
+
If either check fails, `init` stops before writing Cursor's API settings. The local bridge stays running so you can fix the tunnel and rerun the command.
|
|
154
|
+
|
|
155
|
+
For scripts and unattended setup, pass the URL directly:
|
|
46
156
|
|
|
47
157
|
```bash
|
|
48
158
|
npx ocx-cursor init \
|
|
@@ -52,10 +162,11 @@ npx ocx-cursor init \
|
|
|
52
162
|
`init` performs these actions:
|
|
53
163
|
|
|
54
164
|
1. Generates a gateway API key in `~/.opencodex/cursor-bridge/secret`.
|
|
55
|
-
2.
|
|
56
|
-
3.
|
|
57
|
-
4.
|
|
58
|
-
5.
|
|
165
|
+
2. Installs the `com.opencodex.cursor-bridge` LaunchAgent.
|
|
166
|
+
3. Tests the Cloudflare Tunnel and OpenCodex model endpoint.
|
|
167
|
+
4. Stores the key in Cursor with macOS Safe Storage encryption.
|
|
168
|
+
5. Registers the HTTPS URL as Cursor's OpenAI base URL.
|
|
169
|
+
6. Adds active OpenCodex models to Cursor under `opencodex/*`.
|
|
59
170
|
|
|
60
171
|
The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PATH` if your shell does not include it:
|
|
61
172
|
|
|
@@ -63,13 +174,35 @@ The installer links `ocx-cursor` into `~/.local/bin`. Add that directory to `PAT
|
|
|
63
174
|
export PATH="$HOME/.local/bin:$PATH"
|
|
64
175
|
```
|
|
65
176
|
|
|
66
|
-
|
|
177
|
+
Check the local gateway and public hostname before opening Cursor:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
ocx-cursor status
|
|
181
|
+
curl https://cursor-api.example.com/healthz
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The status output should show `Service: running` and `Gateway: healthy`. The public health endpoint should return JSON with `"status":"ok"`.
|
|
185
|
+
|
|
186
|
+
Open Cursor after both checks pass. Models with known reasoning controls show an effort value in the picker. Use `Shift+Command+/` to cycle it.
|
|
187
|
+
|
|
188
|
+
## How requests are routed
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
Cursor
|
|
192
|
+
-> https://cursor-api.example.com/v1
|
|
193
|
+
-> Cloudflare Tunnel
|
|
194
|
+
-> OpenCodex Cursor Bridge on 127.0.0.1:10101
|
|
195
|
+
-> OpenCodex on 127.0.0.1:10100
|
|
196
|
+
-> configured provider
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The gateway API key is generated during `init` and stored in Cursor with macOS Safe Storage encryption. The bridge requires this bearer token on every `/v1/*` request. Keep the bridge bound to `127.0.0.1`; `cloudflared` can reach it without exposing port `10101` to the local network.
|
|
67
200
|
|
|
68
201
|
## Commands
|
|
69
202
|
|
|
70
203
|
| Command | Purpose |
|
|
71
204
|
| --- | --- |
|
|
72
|
-
| `ocx-cursor init --base-url URL` |
|
|
205
|
+
| `ocx-cursor init [--base-url URL]` | Install the service, prompt for and test the tunnel, configure Cursor, and sync models. Cursor must be closed. |
|
|
73
206
|
| `ocx-cursor install` | Reinstall or restart the LaunchAgent without changing Cursor's API settings. |
|
|
74
207
|
| `ocx-cursor sync` | Refresh the active model catalog. The service queues the update while Cursor runs. |
|
|
75
208
|
| `ocx-cursor status` | Show service health, model count, and pending sync state. |
|
|
@@ -94,7 +227,7 @@ Cursor removes custom effort metadata from its database during startup. The Laun
|
|
|
94
227
|
|
|
95
228
|
| Variable | Default | Purpose |
|
|
96
229
|
| --- | --- | --- |
|
|
97
|
-
| `OCX_CURSOR_BASE_URL` | Stored Cursor URL |
|
|
230
|
+
| `OCX_CURSOR_BASE_URL` | Stored Cursor URL | Prompt default, or endpoint for non-interactive `init`, when `--base-url` is absent. |
|
|
98
231
|
| `OCX_CURSOR_HOME` | `~/.opencodex/cursor-bridge` | Service state, API key, catalog, and logs. |
|
|
99
232
|
| `OCX_CURSOR_HOST` | `127.0.0.1` | Local gateway bind address. |
|
|
100
233
|
| `OCX_CURSOR_PORT` | `10101` | Local gateway port. |
|
|
@@ -109,6 +242,70 @@ The gateway accepts these routes:
|
|
|
109
242
|
|
|
110
243
|
The gateway requires its generated bearer token on each `/v1/*` request. It binds to loopback unless you change `OCX_CURSOR_HOST`.
|
|
111
244
|
|
|
245
|
+
## Troubleshooting
|
|
246
|
+
|
|
247
|
+
### The public hostname returns 502
|
|
248
|
+
|
|
249
|
+
The tunnel is running, but it cannot reach the local bridge. Check the bridge and its logs:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
ocx-cursor status
|
|
253
|
+
tail -n 100 ~/.opencodex/cursor-bridge/service.error.log
|
|
254
|
+
tail -n 100 ~/.opencodex/cursor-bridge/service.log
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Confirm that the tunnel ingress points to `http://127.0.0.1:10101`, then restart the bridge if needed:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
ocx-cursor install
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Cloudflare returns error 1016
|
|
264
|
+
|
|
265
|
+
The DNS record exists, but no tunnel connector is online. Check the named tunnel and start it:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
cloudflared tunnel info ocx-cursor
|
|
269
|
+
cloudflared tunnel run ocx-cursor
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
If you installed the login service, inspect it with:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
launchctl print "gui/$(id -u)/com.cloudflare.cloudflared"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Cursor returns 401
|
|
279
|
+
|
|
280
|
+
Run `init` again while Cursor is closed. It preserves the bridge key, retests the tunnel, and writes the matching encrypted value back to Cursor:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
npx ocx-cursor init
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Models or effort options are missing
|
|
287
|
+
|
|
288
|
+
Cursor must be closed before its local model database can be changed. Quit Cursor and run:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
ocx-cursor sync
|
|
292
|
+
ocx-cursor status
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
If the status shows a pending sync, wait until every Cursor Helper process has exited. The service applies the queued catalog automatically.
|
|
296
|
+
|
|
297
|
+
### OpenCodex models cannot be loaded
|
|
298
|
+
|
|
299
|
+
Check OpenCodex first:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
ocx status
|
|
303
|
+
ocx models --json
|
|
304
|
+
curl http://127.0.0.1:10100/v1/models
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
If `ocx models --json` works in your shell but fails in the bridge, check that `ocx` is available at `~/.local/bin/ocx` or `/opt/homebrew/bin/ocx`. The macOS LaunchAgent does not load your interactive shell profile.
|
|
308
|
+
|
|
112
309
|
## Development
|
|
113
310
|
|
|
114
311
|
```bash
|
package/bin/ocx-cursor.mjs
CHANGED
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import process from "node:process";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
5
6
|
import { configureCursorOpenAI, storedCursorOpenAIBaseUrl } from "../src/cursor-config.mjs";
|
|
6
7
|
import { cursorIsRunning } from "../src/cursor-state.mjs";
|
|
7
8
|
import { installService, prepareInstallSecret, serviceStatus, uninstallService } from "../src/install.mjs";
|
|
8
9
|
import { cursorOpenAIBaseUrl, pendingFile } from "../src/paths.mjs";
|
|
9
10
|
import { runService } from "../src/service.mjs";
|
|
11
|
+
import { normalizeBaseUrl, testTunnel } from "../src/setup.mjs";
|
|
10
12
|
import { loadCatalogSnapshot, syncNow } from "../src/sync.mjs";
|
|
11
13
|
|
|
12
14
|
const usage = `OpenCodex Cursor Bridge
|
|
13
15
|
|
|
14
16
|
Usage:
|
|
15
|
-
ocx-cursor init --base-url <https-url>
|
|
16
|
-
|
|
17
|
+
ocx-cursor init [--base-url <https-url>]
|
|
18
|
+
Install the service, test the tunnel, configure Cursor,
|
|
19
|
+
and sync models
|
|
17
20
|
ocx-cursor install Install and start the macOS companion service
|
|
18
21
|
ocx-cursor sync Sync active OpenCodex models into Cursor
|
|
19
22
|
ocx-cursor status Show service and model-sync status
|
|
@@ -26,20 +29,21 @@ function argumentValue(name) {
|
|
|
26
29
|
return index === -1 ? "" : String(process.argv[index + 1] || "");
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
async function requestedBaseUrl() {
|
|
33
|
+
const supplied = argumentValue("--base-url");
|
|
34
|
+
const fallback = cursorOpenAIBaseUrl || storedCursorOpenAIBaseUrl();
|
|
35
|
+
if (supplied) return normalizeBaseUrl(supplied);
|
|
36
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return normalizeBaseUrl(fallback);
|
|
37
|
+
|
|
38
|
+
const prompt = fallback
|
|
39
|
+
? `Cloudflare Tunnel URL [${fallback}]: `
|
|
40
|
+
: "Cloudflare Tunnel URL (for example, https://cursor-api.example.com): ";
|
|
41
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
34
42
|
try {
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
43
|
+
return normalizeBaseUrl((await readline.question(prompt)).trim() || fallback);
|
|
44
|
+
} finally {
|
|
45
|
+
readline.close();
|
|
38
46
|
}
|
|
39
|
-
if (url.protocol !== "https:") throw new Error("Cursor OpenAI base URL must use HTTPS");
|
|
40
|
-
url.pathname = url.pathname.replace(/\/$/, "");
|
|
41
|
-
if (!url.pathname.endsWith("/v1")) throw new Error("Cursor OpenAI base URL must end with /v1");
|
|
42
|
-
return url.toString().replace(/\/$/, "");
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
async function pendingCount() {
|
|
@@ -83,10 +87,18 @@ async function main() {
|
|
|
83
87
|
if (cursorIsRunning()) {
|
|
84
88
|
throw new Error("Quit Cursor before running ocx-cursor init so model variants and effort selectors can be applied");
|
|
85
89
|
}
|
|
86
|
-
const baseUrl =
|
|
87
|
-
argumentValue("--base-url") || cursorOpenAIBaseUrl || storedCursorOpenAIBaseUrl(),
|
|
88
|
-
);
|
|
90
|
+
const baseUrl = await requestedBaseUrl();
|
|
89
91
|
const prepared = await prepareInstallSecret();
|
|
92
|
+
const installed = await installService();
|
|
93
|
+
process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${prepared.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
|
|
94
|
+
process.stdout.write(`Testing Cloudflare Tunnel: ${new URL(baseUrl).origin}\n`);
|
|
95
|
+
let tunnel;
|
|
96
|
+
try {
|
|
97
|
+
tunnel = await testTunnel(baseUrl, prepared.secret);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(`${error.message}\nThe bridge service is running locally. Fix the tunnel and rerun ocx-cursor init.`);
|
|
100
|
+
}
|
|
101
|
+
process.stdout.write(`Tunnel is ready (${tunnel.modelCount} models reachable).\n`);
|
|
90
102
|
const configured = await configureCursorOpenAI({
|
|
91
103
|
secret: prepared.secret,
|
|
92
104
|
baseUrl,
|
|
@@ -94,8 +106,6 @@ async function main() {
|
|
|
94
106
|
process.stdout.write(configured.changed
|
|
95
107
|
? `Configured Cursor OpenAI endpoint: ${baseUrl}\nBackup: ${configured.backupPath}\n`
|
|
96
108
|
: `Cursor OpenAI endpoint is already configured: ${baseUrl}\n`);
|
|
97
|
-
const installed = await installService();
|
|
98
|
-
process.stdout.write(`Installed ${installed.launchAgentFile} (API key ${installed.secretStatus}).\nCLI: ${installed.cliLinkFile}\n`);
|
|
99
109
|
printSync(await syncNow());
|
|
100
110
|
return;
|
|
101
111
|
}
|
package/package.json
CHANGED
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function normalizeBaseUrl(value) {
|
|
2
|
+
if (!value) {
|
|
3
|
+
throw new Error("Enter your Cloudflare Tunnel URL or pass --base-url https://your-domain.example/v1");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const candidate = value.includes("://") ? value : `https://${value}`;
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(candidate);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`Invalid Cloudflare Tunnel URL: ${value}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (url.protocol !== "https:") throw new Error("Cloudflare Tunnel URL must use HTTPS");
|
|
15
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
16
|
+
throw new Error("Cloudflare Tunnel URL cannot contain credentials, a query, or a fragment");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
20
|
+
if (pathname && pathname !== "/v1") {
|
|
21
|
+
throw new Error("Cloudflare Tunnel URL must be a hostname or end with /v1");
|
|
22
|
+
}
|
|
23
|
+
url.pathname = "/v1";
|
|
24
|
+
return url.toString().replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function checkedJson(fetchImpl, url, options, label) {
|
|
28
|
+
let response;
|
|
29
|
+
try {
|
|
30
|
+
response = await fetchImpl(url, {
|
|
31
|
+
...options,
|
|
32
|
+
signal: options?.signal || AbortSignal.timeout(10_000),
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(`${label} failed for ${url}: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!response.ok) throw new Error(`${label} returned HTTP ${response.status} for ${url}`);
|
|
39
|
+
try {
|
|
40
|
+
return await response.json();
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`${label} returned invalid JSON from ${url}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function testTunnel(baseUrl, secret, options = {}) {
|
|
47
|
+
baseUrl = normalizeBaseUrl(baseUrl);
|
|
48
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
49
|
+
const origin = new URL(baseUrl).origin;
|
|
50
|
+
const attempts = options.healthAttempts || 10;
|
|
51
|
+
let health;
|
|
52
|
+
let healthError;
|
|
53
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
54
|
+
try {
|
|
55
|
+
health = await checkedJson(fetchImpl, `${origin}/healthz`, {}, "Tunnel health check");
|
|
56
|
+
break;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
healthError = error;
|
|
59
|
+
if (attempt + 1 < attempts) {
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, options.retryDelayMs ?? 500));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!health) throw healthError;
|
|
65
|
+
if (health?.service !== "opencodex-cursor-bridge" || health?.status !== "ok") {
|
|
66
|
+
throw new Error(`Tunnel health check reached an unexpected service at ${origin}/healthz`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const models = await checkedJson(fetchImpl, `${baseUrl}/models`, {
|
|
70
|
+
headers: { authorization: `Bearer ${secret}` },
|
|
71
|
+
}, "Tunnel model check");
|
|
72
|
+
if (!Array.isArray(models?.data)) {
|
|
73
|
+
throw new Error(`Tunnel model check returned an invalid catalog from ${baseUrl}/models`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { health, modelCount: models.data.length };
|
|
77
|
+
}
|