@vincentt-xr/harness 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/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +58 -4
- package/dist/preview/index.d.ts +2 -14
- package/dist/preview/index.js +5 -29
- package/dist/preview/proxy.d.ts +4 -0
- package/dist/preview/proxy.js +49 -0
- package/dist/preview/runner.d.ts +36 -0
- package/dist/preview/runner.js +87 -0
- package/dist/preview/tunnel.d.ts +14 -0
- package/dist/preview/tunnel.js +28 -0
- package/package.json +1 -1
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { type RelayClient } from "./diagnostics.js";
|
|
3
|
+
/** Reap the running preview, if any. Called by preview_stop and on server exit. */
|
|
4
|
+
export declare function shutdownActivePreview(): Promise<void>;
|
|
3
5
|
export interface McpOptions {
|
|
4
6
|
/** Base URL of the relay's HTTP endpoint (default matches the relay CLI). */
|
|
5
7
|
relayUrl?: string;
|
package/dist/mcp/server.js
CHANGED
|
@@ -9,8 +9,20 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
|
|
11
11
|
import { createProject, gitHeadSha, publishUpload } from "./backend.js";
|
|
12
|
-
import { loadProjectBinding, resolveConfig, writeProjectBinding } from "../shared/config.js";
|
|
12
|
+
import { loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
|
|
13
13
|
import { needsScaffold, scaffoldFromTemplate } from "../scaffold/index.js";
|
|
14
|
+
import { startPreview } from "../preview/index.js";
|
|
15
|
+
// One preview per server process. Held at module scope so runStdio can reap it on
|
|
16
|
+
// shutdown without threading the handle through createHarnessMcp's return type.
|
|
17
|
+
let activePreview = null;
|
|
18
|
+
/** Reap the running preview, if any. Called by preview_stop and on server exit. */
|
|
19
|
+
export async function shutdownActivePreview() {
|
|
20
|
+
if (!activePreview)
|
|
21
|
+
return;
|
|
22
|
+
const preview = activePreview;
|
|
23
|
+
activePreview = null;
|
|
24
|
+
await preview.stop();
|
|
25
|
+
}
|
|
14
26
|
/** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
|
|
15
27
|
async function guard(run) {
|
|
16
28
|
try {
|
|
@@ -104,7 +116,9 @@ export function createHarnessMcp(opts = {}) {
|
|
|
104
116
|
projectId: created.projectId,
|
|
105
117
|
slug: created.slug,
|
|
106
118
|
});
|
|
107
|
-
return ((scaffolded
|
|
119
|
+
return ((scaffolded
|
|
120
|
+
? "Scaffolded the v2-template starter into this directory.\n"
|
|
121
|
+
: "") +
|
|
108
122
|
`Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
|
|
109
123
|
`Binding written to ${bindingPath} (gitignored).\n` +
|
|
110
124
|
`Build locally, then run project_publish to go live at ${created.slug}.vincentt.app.`);
|
|
@@ -116,7 +130,10 @@ export function createHarnessMcp(opts = {}) {
|
|
|
116
130
|
.string()
|
|
117
131
|
.optional()
|
|
118
132
|
.describe("Path to the built dist directory, relative to the project (default: dist)."),
|
|
119
|
-
note: z
|
|
133
|
+
note: z
|
|
134
|
+
.string()
|
|
135
|
+
.optional()
|
|
136
|
+
.describe("Optional release note recorded with the version."),
|
|
120
137
|
},
|
|
121
138
|
}, async ({ distDir, note }) => guard(async () => {
|
|
122
139
|
const binding = await loadProjectBinding(projectCwd);
|
|
@@ -126,15 +143,52 @@ export function createHarnessMcp(opts = {}) {
|
|
|
126
143
|
const cfg = await resolveConfig(projectCwd);
|
|
127
144
|
const dist = path.resolve(projectCwd, distDir ?? "dist");
|
|
128
145
|
const commitSha = await gitHeadSha(projectCwd);
|
|
129
|
-
const result = await publishUpload(cfg, binding.projectId, dist, {
|
|
146
|
+
const result = await publishUpload(cfg, binding.projectId, dist, {
|
|
147
|
+
note,
|
|
148
|
+
commitSha,
|
|
149
|
+
});
|
|
130
150
|
return `Published v${result.version}. Live at ${result.liveUrl}\nThis version: ${result.url}`;
|
|
131
151
|
}));
|
|
152
|
+
server.registerTool("preview_start", {
|
|
153
|
+
description: "Start a live on-device preview: serves the app, opens a public https tunnel, and wires the diagnostics relay so diag_logs/diag_network/diag_trace read from the connected device. Returns the URL to open on a phone. Requires a project_create binding; cloudflared is auto-provisioned if missing. One preview at a time — call preview_stop before starting another. The dev server keeps running until preview_stop, so start it once and iterate.",
|
|
154
|
+
inputSchema: {},
|
|
155
|
+
}, async () => guard(async () => {
|
|
156
|
+
if (activePreview) {
|
|
157
|
+
return `A preview is already running:\n${activePreview.url}\nCall preview_stop first to restart.`;
|
|
158
|
+
}
|
|
159
|
+
// App stdout would corrupt the MCP channel; keep it off stdout entirely and
|
|
160
|
+
// route harness progress to stderr.
|
|
161
|
+
activePreview = await startPreview({
|
|
162
|
+
projectCwd,
|
|
163
|
+
onLog: (m) => console.error(`[preview] ${m}`),
|
|
164
|
+
});
|
|
165
|
+
return (`Live preview running — open on your device:\n${activePreview.url}\n\n` +
|
|
166
|
+
`Diagnostics are live: diag_logs / diag_network / diag_trace now read from this device.\n` +
|
|
167
|
+
`Call preview_stop when finished.`);
|
|
168
|
+
}));
|
|
169
|
+
server.registerTool("preview_stop", {
|
|
170
|
+
description: "Stop the running preview: tears down the tunnel (and its DNS route), the app dev server, and the diagnostics relay. Safe to call when nothing is running.",
|
|
171
|
+
inputSchema: {},
|
|
172
|
+
}, async () => guard(async () => {
|
|
173
|
+
if (!activePreview)
|
|
174
|
+
return "No preview is running.";
|
|
175
|
+
await shutdownActivePreview();
|
|
176
|
+
return "Preview stopped. Tunnel and dev server torn down.";
|
|
177
|
+
}));
|
|
132
178
|
return server;
|
|
133
179
|
}
|
|
134
180
|
export async function runStdio(opts = {}) {
|
|
135
181
|
const server = createHarnessMcp(opts);
|
|
136
182
|
const transport = new StdioServerTransport();
|
|
137
183
|
await server.connect(transport);
|
|
184
|
+
// A running preview owns a child process + a live backend tunnel; reap both when
|
|
185
|
+
// the agent disconnects so we never leak a tunnel/DNS route past the session.
|
|
186
|
+
const shutdown = async () => {
|
|
187
|
+
await shutdownActivePreview();
|
|
188
|
+
process.exit(0);
|
|
189
|
+
};
|
|
190
|
+
process.on("SIGINT", shutdown);
|
|
191
|
+
process.on("SIGTERM", shutdown);
|
|
138
192
|
// stdout is the MCP channel — status goes to stderr only.
|
|
139
193
|
console.error("[harness-mcp] connected over stdio");
|
|
140
194
|
}
|
package/dist/preview/index.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+
export { startSessionTunnel, type SessionTunnel } from "./tunnel.js";
|
|
2
2
|
export { ensureCloudflared, type EnsureCloudflaredOptions } from "./cloudflared.js";
|
|
3
|
-
export
|
|
4
|
-
/** Public https URL to open on the device. */
|
|
5
|
-
url: string;
|
|
6
|
-
/** Tear the tunnel down (DNS route + tunnel). Idempotent; call on SIGINT. */
|
|
7
|
-
reap: () => Promise<void>;
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
11
|
-
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
12
|
-
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
13
|
-
* unbound (run project_create) or no backend/PAT is configured.
|
|
14
|
-
*/
|
|
15
|
-
export declare function startSessionTunnel(projectCwd: string, localPort: number): Promise<SessionTunnel>;
|
|
3
|
+
export { startPreview, type StartPreviewOptions, type RunningPreview } from "./runner.js";
|
package/dist/preview/index.js
CHANGED
|
@@ -1,30 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// this module stays free of any child-process / cloudflared coupling.
|
|
6
|
-
import { loadProjectBinding, resolveConfig } from "../shared/config.js";
|
|
7
|
-
import { mintTunnel, reapTunnel } from "../mcp/backend.js";
|
|
1
|
+
// Public surface of the preview limb (`@vincentt-xr/harness/preview`): the tunnel
|
|
2
|
+
// mint, the cloudflared resolver, and the full one-call preview runner. Barrel
|
|
3
|
+
// only — implementations live in the sibling modules.
|
|
4
|
+
export { startSessionTunnel } from "./tunnel.js";
|
|
8
5
|
export { ensureCloudflared } from "./cloudflared.js";
|
|
9
|
-
|
|
10
|
-
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
11
|
-
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
12
|
-
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
13
|
-
* unbound (run project_create) or no backend/PAT is configured.
|
|
14
|
-
*/
|
|
15
|
-
export async function startSessionTunnel(projectCwd, localPort) {
|
|
16
|
-
const binding = await loadProjectBinding(projectCwd);
|
|
17
|
-
if (!binding) {
|
|
18
|
-
throw new Error("No project bound to this directory — run project_create first.");
|
|
19
|
-
}
|
|
20
|
-
const cfg = await resolveConfig(projectCwd);
|
|
21
|
-
const minted = await mintTunnel(cfg, binding.projectId, localPort);
|
|
22
|
-
return {
|
|
23
|
-
...minted,
|
|
24
|
-
url: `https://${minted.hostname}`,
|
|
25
|
-
reap: () => reapTunnel(cfg, binding.projectId, {
|
|
26
|
-
tunnelId: minted.tunnelId,
|
|
27
|
-
dnsRecordId: minted.dnsRecordId,
|
|
28
|
-
}),
|
|
29
|
-
};
|
|
30
|
-
}
|
|
6
|
+
export { startPreview } from "./runner.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import type { Duplex } from "node:stream";
|
|
3
|
+
export declare function proxyWeb(req: IncomingMessage, res: ServerResponse, targetPort: number): void;
|
|
4
|
+
export declare function proxyWs(req: IncomingMessage, socket: Duplex, head: Buffer, targetPort: number): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A minimal same-host reverse proxy: forward HTTP requests and WebSocket upgrades
|
|
2
|
+
// to a localhost target port. Enough for the preview front proxy (app + harness
|
|
3
|
+
// relay behind one origin); not a general proxy. No deps.
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { request, } from "node:http";
|
|
6
|
+
// Rewrite Host to the loopback target. cloudflared forwards the public Host, and
|
|
7
|
+
// esbuild's `serve` 403s any Host it doesn't recognize — so the phone would get a
|
|
8
|
+
// 403. The origin only needs a Host it accepts; the browser never sees this value.
|
|
9
|
+
function localHeaders(headers, targetPort) {
|
|
10
|
+
return { ...headers, host: `localhost:${targetPort}` };
|
|
11
|
+
}
|
|
12
|
+
export function proxyWeb(req, res, targetPort) {
|
|
13
|
+
const proxyReq = request({
|
|
14
|
+
host: "127.0.0.1",
|
|
15
|
+
port: targetPort,
|
|
16
|
+
path: req.url,
|
|
17
|
+
method: req.method,
|
|
18
|
+
headers: localHeaders(req.headers, targetPort),
|
|
19
|
+
}, (proxyRes) => {
|
|
20
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
21
|
+
proxyRes.pipe(res);
|
|
22
|
+
});
|
|
23
|
+
proxyReq.on("error", () => {
|
|
24
|
+
if (!res.headersSent)
|
|
25
|
+
res.writeHead(502);
|
|
26
|
+
res.end();
|
|
27
|
+
});
|
|
28
|
+
req.pipe(proxyReq);
|
|
29
|
+
}
|
|
30
|
+
export function proxyWs(req, socket, head, targetPort) {
|
|
31
|
+
// Re-issue the upgrade handshake against the target and splice the sockets.
|
|
32
|
+
const headers = localHeaders(req.headers, targetPort);
|
|
33
|
+
const upstream = connect(targetPort, "127.0.0.1", () => {
|
|
34
|
+
const headerLines = [
|
|
35
|
+
`${req.method} ${req.url} HTTP/1.1`,
|
|
36
|
+
...Object.entries(headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`),
|
|
37
|
+
"",
|
|
38
|
+
"",
|
|
39
|
+
].join("\r\n");
|
|
40
|
+
upstream.write(headerLines);
|
|
41
|
+
if (head && head.length)
|
|
42
|
+
upstream.write(head);
|
|
43
|
+
upstream.pipe(socket);
|
|
44
|
+
socket.pipe(upstream);
|
|
45
|
+
});
|
|
46
|
+
const bail = () => socket.destroy();
|
|
47
|
+
upstream.on("error", bail);
|
|
48
|
+
socket.on("error", bail);
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
export interface StartPreviewOptions {
|
|
3
|
+
/** Project directory — its .vincentt binding + dev script drive the preview. */
|
|
4
|
+
projectCwd: string;
|
|
5
|
+
/** Internal port for the app dev serve (default 5173). */
|
|
6
|
+
appPort?: number;
|
|
7
|
+
/** Relay port; must match the MCP server's relay client (default 7331). */
|
|
8
|
+
relayPort?: number;
|
|
9
|
+
/** Port the tunnel points at — the front proxy origin (default 5190). */
|
|
10
|
+
frontPort?: number;
|
|
11
|
+
/** Path routed to the relay instead of the app (default /__harness). */
|
|
12
|
+
harnessPath?: string;
|
|
13
|
+
/** Command that starts the app dev serve on $PORT (default `npm run dev`). */
|
|
14
|
+
devCommand?: string[];
|
|
15
|
+
/** stdio for the app dev serve. "ignore" (default) keeps an MCP server's stdout
|
|
16
|
+
* clean; a CLI can pass "inherit" to surface build output. */
|
|
17
|
+
appStdio?: "ignore" | "inherit";
|
|
18
|
+
/** How long to wait for cloudflared to register before giving up (default 45s). */
|
|
19
|
+
registerTimeoutMs?: number;
|
|
20
|
+
/** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
|
|
21
|
+
onLog?: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export interface RunningPreview {
|
|
24
|
+
/** Public https URL to open on the device. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** Relay port the diag_* tools query. */
|
|
27
|
+
relayPort: number;
|
|
28
|
+
/** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
|
|
29
|
+
stop: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve once cloudflared reports a live edge connection, reject if it exits
|
|
34
|
+
* first or never registers. Exported for unit testing the log-scan/timeout logic.
|
|
35
|
+
*/
|
|
36
|
+
export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number): Promise<void>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The whole preview stack behind one call. Stands up, in order: the app dev serve
|
|
2
|
+
// (on an internal port), the harness relay (WS event sink + localhost /query), a
|
|
3
|
+
// front proxy unifying them on one origin (`/__harness` → relay, else → app), and
|
|
4
|
+
// a cloudflared tunnel to that origin so a phone gets ONE https URL that serves
|
|
5
|
+
// both the app (secure context → live camera) and the diagnostics socket.
|
|
6
|
+
//
|
|
7
|
+
// Returns the public URL plus a `stop()` that reaps everything. A failure at any
|
|
8
|
+
// step tears down what was already started, so the caller never leaks a child
|
|
9
|
+
// process or a live backend tunnel. Used by both the CLI (`npm run preview`) and
|
|
10
|
+
// the MCP verbs (preview_start / preview_stop).
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { startRelay } from "../relay/server.js";
|
|
14
|
+
import { ensureCloudflared } from "./cloudflared.js";
|
|
15
|
+
import { startSessionTunnel } from "./tunnel.js";
|
|
16
|
+
import { proxyWeb, proxyWs } from "./proxy.js";
|
|
17
|
+
export async function startPreview(opts) {
|
|
18
|
+
const { projectCwd, appPort = 5173, relayPort = 7331, frontPort = 5190, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, onLog = () => undefined, } = opts;
|
|
19
|
+
// Track every resource so any failure below can unwind exactly what started.
|
|
20
|
+
let app;
|
|
21
|
+
let relay;
|
|
22
|
+
let front;
|
|
23
|
+
let session;
|
|
24
|
+
let tunnel;
|
|
25
|
+
const stop = async () => {
|
|
26
|
+
tunnel?.kill();
|
|
27
|
+
app?.kill();
|
|
28
|
+
front?.close();
|
|
29
|
+
if (relay)
|
|
30
|
+
await relay.close();
|
|
31
|
+
if (session)
|
|
32
|
+
await session.reap();
|
|
33
|
+
};
|
|
34
|
+
try {
|
|
35
|
+
// Resolve cloudflared up front so a fresh host provisions it before we mint a
|
|
36
|
+
// tunnel we couldn't otherwise run.
|
|
37
|
+
const cloudflaredBin = await ensureCloudflared({ onLog });
|
|
38
|
+
app = spawn(devCommand[0], devCommand.slice(1), {
|
|
39
|
+
cwd: projectCwd,
|
|
40
|
+
env: { ...process.env, PORT: String(appPort) },
|
|
41
|
+
stdio: appStdio,
|
|
42
|
+
});
|
|
43
|
+
relay = startRelay({
|
|
44
|
+
port: relayPort,
|
|
45
|
+
path: harnessPath,
|
|
46
|
+
onLog: (m) => onLog(`[relay] ${m}`),
|
|
47
|
+
});
|
|
48
|
+
front = createServer((req, res) => proxyWeb(req, res, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
49
|
+
front.on("upgrade", (req, socket, head) => proxyWs(req, socket, head, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
50
|
+
await new Promise((resolve) => front.listen(frontPort, resolve));
|
|
51
|
+
session = await startSessionTunnel(projectCwd, frontPort);
|
|
52
|
+
tunnel = spawn(cloudflaredBin, ["tunnel", "run", "--token", session.runToken], {
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
});
|
|
55
|
+
await waitForRegister(tunnel, registerTimeoutMs);
|
|
56
|
+
return { url: session.url, relayPort, stop };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
await stop();
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve once cloudflared reports a live edge connection, reject if it exits
|
|
65
|
+
* first or never registers. Exported for unit testing the log-scan/timeout logic.
|
|
66
|
+
*/
|
|
67
|
+
export function waitForRegister(tunnel, timeoutMs) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
let settled = false;
|
|
70
|
+
const finish = (fn) => {
|
|
71
|
+
if (settled)
|
|
72
|
+
return;
|
|
73
|
+
settled = true;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
fn();
|
|
76
|
+
};
|
|
77
|
+
const scan = (buf) => {
|
|
78
|
+
if (/Registered tunnel connection|Connection [^ ]+ registered/.test(String(buf))) {
|
|
79
|
+
finish(resolve);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
tunnel.stdout?.on("data", scan);
|
|
83
|
+
tunnel.stderr?.on("data", scan);
|
|
84
|
+
tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).`))));
|
|
85
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.`))), timeoutMs);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type MintedTunnel } from "../mcp/backend.js";
|
|
2
|
+
export interface SessionTunnel extends MintedTunnel {
|
|
3
|
+
/** Public https URL to open on the device. */
|
|
4
|
+
url: string;
|
|
5
|
+
/** Tear the tunnel down (DNS route + tunnel). Idempotent; call on SIGINT. */
|
|
6
|
+
reap: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
10
|
+
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
11
|
+
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
12
|
+
* unbound (run project_create) or no backend/PAT is configured.
|
|
13
|
+
*/
|
|
14
|
+
export declare function startSessionTunnel(projectCwd: string, localPort: number): Promise<SessionTunnel>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Per-session dev tunnel: resolve the project binding + machine config and mint a
|
|
2
|
+
// named tunnel via the backend (which holds the Cloudflare account creds). Stays
|
|
3
|
+
// free of any child-process / cloudflared coupling — the caller runs cloudflared
|
|
4
|
+
// with the returned run token (see runner.ts) and calls reap() on exit.
|
|
5
|
+
import { loadProjectBinding, resolveConfig } from "../shared/config.js";
|
|
6
|
+
import { mintTunnel, reapTunnel } from "../mcp/backend.js";
|
|
7
|
+
/**
|
|
8
|
+
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
9
|
+
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
10
|
+
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
11
|
+
* unbound (run project_create) or no backend/PAT is configured.
|
|
12
|
+
*/
|
|
13
|
+
export async function startSessionTunnel(projectCwd, localPort) {
|
|
14
|
+
const binding = await loadProjectBinding(projectCwd);
|
|
15
|
+
if (!binding) {
|
|
16
|
+
throw new Error("No project bound to this directory — run project_create first.");
|
|
17
|
+
}
|
|
18
|
+
const cfg = await resolveConfig(projectCwd);
|
|
19
|
+
const minted = await mintTunnel(cfg, binding.projectId, localPort);
|
|
20
|
+
return {
|
|
21
|
+
...minted,
|
|
22
|
+
url: `https://${minted.hostname}`,
|
|
23
|
+
reap: () => reapTunnel(cfg, binding.projectId, {
|
|
24
|
+
tunnelId: minted.tunnelId,
|
|
25
|
+
dnsRecordId: minted.dnsRecordId,
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
}
|
package/package.json
CHANGED