@vincentt-xr/harness 0.4.0 → 1.1.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.
Files changed (54) hide show
  1. package/dist/client/HarnessProvider.d.ts +43 -1
  2. package/dist/client/HarnessProvider.js +66 -10
  3. package/dist/client/annotate.d.ts +34 -0
  4. package/dist/client/annotate.js +104 -0
  5. package/dist/client/index.d.ts +2 -0
  6. package/dist/client/index.js +1 -0
  7. package/dist/shared/events.d.ts +138 -3
  8. package/dist/shared/events.js +25 -1
  9. package/dist/tunnel/cbor.d.ts +71 -0
  10. package/dist/tunnel/cbor.js +403 -0
  11. package/dist/tunnel/client.d.ts +219 -0
  12. package/dist/tunnel/client.js +620 -0
  13. package/dist/tunnel/forwardTarget.d.ts +69 -0
  14. package/dist/tunnel/forwardTarget.js +174 -0
  15. package/dist/tunnel/frame.d.ts +126 -0
  16. package/dist/tunnel/frame.js +244 -0
  17. package/dist/tunnel/index.d.ts +11 -0
  18. package/dist/tunnel/index.js +11 -0
  19. package/package.json +14 -31
  20. package/README.md +0 -87
  21. package/dist/cli/index.d.ts +0 -2
  22. package/dist/cli/index.js +0 -55
  23. package/dist/login/login.d.ts +0 -34
  24. package/dist/login/login.js +0 -148
  25. package/dist/mcp/backend.d.ts +0 -52
  26. package/dist/mcp/backend.js +0 -146
  27. package/dist/mcp/cli.d.ts +0 -2
  28. package/dist/mcp/cli.js +0 -10
  29. package/dist/mcp/diagnostics.d.ts +0 -13
  30. package/dist/mcp/diagnostics.js +0 -61
  31. package/dist/mcp/server.d.ts +0 -16
  32. package/dist/mcp/server.js +0 -239
  33. package/dist/preview/cloudflared.d.ts +0 -13
  34. package/dist/preview/cloudflared.js +0 -46
  35. package/dist/preview/index.d.ts +0 -3
  36. package/dist/preview/index.js +0 -6
  37. package/dist/preview/net.d.ts +0 -6
  38. package/dist/preview/net.js +0 -56
  39. package/dist/preview/proxy.d.ts +0 -4
  40. package/dist/preview/proxy.js +0 -49
  41. package/dist/preview/runner.d.ts +0 -45
  42. package/dist/preview/runner.js +0 -110
  43. package/dist/preview/tunnel.d.ts +0 -14
  44. package/dist/preview/tunnel.js +0 -28
  45. package/dist/relay/cli.d.ts +0 -2
  46. package/dist/relay/cli.js +0 -7
  47. package/dist/relay/server.d.ts +0 -12
  48. package/dist/relay/server.js +0 -85
  49. package/dist/relay/store.d.ts +0 -13
  50. package/dist/relay/store.js +0 -68
  51. package/dist/scaffold/index.d.ts +0 -26
  52. package/dist/scaffold/index.js +0 -85
  53. package/dist/shared/config.d.ts +0 -39
  54. package/dist/shared/config.js +0 -90
@@ -1,239 +0,0 @@
1
- // The agent-agnostic MCP server. Any MCP-capable agent (Claude Code, Codex,
2
- // Cursor, Cline, …) points its MCP config at `harness-mcp` and gets three tools
3
- // to PULL diagnostics off the phone, replacing the user hand-ferrying console
4
- // output and DevTools traces. All formatting lives in diagnostics.ts; this file
5
- // is only the MCP SDK wiring.
6
- import path from "node:path";
7
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
- import { z } from "zod";
10
- import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
11
- import { createProject, gitHeadSha, publishUpload } from "./backend.js";
12
- import { loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
13
- import { installDependencies, 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
- }
26
- /** Resolve a call's project dir: an explicit `projectDir` arg (absolute, or
27
- * relative to the server base) overrides the base; otherwise the base is used. */
28
- function resolveProjectDir(base, arg) {
29
- return arg ? path.resolve(base, arg) : base;
30
- }
31
- const projectDirInput = {
32
- projectDir: z
33
- .string()
34
- .optional()
35
- .describe("Absolute path (or path relative to the server's working dir) of the project. Defaults to VINCENTT_PROJECT_DIR or the server's cwd. Set this when your agent spawns MCP servers outside the project directory."),
36
- };
37
- /** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
38
- async function guard(run) {
39
- try {
40
- return { content: [{ type: "text", text: await run() }] };
41
- }
42
- catch (err) {
43
- const msg = err instanceof Error ? err.message : String(err);
44
- return { content: [{ type: "text", text: msg }], isError: true };
45
- }
46
- }
47
- const sharedInput = {
48
- sessionId: z
49
- .string()
50
- .optional()
51
- .describe("Filter to one preview session (phone). Omit for all."),
52
- since: z
53
- .number()
54
- .optional()
55
- .describe("Return only events with seq greater than this. Use the latestSeq from a prior call to poll for new events."),
56
- limit: z
57
- .number()
58
- .optional()
59
- .describe("Cap the number of events returned (newest kept)."),
60
- };
61
- export function createHarnessMcp(opts = {}) {
62
- const defaultRelay = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
63
- // Diag tools read through this. preview_start re-points it at the live preview's
64
- // relay port (OS-assigned), and preview_stop resets it to the default.
65
- let relayClient = defaultRelay;
66
- const now = opts.now ?? Date.now;
67
- const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
68
- server.registerTool("diag_logs", {
69
- description: "Read console logs the preview app produced on the device. Use `errorsOnly` to see just errors, `since` to poll for new logs. Replaces the user pasting console output.",
70
- inputSchema: {
71
- ...sharedInput,
72
- errorsOnly: z.boolean().optional().describe("Return only error-level logs."),
73
- },
74
- }, async ({ sessionId, since, limit, errorsOnly }) => {
75
- const q = { kind: "log", sessionId, since, limit };
76
- let result = await relayClient.query(q);
77
- if (errorsOnly)
78
- result = filterErrors(result);
79
- return { content: [{ type: "text", text: renderResult(result, "log", now()) }] };
80
- });
81
- server.registerTool("diag_network", {
82
- description: "Read fetch/XHR requests the preview app made on the device (method, URL, status, duration). Use to find failed asset loads or slow calls without DevTools.",
83
- inputSchema: sharedInput,
84
- }, async ({ sessionId, since, limit }) => {
85
- const result = await relayClient.query({
86
- kind: "network",
87
- sessionId,
88
- since,
89
- limit,
90
- });
91
- return {
92
- content: [{ type: "text", text: renderResult(result, "network", now()) }],
93
- };
94
- });
95
- server.registerTool("diag_trace", {
96
- description: "Read performance samples from the device (fps, longest main-thread task, long-task count, phase marks). Replaces hand-exporting a DevTools trace to spot jank.",
97
- inputSchema: sharedInput,
98
- }, async ({ sessionId, since, limit }) => {
99
- const result = await relayClient.query({ kind: "trace", sessionId, since, limit });
100
- return { content: [{ type: "text", text: renderResult(result, "trace", now()) }] };
101
- });
102
- // The project directory the lifecycle verbs operate on. Do NOT assume the agent
103
- // spawned the server in it — only Claude Code reliably does; Cursor/Copilot/Codex
104
- // spawn MCP servers from the editor root or home. VINCENTT_PROJECT_DIR (settable
105
- // in every host's MCP config `env`) is the portable knob; a per-call `projectDir`
106
- // arg overrides it. See docs/harness-agent-agnostic.md.
107
- const baseProjectDir = opts.cwd ?? process.env.VINCENTT_PROJECT_DIR ?? process.cwd();
108
- server.registerTool("project_create", {
109
- description: "Create a Vincentt project this working directory publishes to. If the directory has no app yet, it scaffolds the v2-template starter into it (like GitHub's Use-this-template); then it writes a local .vincentt/project.json binding and reserves <slug>.vincentt.app. Ask the user for a project name AND a slug before calling; pass what they give. Omit either to accept a default — name = folder name, slug = a platform-assigned catchy one. The slug is the permanent public subdomain (locked once published), so confirm it with the user. Run once per new app; publish with project_publish to go live.",
110
- inputSchema: {
111
- name: z
112
- .string()
113
- .optional()
114
- .describe("Display name for the project. Defaults to the working-directory name."),
115
- slug: z
116
- .string()
117
- .optional()
118
- .describe("Desired subdomain slug (<slug>.vincentt.app), lowercase kebab-case. Omit to let the platform assign one. Permanent once published."),
119
- scaffold: z
120
- .boolean()
121
- .optional()
122
- .describe("Whether to scaffold the v2-template starter when the directory has no app. Defaults to true; set false to bind an existing/empty directory without cloning."),
123
- ...projectDirInput,
124
- },
125
- }, async ({ name, slug, scaffold, projectDir }) => guard(async () => {
126
- const projectCwd = resolveProjectDir(baseProjectDir, projectDir);
127
- const existing = await loadProjectBinding(projectCwd);
128
- if (existing) {
129
- return `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}). Delete .vincentt/project.json to rebind.`;
130
- }
131
- // Resolve config first so a missing PAT fails before any clone/side effect.
132
- const cfg = await resolveConfig(projectCwd);
133
- let scaffolded = false;
134
- if (scaffold !== false && (await needsScaffold(projectCwd))) {
135
- await scaffoldFromTemplate(projectCwd);
136
- scaffolded = true;
137
- }
138
- const created = await createProject(cfg, name ?? path.basename(projectCwd), slug);
139
- const bindingPath = await writeProjectBinding(projectCwd, {
140
- projectId: created.projectId,
141
- slug: created.slug,
142
- });
143
- // Install deps for a freshly scaffolded app so the first preview/build works
144
- // out of the box. Best-effort — a failure just becomes a manual-install hint.
145
- let scaffoldNote = "";
146
- if (scaffolded) {
147
- scaffoldNote = "Scaffolded the v2-template starter into this directory.\n";
148
- try {
149
- await installDependencies(projectCwd);
150
- scaffoldNote += "Installed dependencies (npm install).\n";
151
- }
152
- catch (err) {
153
- const msg = err instanceof Error ? err.message : String(err);
154
- scaffoldNote += `Note: \`npm install\` failed (${msg}) — run it manually before preview.\n`;
155
- }
156
- }
157
- return (scaffoldNote +
158
- `Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
159
- `Binding written to ${bindingPath} (gitignored).\n` +
160
- `Build locally, then run project_publish to go live at ${created.slug}.vincentt.app.`);
161
- }));
162
- server.registerTool("project_publish", {
163
- description: "Publish the locally-built dist so this project goes live at <slug>.vincentt.app. Build first (e.g. npm run build), then call this — the dist is uploaded as-is and the server never rebuilds. Requires a prior project_create binding.",
164
- inputSchema: {
165
- distDir: z
166
- .string()
167
- .optional()
168
- .describe("Path to the built dist directory, relative to the project (default: dist)."),
169
- note: z
170
- .string()
171
- .optional()
172
- .describe("Optional release note recorded with the version."),
173
- ...projectDirInput,
174
- },
175
- }, async ({ distDir, note, projectDir }) => guard(async () => {
176
- const projectCwd = resolveProjectDir(baseProjectDir, projectDir);
177
- const binding = await loadProjectBinding(projectCwd);
178
- if (!binding) {
179
- return "No project is bound to this directory. Run project_create first.";
180
- }
181
- const cfg = await resolveConfig(projectCwd);
182
- const dist = path.resolve(projectCwd, distDir ?? "dist");
183
- const commitSha = await gitHeadSha(projectCwd);
184
- const result = await publishUpload(cfg, binding.projectId, dist, {
185
- note,
186
- commitSha,
187
- });
188
- return `Published v${result.version}. Live at ${result.liveUrl}\nThis version: ${result.url}`;
189
- }));
190
- server.registerTool("preview_start", {
191
- 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.",
192
- inputSchema: { ...projectDirInput },
193
- }, async ({ projectDir }) => guard(async () => {
194
- if (activePreview) {
195
- return `A preview is already running:\n${activePreview.url}\nCall preview_stop first to restart.`;
196
- }
197
- // App stdout would corrupt the MCP channel; keep it off stdout entirely and
198
- // route harness progress to stderr.
199
- activePreview = await startPreview({
200
- projectCwd: resolveProjectDir(baseProjectDir, projectDir),
201
- onLog: (m) => console.error(`[preview] ${m}`),
202
- });
203
- // Point the diag tools at this preview's relay (its port is OS-assigned).
204
- relayClient = httpRelayClient(`http://localhost:${activePreview.relayPort}`);
205
- const readiness = activePreview.appReady
206
- ? ""
207
- : `\nNote: the app on :${activePreview.appPort} isn't responding yet — the URL may be blank until its build finishes. Check diag_logs.`;
208
- return (`Live preview running — open on your device:\n${activePreview.url}\n\n` +
209
- `Diagnostics are live: diag_logs / diag_network / diag_trace now read from this device.\n` +
210
- `Call preview_stop when finished.${readiness}`);
211
- }));
212
- server.registerTool("preview_stop", {
213
- 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.",
214
- inputSchema: {},
215
- }, async () => guard(async () => {
216
- if (!activePreview)
217
- return "No preview is running.";
218
- await shutdownActivePreview();
219
- // The preview's relay is gone; send diag reads back to the default.
220
- relayClient = defaultRelay;
221
- return "Preview stopped. Tunnel and dev server torn down.";
222
- }));
223
- return server;
224
- }
225
- export async function runStdio(opts = {}) {
226
- const server = createHarnessMcp(opts);
227
- const transport = new StdioServerTransport();
228
- await server.connect(transport);
229
- // A running preview owns a child process + a live backend tunnel; reap both when
230
- // the agent disconnects so we never leak a tunnel/DNS route past the session.
231
- const shutdown = async () => {
232
- await shutdownActivePreview();
233
- process.exit(0);
234
- };
235
- process.on("SIGINT", shutdown);
236
- process.on("SIGTERM", shutdown);
237
- // stdout is the MCP channel — status goes to stderr only.
238
- console.error("[harness-mcp] connected over stdio");
239
- }
@@ -1,13 +0,0 @@
1
- export interface EnsureCloudflaredOptions {
2
- /** Progress sink for the one-time download (it is ~40MB and takes a few seconds). */
3
- onLog?: (message: string) => void;
4
- }
5
- /**
6
- * Return a spawnable cloudflared command/path, provisioning one if the host has
7
- * none. Resolves to `"cloudflared"` when a system install is on PATH, else to the
8
- * `cloudflared` package's managed binary (already fetched by its postinstall, or
9
- * downloaded here on demand when install scripts were skipped). Throws only if the
10
- * download itself fails (offline / blocked egress) — surface that to the user
11
- * with the manual `brew install cloudflared` fallback.
12
- */
13
- export declare function ensureCloudflared(opts?: EnsureCloudflaredOptions): Promise<string>;
@@ -1,46 +0,0 @@
1
- // Resolve a runnable cloudflared binary so a preview never hard-fails on a
2
- // missing system install. The tunnel protocol is proprietary QUIC to Cloudflare's
3
- // edge — there is no pure-Node connector, so *some* cloudflared binary is
4
- // required. We prefer one already on PATH (respects a user's own install) and
5
- // otherwise fall back to the copy the `cloudflared` npm package provisions, so a
6
- // fresh machine that installed only the harness can still preview on a device.
7
- import { spawnSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
9
- import { mkdir } from "node:fs/promises";
10
- import path from "node:path";
11
- function isOnPath() {
12
- // `which`/`where` exits 0 only when the binary resolves on PATH.
13
- const probe = process.platform === "win32" ? "where" : "which";
14
- return spawnSync(probe, ["cloudflared"], { stdio: "ignore" }).status === 0;
15
- }
16
- /**
17
- * Return a spawnable cloudflared command/path, provisioning one if the host has
18
- * none. Resolves to `"cloudflared"` when a system install is on PATH, else to the
19
- * `cloudflared` package's managed binary (already fetched by its postinstall, or
20
- * downloaded here on demand when install scripts were skipped). Throws only if the
21
- * download itself fails (offline / blocked egress) — surface that to the user
22
- * with the manual `brew install cloudflared` fallback.
23
- */
24
- export async function ensureCloudflared(opts = {}) {
25
- if (isOnPath())
26
- return "cloudflared";
27
- const cloudflared = await import("cloudflared");
28
- if (existsSync(cloudflared.bin))
29
- return cloudflared.bin;
30
- // Reached only when the package's postinstall was skipped (e.g. --ignore-scripts).
31
- const log = opts.onLog ?? (() => undefined);
32
- log("cloudflared not found — downloading a one-time copy…");
33
- await mkdir(path.dirname(cloudflared.bin), { recursive: true });
34
- // The download may print progress. On an MCP stdio server, ANY stdout write
35
- // corrupts the JSON-RPC stream — redirect stdout→stderr for its duration.
36
- const realWrite = process.stdout.write.bind(process.stdout);
37
- process.stdout.write = process.stderr.write.bind(process.stderr);
38
- try {
39
- await cloudflared.install(cloudflared.bin);
40
- }
41
- finally {
42
- process.stdout.write = realWrite;
43
- }
44
- log(`cloudflared ready at ${cloudflared.bin}`);
45
- return cloudflared.bin;
46
- }
@@ -1,3 +0,0 @@
1
- export { startSessionTunnel, type SessionTunnel } from "./tunnel.js";
2
- export { ensureCloudflared, type EnsureCloudflaredOptions } from "./cloudflared.js";
3
- export { startPreview, type StartPreviewOptions, type RunningPreview } from "./runner.js";
@@ -1,6 +0,0 @@
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";
5
- export { ensureCloudflared } from "./cloudflared.js";
6
- export { startPreview } from "./runner.js";
@@ -1,6 +0,0 @@
1
- /** Ask the OS for an unused ephemeral port. */
2
- export declare function getFreePort(): Promise<number>;
3
- /** True if something already accepts TCP connections on the port (e.g. a dev server). */
4
- export declare function isPortListening(port: number, host?: string): Promise<boolean>;
5
- /** Poll the app for any non-5xx HTTP response until it's ready or the timeout hits. */
6
- export declare function waitForApp(port: number, timeoutMs: number): Promise<boolean>;
@@ -1,56 +0,0 @@
1
- // TCP/HTTP port helpers for the preview runner: pick a free port for the internal
2
- // servers, detect a dev server already listening (so we reuse it instead of
3
- // spawning a second one), and wait for the app to actually serve before we hand
4
- // back a public URL.
5
- import { createServer, connect } from "node:net";
6
- import { get as httpGet } from "node:http";
7
- /** Ask the OS for an unused ephemeral port. */
8
- export function getFreePort() {
9
- return new Promise((resolve, reject) => {
10
- const srv = createServer();
11
- srv.on("error", reject);
12
- srv.listen(0, "127.0.0.1", () => {
13
- const { port } = srv.address();
14
- srv.close(() => resolve(port));
15
- });
16
- });
17
- }
18
- /** True if something already accepts TCP connections on the port (e.g. a dev server). */
19
- export function isPortListening(port, host = "127.0.0.1") {
20
- return new Promise((resolve) => {
21
- const sock = connect(port, host);
22
- const done = (v) => {
23
- sock.destroy();
24
- resolve(v);
25
- };
26
- sock.setTimeout(400);
27
- sock.once("connect", () => done(true));
28
- sock.once("timeout", () => done(false));
29
- sock.once("error", () => resolve(false));
30
- });
31
- }
32
- /** Poll the app for any non-5xx HTTP response until it's ready or the timeout hits. */
33
- export function waitForApp(port, timeoutMs) {
34
- const probe = () => new Promise((resolve) => {
35
- const req = httpGet({ host: "127.0.0.1", port, path: "/", timeout: 1000 }, (res) => {
36
- res.resume();
37
- resolve((res.statusCode ?? 500) < 500);
38
- });
39
- req.on("error", () => resolve(false));
40
- req.on("timeout", () => {
41
- req.destroy();
42
- resolve(false);
43
- });
44
- });
45
- return new Promise((resolve) => {
46
- const deadline = Date.now() + timeoutMs;
47
- const tick = async () => {
48
- if (await probe())
49
- return resolve(true);
50
- if (Date.now() >= deadline)
51
- return resolve(false);
52
- setTimeout(() => void tick(), 300);
53
- };
54
- void tick();
55
- });
56
- }
@@ -1,4 +0,0 @@
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;
@@ -1,49 +0,0 @@
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
- }
@@ -1,45 +0,0 @@
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
- /** App dev-serve port (default 5173). If a server is already listening here, it
6
- * is reused instead of spawning a second one. */
7
- appPort?: number;
8
- /** Relay port. Default: an OS-assigned free port (returned as relayPort). */
9
- relayPort?: number;
10
- /** Front-proxy port the tunnel points at. Default: an OS-assigned free port. */
11
- frontPort?: number;
12
- /** Path routed to the relay instead of the app (default /__harness). */
13
- harnessPath?: string;
14
- /** Command that starts the app dev serve on $PORT (default `npm run dev`). */
15
- devCommand?: string[];
16
- /** stdio for the app dev serve. "ignore" (default) keeps an MCP server's stdout
17
- * clean; a CLI can pass "inherit" to surface build output. */
18
- appStdio?: "ignore" | "inherit";
19
- /** How long to wait for cloudflared to register before giving up (default 45s). */
20
- registerTimeoutMs?: number;
21
- /** How long to wait for the app to serve before returning anyway (default 8s).
22
- * Kept short so an MCP tool call stays within strict clients' ~30s timeout;
23
- * a not-yet-ready app still returns (appReady=false), it isn't an error. */
24
- appReadyTimeoutMs?: number;
25
- /** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
26
- onLog?: (message: string) => void;
27
- }
28
- export interface RunningPreview {
29
- /** Public https URL to open on the device. */
30
- url: string;
31
- /** Relay port the diag_* tools query (may be OS-assigned). */
32
- relayPort: number;
33
- /** The app dev-serve port the tunnel ultimately serves. */
34
- appPort: number;
35
- /** Whether the app was responding when we returned (false = URL may be blank). */
36
- appReady: boolean;
37
- /** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
38
- stop: () => Promise<void>;
39
- }
40
- export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
41
- /**
42
- * Resolve once cloudflared reports a live edge connection, reject if it exits
43
- * first or never registers. Exported for unit testing the log-scan/timeout logic.
44
- */
45
- export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number): Promise<void>;
@@ -1,110 +0,0 @@
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
- import { getFreePort, isPortListening, waitForApp } from "./net.js";
18
- export async function startPreview(opts) {
19
- const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
20
- // Resolve ports. The app port defaults to 5173; the relay + front ports are
21
- // harness-internal, so auto-pick free ones (the relay port is returned for the
22
- // MCP diag_* tools to point at) instead of colliding on fixed defaults.
23
- const appPort = opts.appPort ?? 5173;
24
- const relayPort = opts.relayPort ?? (await getFreePort());
25
- const frontPort = opts.frontPort ?? (await getFreePort());
26
- // If a dev server is already up on appPort, reuse it — don't spawn a second one
27
- // that would fail to bind and leave the tunnel serving a broken origin.
28
- const reuseApp = await isPortListening(appPort);
29
- // Track every resource so any failure below can unwind exactly what started.
30
- let app;
31
- let relay;
32
- let front;
33
- let session;
34
- let tunnel;
35
- const stop = async () => {
36
- tunnel?.kill();
37
- app?.kill();
38
- front?.close();
39
- if (relay)
40
- await relay.close();
41
- if (session)
42
- await session.reap();
43
- };
44
- try {
45
- // Resolve cloudflared up front so a fresh host provisions it before we mint a
46
- // tunnel we couldn't otherwise run.
47
- const cloudflaredBin = await ensureCloudflared({ onLog });
48
- if (reuseApp) {
49
- onLog(`reusing the dev server already on :${appPort}`);
50
- }
51
- else {
52
- app = spawn(devCommand[0], devCommand.slice(1), {
53
- cwd: projectCwd,
54
- env: { ...process.env, PORT: String(appPort) },
55
- stdio: appStdio,
56
- // Windows: npm/pnpm are .cmd shims — spawn needs a shell to resolve them.
57
- shell: process.platform === "win32",
58
- });
59
- }
60
- relay = startRelay({
61
- port: relayPort,
62
- path: harnessPath,
63
- onLog: (m) => onLog(`[relay] ${m}`),
64
- });
65
- front = createServer((req, res) => proxyWeb(req, res, req.url?.startsWith(harnessPath) ? relayPort : appPort));
66
- front.on("upgrade", (req, socket, head) => proxyWs(req, socket, head, req.url?.startsWith(harnessPath) ? relayPort : appPort));
67
- await new Promise((resolve) => front.listen(frontPort, resolve));
68
- session = await startSessionTunnel(projectCwd, frontPort);
69
- tunnel = spawn(cloudflaredBin, ["tunnel", "run", "--token", session.runToken], {
70
- stdio: ["ignore", "pipe", "pipe"],
71
- });
72
- await waitForRegister(tunnel, registerTimeoutMs);
73
- // Don't hand back a live URL that serves a blank page: wait for the app to
74
- // actually respond. Non-fatal — a slow build still returns, flagged not-ready.
75
- const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
76
- if (!appReady) {
77
- onLog(`app on :${appPort} isn't responding yet — the URL may be blank until it builds`);
78
- }
79
- return { url: session.url, relayPort, appPort, appReady, stop };
80
- }
81
- catch (err) {
82
- await stop();
83
- throw err;
84
- }
85
- }
86
- /**
87
- * Resolve once cloudflared reports a live edge connection, reject if it exits
88
- * first or never registers. Exported for unit testing the log-scan/timeout logic.
89
- */
90
- export function waitForRegister(tunnel, timeoutMs) {
91
- return new Promise((resolve, reject) => {
92
- let settled = false;
93
- const finish = (fn) => {
94
- if (settled)
95
- return;
96
- settled = true;
97
- clearTimeout(timer);
98
- fn();
99
- };
100
- const scan = (buf) => {
101
- if (/Registered tunnel connection|Connection [^ ]+ registered/.test(String(buf))) {
102
- finish(resolve);
103
- }
104
- };
105
- tunnel.stdout?.on("data", scan);
106
- tunnel.stderr?.on("data", scan);
107
- tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).`))));
108
- const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.`))), timeoutMs);
109
- });
110
- }
@@ -1,14 +0,0 @@
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>;
@@ -1,28 +0,0 @@
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
- }
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/relay/cli.js DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env node
2
- // `harness-relay` — start the diagnostics relay beside Vite. The preview loop
3
- // spawns this (or you run it directly). Prints a line the tunnel step can read.
4
- import { startRelay } from "./server.js";
5
- const portArg = process.argv.indexOf("--port");
6
- const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : 7331;
7
- startRelay({ port, onLog: (m) => console.log(`[harness-relay] ${m}`) });