@vincentt-xr/harness 0.2.0 → 0.3.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.
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
11
11
  import { createProject, gitHeadSha, publishUpload } from "./backend.js";
12
12
  import { loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
13
- import { needsScaffold, scaffoldFromTemplate } from "../scaffold/index.js";
13
+ import { installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
14
14
  import { startPreview } from "../preview/index.js";
15
15
  // One preview per server process. Held at module scope so runStdio can reap it on
16
16
  // shutdown without threading the handle through createHarnessMcp's return type.
@@ -48,7 +48,10 @@ const sharedInput = {
48
48
  .describe("Cap the number of events returned (newest kept)."),
49
49
  };
50
50
  export function createHarnessMcp(opts = {}) {
51
- const relay = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
51
+ const defaultRelay = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
52
+ // Diag tools read through this. preview_start re-points it at the live preview's
53
+ // relay port (OS-assigned), and preview_stop resets it to the default.
54
+ let relayClient = defaultRelay;
52
55
  const now = opts.now ?? Date.now;
53
56
  const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
54
57
  server.registerTool("diag_logs", {
@@ -59,7 +62,7 @@ export function createHarnessMcp(opts = {}) {
59
62
  },
60
63
  }, async ({ sessionId, since, limit, errorsOnly }) => {
61
64
  const q = { kind: "log", sessionId, since, limit };
62
- let result = await relay.query(q);
65
+ let result = await relayClient.query(q);
63
66
  if (errorsOnly)
64
67
  result = filterErrors(result);
65
68
  return { content: [{ type: "text", text: renderResult(result, "log", now()) }] };
@@ -68,7 +71,12 @@ export function createHarnessMcp(opts = {}) {
68
71
  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.",
69
72
  inputSchema: sharedInput,
70
73
  }, async ({ sessionId, since, limit }) => {
71
- const result = await relay.query({ kind: "network", sessionId, since, limit });
74
+ const result = await relayClient.query({
75
+ kind: "network",
76
+ sessionId,
77
+ since,
78
+ limit,
79
+ });
72
80
  return {
73
81
  content: [{ type: "text", text: renderResult(result, "network", now()) }],
74
82
  };
@@ -77,7 +85,7 @@ export function createHarnessMcp(opts = {}) {
77
85
  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.",
78
86
  inputSchema: sharedInput,
79
87
  }, async ({ sessionId, since, limit }) => {
80
- const result = await relay.query({ kind: "trace", sessionId, since, limit });
88
+ const result = await relayClient.query({ kind: "trace", sessionId, since, limit });
81
89
  return { content: [{ type: "text", text: renderResult(result, "trace", now()) }] };
82
90
  });
83
91
  // The cwd the agent spawned this server in IS the creator's project directory —
@@ -116,9 +124,21 @@ export function createHarnessMcp(opts = {}) {
116
124
  projectId: created.projectId,
117
125
  slug: created.slug,
118
126
  });
119
- return ((scaffolded
120
- ? "Scaffolded the v2-template starter into this directory.\n"
121
- : "") +
127
+ // Install deps for a freshly scaffolded app so the first preview/build works
128
+ // out of the box. Best-effort a failure just becomes a manual-install hint.
129
+ let scaffoldNote = "";
130
+ if (scaffolded) {
131
+ scaffoldNote = "Scaffolded the v2-template starter into this directory.\n";
132
+ try {
133
+ await installDependencies(projectCwd);
134
+ scaffoldNote += "Installed dependencies (npm install).\n";
135
+ }
136
+ catch (err) {
137
+ const msg = err instanceof Error ? err.message : String(err);
138
+ scaffoldNote += `Note: \`npm install\` failed (${msg}) — run it manually before preview.\n`;
139
+ }
140
+ }
141
+ return (scaffoldNote +
122
142
  `Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
123
143
  `Binding written to ${bindingPath} (gitignored).\n` +
124
144
  `Build locally, then run project_publish to go live at ${created.slug}.vincentt.app.`);
@@ -162,9 +182,14 @@ export function createHarnessMcp(opts = {}) {
162
182
  projectCwd,
163
183
  onLog: (m) => console.error(`[preview] ${m}`),
164
184
  });
185
+ // Point the diag tools at this preview's relay (its port is OS-assigned).
186
+ relayClient = httpRelayClient(`http://localhost:${activePreview.relayPort}`);
187
+ const readiness = activePreview.appReady
188
+ ? ""
189
+ : `\nNote: the app on :${activePreview.appPort} isn't responding yet — the URL may be blank until its build finishes. Check diag_logs.`;
165
190
  return (`Live preview running — open on your device:\n${activePreview.url}\n\n` +
166
191
  `Diagnostics are live: diag_logs / diag_network / diag_trace now read from this device.\n` +
167
- `Call preview_stop when finished.`);
192
+ `Call preview_stop when finished.${readiness}`);
168
193
  }));
169
194
  server.registerTool("preview_stop", {
170
195
  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.",
@@ -173,6 +198,8 @@ export function createHarnessMcp(opts = {}) {
173
198
  if (!activePreview)
174
199
  return "No preview is running.";
175
200
  await shutdownActivePreview();
201
+ // The preview's relay is gone; send diag reads back to the default.
202
+ relayClient = defaultRelay;
176
203
  return "Preview stopped. Tunnel and dev server torn down.";
177
204
  }));
178
205
  return server;
@@ -0,0 +1,6 @@
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>;
@@ -0,0 +1,56 @@
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
+ }
@@ -2,11 +2,12 @@ import { type ChildProcess } from "node:child_process";
2
2
  export interface StartPreviewOptions {
3
3
  /** Project directory — its .vincentt binding + dev script drive the preview. */
4
4
  projectCwd: string;
5
- /** Internal port for the app dev serve (default 5173). */
5
+ /** App dev-serve port (default 5173). If a server is already listening here, it
6
+ * is reused instead of spawning a second one. */
6
7
  appPort?: number;
7
- /** Relay port; must match the MCP server's relay client (default 7331). */
8
+ /** Relay port. Default: an OS-assigned free port (returned as relayPort). */
8
9
  relayPort?: number;
9
- /** Port the tunnel points at the front proxy origin (default 5190). */
10
+ /** Front-proxy port the tunnel points at. Default: an OS-assigned free port. */
10
11
  frontPort?: number;
11
12
  /** Path routed to the relay instead of the app (default /__harness). */
12
13
  harnessPath?: string;
@@ -17,14 +18,20 @@ export interface StartPreviewOptions {
17
18
  appStdio?: "ignore" | "inherit";
18
19
  /** How long to wait for cloudflared to register before giving up (default 45s). */
19
20
  registerTimeoutMs?: number;
21
+ /** How long to wait for the app to serve before returning anyway (default 20s). */
22
+ appReadyTimeoutMs?: number;
20
23
  /** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
21
24
  onLog?: (message: string) => void;
22
25
  }
23
26
  export interface RunningPreview {
24
27
  /** Public https URL to open on the device. */
25
28
  url: string;
26
- /** Relay port the diag_* tools query. */
29
+ /** Relay port the diag_* tools query (may be OS-assigned). */
27
30
  relayPort: number;
31
+ /** The app dev-serve port the tunnel ultimately serves. */
32
+ appPort: number;
33
+ /** Whether the app was responding when we returned (false = URL may be blank). */
34
+ appReady: boolean;
28
35
  /** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
29
36
  stop: () => Promise<void>;
30
37
  }
@@ -14,8 +14,18 @@ import { startRelay } from "../relay/server.js";
14
14
  import { ensureCloudflared } from "./cloudflared.js";
15
15
  import { startSessionTunnel } from "./tunnel.js";
16
16
  import { proxyWeb, proxyWs } from "./proxy.js";
17
+ import { getFreePort, isPortListening, waitForApp } from "./net.js";
17
18
  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
+ const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 20_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);
19
29
  // Track every resource so any failure below can unwind exactly what started.
20
30
  let app;
21
31
  let relay;
@@ -35,11 +45,18 @@ export async function startPreview(opts) {
35
45
  // Resolve cloudflared up front so a fresh host provisions it before we mint a
36
46
  // tunnel we couldn't otherwise run.
37
47
  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
- });
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
+ }
43
60
  relay = startRelay({
44
61
  port: relayPort,
45
62
  path: harnessPath,
@@ -53,7 +70,13 @@ export async function startPreview(opts) {
53
70
  stdio: ["ignore", "pipe", "pipe"],
54
71
  });
55
72
  await waitForRegister(tunnel, registerTimeoutMs);
56
- return { url: session.url, relayPort, stop };
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 };
57
80
  }
58
81
  catch (err) {
59
82
  await stop();
@@ -17,3 +17,10 @@ export interface ScaffoldOptions {
17
17
  * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
18
18
  */
19
19
  export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<void>;
20
+ /**
21
+ * Install a freshly scaffolded project's dependencies so the first preview/build
22
+ * doesn't fail on a missing node_modules. Throws on failure (no network, no npm);
23
+ * the caller surfaces it as a "run npm install yourself" hint rather than aborting
24
+ * project creation.
25
+ */
26
+ export declare function installDependencies(dir: string): Promise<void>;
@@ -70,3 +70,16 @@ async function isGitRepo(dir) {
70
70
  return false;
71
71
  }
72
72
  }
73
+ /**
74
+ * Install a freshly scaffolded project's dependencies so the first preview/build
75
+ * doesn't fail on a missing node_modules. Throws on failure (no network, no npm);
76
+ * the caller surfaces it as a "run npm install yourself" hint rather than aborting
77
+ * project creation.
78
+ */
79
+ export async function installDependencies(dir) {
80
+ // Windows: npm is npm.cmd — execFile needs a shell to resolve it.
81
+ await execFileAsync("npm", ["install"], {
82
+ cwd: dir,
83
+ shell: process.platform === "win32",
84
+ });
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/harness",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",