@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.
@@ -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;
@@ -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 ? "Scaffolded the v2-template starter into this directory.\n" : "") +
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.string().optional().describe("Optional release note recorded with the version."),
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, { note, commitSha });
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
  }
@@ -1,15 +1,3 @@
1
- import { type MintedTunnel } from "../mcp/backend.js";
1
+ export { startSessionTunnel, type SessionTunnel } from "./tunnel.js";
2
2
  export { ensureCloudflared, type EnsureCloudflaredOptions } from "./cloudflared.js";
3
- export interface SessionTunnel extends MintedTunnel {
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";
@@ -1,30 +1,6 @@
1
- // Preview-script glue for the per-session dev tunnel. Resolves the project
2
- // binding + machine config and mints a named tunnel via the backend (which holds
3
- // the Cloudflare account creds). The caller (an app's preview.mjs) runs
4
- // `cloudflared tunnel run --token <runToken>` itself and calls reap() on exit —
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/harness",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Vincentt AR dev-loop harness — in-app diagnostics client + relay + agent-agnostic MCP server",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",