@vincentt-xr/harness 0.3.0 → 1.0.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/client/HarnessProvider.d.ts +5 -0
- package/dist/client/HarnessProvider.js +11 -0
- package/dist/client/annotate.d.ts +34 -0
- package/dist/client/annotate.js +104 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/shared/events.d.ts +50 -0
- package/package.json +8 -33
- package/README.md +0 -87
- package/dist/mcp/backend.d.ts +0 -52
- package/dist/mcp/backend.js +0 -146
- package/dist/mcp/cli.d.ts +0 -2
- package/dist/mcp/cli.js +0 -10
- package/dist/mcp/diagnostics.d.ts +0 -13
- package/dist/mcp/diagnostics.js +0 -61
- package/dist/mcp/server.d.ts +0 -16
- package/dist/mcp/server.js +0 -221
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -37
- package/dist/preview/index.d.ts +0 -3
- package/dist/preview/index.js +0 -6
- package/dist/preview/net.d.ts +0 -6
- package/dist/preview/net.js +0 -56
- package/dist/preview/proxy.d.ts +0 -4
- package/dist/preview/proxy.js +0 -49
- package/dist/preview/runner.d.ts +0 -43
- package/dist/preview/runner.js +0 -110
- package/dist/preview/tunnel.d.ts +0 -14
- package/dist/preview/tunnel.js +0 -28
- package/dist/relay/cli.d.ts +0 -2
- package/dist/relay/cli.js +0 -7
- package/dist/relay/server.d.ts +0 -12
- package/dist/relay/server.js +0 -85
- package/dist/relay/store.d.ts +0 -13
- package/dist/relay/store.js +0 -68
- package/dist/scaffold/index.d.ts +0 -26
- package/dist/scaffold/index.js +0 -85
- package/dist/shared/config.d.ts +0 -33
- package/dist/shared/config.js +0 -76
package/dist/mcp/diagnostics.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
// The MCP server's logic, independent of the MCP SDK surface: fetch events from
|
|
2
|
-
// the relay and format them into the compact text an agent reads. Kept separate
|
|
3
|
-
// from the SDK wiring (server.ts) so the formatting is unit-testable and the SDK
|
|
4
|
-
// version can churn without touching this.
|
|
5
|
-
/** A RelayClient backed by the relay's HTTP /query endpoint. */
|
|
6
|
-
export function httpRelayClient(baseUrl) {
|
|
7
|
-
return {
|
|
8
|
-
async query(q) {
|
|
9
|
-
const res = await fetch(`${baseUrl}/query`, {
|
|
10
|
-
method: "POST",
|
|
11
|
-
headers: { "content-type": "application/json" },
|
|
12
|
-
body: JSON.stringify(q),
|
|
13
|
-
});
|
|
14
|
-
if (!res.ok)
|
|
15
|
-
throw new Error(`relay /query returned ${res.status}`);
|
|
16
|
-
return (await res.json());
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
/** Human-time from the client timestamp, relative to now, for the agent's read. */
|
|
21
|
-
function ago(t, now) {
|
|
22
|
-
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
23
|
-
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
|
|
24
|
-
}
|
|
25
|
-
export function formatLog(e, now) {
|
|
26
|
-
const level = e.level.toUpperCase().padEnd(5);
|
|
27
|
-
const origin = e.origin ? ` (${e.origin})` : "";
|
|
28
|
-
return `[${level}] ${e.message}${origin} · ${ago(e.t, now)} #${e.seq}`;
|
|
29
|
-
}
|
|
30
|
-
export function formatNetwork(e, now) {
|
|
31
|
-
const status = e.error ? `ERR ${e.error}` : String(e.status);
|
|
32
|
-
return `${e.method} ${e.url} → ${status} (${e.durationMs}ms) · ${ago(e.t, now)} #${e.seq}`;
|
|
33
|
-
}
|
|
34
|
-
export function formatTrace(e, now) {
|
|
35
|
-
const marks = e.marks.length ? ` marks: ${e.marks.join(", ")}` : "";
|
|
36
|
-
return (`${e.fps.toFixed(0)}fps over ${e.windowMs}ms · longest task ${e.longestTaskMs}ms · ` +
|
|
37
|
-
`${e.longTaskCount} long task(s)${marks} · ${ago(e.t, now)} #${e.seq}`);
|
|
38
|
-
}
|
|
39
|
-
/** Render a RelayResult for one kind into the text block a tool returns. */
|
|
40
|
-
export function renderResult(result, kind, now) {
|
|
41
|
-
const lines = result.events.map((e) => {
|
|
42
|
-
if (e.kind === "log")
|
|
43
|
-
return formatLog(e, now);
|
|
44
|
-
if (e.kind === "network")
|
|
45
|
-
return formatNetwork(e, now);
|
|
46
|
-
return formatTrace(e, now);
|
|
47
|
-
});
|
|
48
|
-
const header = result.sessions.length > 1 ? `sessions: ${result.sessions.join(", ")}\n` : "";
|
|
49
|
-
const cursor = `\n\n(latestSeq ${result.latestSeq} — pass since=${result.latestSeq} to get only newer ${kind} events)`;
|
|
50
|
-
if (lines.length === 0) {
|
|
51
|
-
return `${header}No ${kind} events${result.latestSeq >= 0 ? " matched" : " yet — is the preview app open on the device?"}.${result.latestSeq >= 0 ? cursor : ""}`;
|
|
52
|
-
}
|
|
53
|
-
return `${header}${lines.join("\n")}${cursor}`;
|
|
54
|
-
}
|
|
55
|
-
/** Only-errors convenience filter applied on top of a log query result. */
|
|
56
|
-
export function filterErrors(result) {
|
|
57
|
-
return {
|
|
58
|
-
...result,
|
|
59
|
-
events: result.events.filter((e) => e.kind === "log" && e.level === "error"),
|
|
60
|
-
};
|
|
61
|
-
}
|
package/dist/mcp/server.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
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>;
|
|
5
|
-
export interface McpOptions {
|
|
6
|
-
/** Base URL of the relay's HTTP endpoint (default matches the relay CLI). */
|
|
7
|
-
relayUrl?: string;
|
|
8
|
-
/** Injected for tests; defaults to an HTTP client against relayUrl. */
|
|
9
|
-
relay?: RelayClient;
|
|
10
|
-
/** Injected clock for deterministic relative-time rendering in tests. */
|
|
11
|
-
now?: () => number;
|
|
12
|
-
/** Project directory for the lifecycle verbs' binding. Defaults to process.cwd(). */
|
|
13
|
-
cwd?: string;
|
|
14
|
-
}
|
|
15
|
-
export declare function createHarnessMcp(opts?: McpOptions): McpServer;
|
|
16
|
-
export declare function runStdio(opts?: McpOptions): Promise<void>;
|
package/dist/mcp/server.js
DELETED
|
@@ -1,221 +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
|
-
/** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
|
|
27
|
-
async function guard(run) {
|
|
28
|
-
try {
|
|
29
|
-
return { content: [{ type: "text", text: await run() }] };
|
|
30
|
-
}
|
|
31
|
-
catch (err) {
|
|
32
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
-
return { content: [{ type: "text", text: msg }], isError: true };
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
const sharedInput = {
|
|
37
|
-
sessionId: z
|
|
38
|
-
.string()
|
|
39
|
-
.optional()
|
|
40
|
-
.describe("Filter to one preview session (phone). Omit for all."),
|
|
41
|
-
since: z
|
|
42
|
-
.number()
|
|
43
|
-
.optional()
|
|
44
|
-
.describe("Return only events with seq greater than this. Use the latestSeq from a prior call to poll for new events."),
|
|
45
|
-
limit: z
|
|
46
|
-
.number()
|
|
47
|
-
.optional()
|
|
48
|
-
.describe("Cap the number of events returned (newest kept)."),
|
|
49
|
-
};
|
|
50
|
-
export function createHarnessMcp(opts = {}) {
|
|
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;
|
|
55
|
-
const now = opts.now ?? Date.now;
|
|
56
|
-
const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
|
|
57
|
-
server.registerTool("diag_logs", {
|
|
58
|
-
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.",
|
|
59
|
-
inputSchema: {
|
|
60
|
-
...sharedInput,
|
|
61
|
-
errorsOnly: z.boolean().optional().describe("Return only error-level logs."),
|
|
62
|
-
},
|
|
63
|
-
}, async ({ sessionId, since, limit, errorsOnly }) => {
|
|
64
|
-
const q = { kind: "log", sessionId, since, limit };
|
|
65
|
-
let result = await relayClient.query(q);
|
|
66
|
-
if (errorsOnly)
|
|
67
|
-
result = filterErrors(result);
|
|
68
|
-
return { content: [{ type: "text", text: renderResult(result, "log", now()) }] };
|
|
69
|
-
});
|
|
70
|
-
server.registerTool("diag_network", {
|
|
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.",
|
|
72
|
-
inputSchema: sharedInput,
|
|
73
|
-
}, async ({ sessionId, since, limit }) => {
|
|
74
|
-
const result = await relayClient.query({
|
|
75
|
-
kind: "network",
|
|
76
|
-
sessionId,
|
|
77
|
-
since,
|
|
78
|
-
limit,
|
|
79
|
-
});
|
|
80
|
-
return {
|
|
81
|
-
content: [{ type: "text", text: renderResult(result, "network", now()) }],
|
|
82
|
-
};
|
|
83
|
-
});
|
|
84
|
-
server.registerTool("diag_trace", {
|
|
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.",
|
|
86
|
-
inputSchema: sharedInput,
|
|
87
|
-
}, async ({ sessionId, since, limit }) => {
|
|
88
|
-
const result = await relayClient.query({ kind: "trace", sessionId, since, limit });
|
|
89
|
-
return { content: [{ type: "text", text: renderResult(result, "trace", now()) }] };
|
|
90
|
-
});
|
|
91
|
-
// The cwd the agent spawned this server in IS the creator's project directory —
|
|
92
|
-
// where the .vincentt/project.json binding is read and written.
|
|
93
|
-
const projectCwd = opts.cwd ?? process.cwd();
|
|
94
|
-
server.registerTool("project_create", {
|
|
95
|
-
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.",
|
|
96
|
-
inputSchema: {
|
|
97
|
-
name: z
|
|
98
|
-
.string()
|
|
99
|
-
.optional()
|
|
100
|
-
.describe("Display name for the project. Defaults to the working-directory name."),
|
|
101
|
-
slug: z
|
|
102
|
-
.string()
|
|
103
|
-
.optional()
|
|
104
|
-
.describe("Desired subdomain slug (<slug>.vincentt.app), lowercase kebab-case. Omit to let the platform assign one. Permanent once published."),
|
|
105
|
-
scaffold: z
|
|
106
|
-
.boolean()
|
|
107
|
-
.optional()
|
|
108
|
-
.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."),
|
|
109
|
-
},
|
|
110
|
-
}, async ({ name, slug, scaffold }) => guard(async () => {
|
|
111
|
-
const existing = await loadProjectBinding(projectCwd);
|
|
112
|
-
if (existing) {
|
|
113
|
-
return `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}). Delete .vincentt/project.json to rebind.`;
|
|
114
|
-
}
|
|
115
|
-
// Resolve config first so a missing PAT fails before any clone/side effect.
|
|
116
|
-
const cfg = await resolveConfig(projectCwd);
|
|
117
|
-
let scaffolded = false;
|
|
118
|
-
if (scaffold !== false && (await needsScaffold(projectCwd))) {
|
|
119
|
-
await scaffoldFromTemplate(projectCwd);
|
|
120
|
-
scaffolded = true;
|
|
121
|
-
}
|
|
122
|
-
const created = await createProject(cfg, name ?? path.basename(projectCwd), slug);
|
|
123
|
-
const bindingPath = await writeProjectBinding(projectCwd, {
|
|
124
|
-
projectId: created.projectId,
|
|
125
|
-
slug: created.slug,
|
|
126
|
-
});
|
|
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 +
|
|
142
|
-
`Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
|
|
143
|
-
`Binding written to ${bindingPath} (gitignored).\n` +
|
|
144
|
-
`Build locally, then run project_publish to go live at ${created.slug}.vincentt.app.`);
|
|
145
|
-
}));
|
|
146
|
-
server.registerTool("project_publish", {
|
|
147
|
-
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.",
|
|
148
|
-
inputSchema: {
|
|
149
|
-
distDir: z
|
|
150
|
-
.string()
|
|
151
|
-
.optional()
|
|
152
|
-
.describe("Path to the built dist directory, relative to the project (default: dist)."),
|
|
153
|
-
note: z
|
|
154
|
-
.string()
|
|
155
|
-
.optional()
|
|
156
|
-
.describe("Optional release note recorded with the version."),
|
|
157
|
-
},
|
|
158
|
-
}, async ({ distDir, note }) => guard(async () => {
|
|
159
|
-
const binding = await loadProjectBinding(projectCwd);
|
|
160
|
-
if (!binding) {
|
|
161
|
-
return "No project is bound to this directory. Run project_create first.";
|
|
162
|
-
}
|
|
163
|
-
const cfg = await resolveConfig(projectCwd);
|
|
164
|
-
const dist = path.resolve(projectCwd, distDir ?? "dist");
|
|
165
|
-
const commitSha = await gitHeadSha(projectCwd);
|
|
166
|
-
const result = await publishUpload(cfg, binding.projectId, dist, {
|
|
167
|
-
note,
|
|
168
|
-
commitSha,
|
|
169
|
-
});
|
|
170
|
-
return `Published v${result.version}. Live at ${result.liveUrl}\nThis version: ${result.url}`;
|
|
171
|
-
}));
|
|
172
|
-
server.registerTool("preview_start", {
|
|
173
|
-
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.",
|
|
174
|
-
inputSchema: {},
|
|
175
|
-
}, async () => guard(async () => {
|
|
176
|
-
if (activePreview) {
|
|
177
|
-
return `A preview is already running:\n${activePreview.url}\nCall preview_stop first to restart.`;
|
|
178
|
-
}
|
|
179
|
-
// App stdout would corrupt the MCP channel; keep it off stdout entirely and
|
|
180
|
-
// route harness progress to stderr.
|
|
181
|
-
activePreview = await startPreview({
|
|
182
|
-
projectCwd,
|
|
183
|
-
onLog: (m) => console.error(`[preview] ${m}`),
|
|
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.`;
|
|
190
|
-
return (`Live preview running — open on your device:\n${activePreview.url}\n\n` +
|
|
191
|
-
`Diagnostics are live: diag_logs / diag_network / diag_trace now read from this device.\n` +
|
|
192
|
-
`Call preview_stop when finished.${readiness}`);
|
|
193
|
-
}));
|
|
194
|
-
server.registerTool("preview_stop", {
|
|
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.",
|
|
196
|
-
inputSchema: {},
|
|
197
|
-
}, async () => guard(async () => {
|
|
198
|
-
if (!activePreview)
|
|
199
|
-
return "No preview is running.";
|
|
200
|
-
await shutdownActivePreview();
|
|
201
|
-
// The preview's relay is gone; send diag reads back to the default.
|
|
202
|
-
relayClient = defaultRelay;
|
|
203
|
-
return "Preview stopped. Tunnel and dev server torn down.";
|
|
204
|
-
}));
|
|
205
|
-
return server;
|
|
206
|
-
}
|
|
207
|
-
export async function runStdio(opts = {}) {
|
|
208
|
-
const server = createHarnessMcp(opts);
|
|
209
|
-
const transport = new StdioServerTransport();
|
|
210
|
-
await server.connect(transport);
|
|
211
|
-
// A running preview owns a child process + a live backend tunnel; reap both when
|
|
212
|
-
// the agent disconnects so we never leak a tunnel/DNS route past the session.
|
|
213
|
-
const shutdown = async () => {
|
|
214
|
-
await shutdownActivePreview();
|
|
215
|
-
process.exit(0);
|
|
216
|
-
};
|
|
217
|
-
process.on("SIGINT", shutdown);
|
|
218
|
-
process.on("SIGTERM", shutdown);
|
|
219
|
-
// stdout is the MCP channel — status goes to stderr only.
|
|
220
|
-
console.error("[harness-mcp] connected over stdio");
|
|
221
|
-
}
|
|
@@ -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,37 +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
|
-
await cloudflared.install(cloudflared.bin);
|
|
35
|
-
log(`cloudflared ready at ${cloudflared.bin}`);
|
|
36
|
-
return cloudflared.bin;
|
|
37
|
-
}
|
package/dist/preview/index.d.ts
DELETED
package/dist/preview/index.js
DELETED
|
@@ -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";
|
package/dist/preview/net.d.ts
DELETED
|
@@ -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>;
|
package/dist/preview/net.js
DELETED
|
@@ -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
|
-
}
|
package/dist/preview/proxy.d.ts
DELETED
|
@@ -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;
|
package/dist/preview/proxy.js
DELETED
|
@@ -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
|
-
}
|
package/dist/preview/runner.d.ts
DELETED
|
@@ -1,43 +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 20s). */
|
|
22
|
-
appReadyTimeoutMs?: number;
|
|
23
|
-
/** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
|
|
24
|
-
onLog?: (message: string) => void;
|
|
25
|
-
}
|
|
26
|
-
export interface RunningPreview {
|
|
27
|
-
/** Public https URL to open on the device. */
|
|
28
|
-
url: string;
|
|
29
|
-
/** Relay port the diag_* tools query (may be OS-assigned). */
|
|
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;
|
|
35
|
-
/** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
|
|
36
|
-
stop: () => Promise<void>;
|
|
37
|
-
}
|
|
38
|
-
export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
|
|
39
|
-
/**
|
|
40
|
-
* Resolve once cloudflared reports a live edge connection, reject if it exits
|
|
41
|
-
* first or never registers. Exported for unit testing the log-scan/timeout logic.
|
|
42
|
-
*/
|
|
43
|
-
export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number): Promise<void>;
|
package/dist/preview/runner.js
DELETED
|
@@ -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 = 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);
|
|
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
|
-
}
|
package/dist/preview/tunnel.d.ts
DELETED
|
@@ -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>;
|