@vincentt-xr/harness 0.4.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.
Files changed (43) hide show
  1. package/dist/client/HarnessProvider.d.ts +5 -0
  2. package/dist/client/HarnessProvider.js +11 -0
  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 +50 -0
  8. package/package.json +8 -34
  9. package/README.md +0 -87
  10. package/dist/cli/index.d.ts +0 -2
  11. package/dist/cli/index.js +0 -55
  12. package/dist/login/login.d.ts +0 -34
  13. package/dist/login/login.js +0 -148
  14. package/dist/mcp/backend.d.ts +0 -52
  15. package/dist/mcp/backend.js +0 -146
  16. package/dist/mcp/cli.d.ts +0 -2
  17. package/dist/mcp/cli.js +0 -10
  18. package/dist/mcp/diagnostics.d.ts +0 -13
  19. package/dist/mcp/diagnostics.js +0 -61
  20. package/dist/mcp/server.d.ts +0 -16
  21. package/dist/mcp/server.js +0 -239
  22. package/dist/preview/cloudflared.d.ts +0 -13
  23. package/dist/preview/cloudflared.js +0 -46
  24. package/dist/preview/index.d.ts +0 -3
  25. package/dist/preview/index.js +0 -6
  26. package/dist/preview/net.d.ts +0 -6
  27. package/dist/preview/net.js +0 -56
  28. package/dist/preview/proxy.d.ts +0 -4
  29. package/dist/preview/proxy.js +0 -49
  30. package/dist/preview/runner.d.ts +0 -45
  31. package/dist/preview/runner.js +0 -110
  32. package/dist/preview/tunnel.d.ts +0 -14
  33. package/dist/preview/tunnel.js +0 -28
  34. package/dist/relay/cli.d.ts +0 -2
  35. package/dist/relay/cli.js +0 -7
  36. package/dist/relay/server.d.ts +0 -12
  37. package/dist/relay/server.js +0 -85
  38. package/dist/relay/store.d.ts +0 -13
  39. package/dist/relay/store.js +0 -68
  40. package/dist/scaffold/index.d.ts +0 -26
  41. package/dist/scaffold/index.js +0 -85
  42. package/dist/shared/config.d.ts +0 -39
  43. package/dist/shared/config.js +0 -90
@@ -1,52 +0,0 @@
1
- import type { ResolvedConfig } from "../shared/config.js";
2
- export interface CreatedProject {
3
- projectId: string;
4
- slug: string;
5
- name: string;
6
- }
7
- export interface PublishResult {
8
- version: number;
9
- /** Immutable versioned URL (<slug>.<apex>/v<n>/). */
10
- url: string;
11
- /** Stable live alias (<slug>.<apex>/). */
12
- liveUrl: string;
13
- }
14
- export declare class BackendError extends Error {
15
- readonly status?: number | undefined;
16
- constructor(message: string, status?: number | undefined);
17
- }
18
- /**
19
- * Create a backend project (reuses POST /projects). A `slug` pins the permanent
20
- * <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
21
- * taken slug comes back 409, surfaced so the caller can re-ask.
22
- */
23
- export declare function createProject(cfg: ResolvedConfig, name: string, slug?: string): Promise<CreatedProject>;
24
- export interface MintedTunnel {
25
- tunnelId: string;
26
- /** The public host the phone opens: <slug>-<token>.<previewApex>. */
27
- hostname: string;
28
- /** cloudflared run token — `cloudflared tunnel run --token <this>`. */
29
- runToken: string;
30
- /** DNS record id, passed back on reap. */
31
- dnsRecordId: string;
32
- }
33
- /** Mint a per-session dev tunnel for `localPort` (the backend holds CF creds). */
34
- export declare function mintTunnel(cfg: ResolvedConfig, projectId: string, localPort: number): Promise<MintedTunnel>;
35
- /** Tear down a session tunnel (DNS route + tunnel). Best-effort; never throws. */
36
- export declare function reapTunnel(cfg: ResolvedConfig, projectId: string, ref: {
37
- tunnelId: string;
38
- dnsRecordId?: string;
39
- }): Promise<void>;
40
- /** Recursively list files under `dir`, returning dir-relative POSIX paths. */
41
- export declare function walkDir(dir: string, base?: string): Promise<string[]>;
42
- /**
43
- * Upload a locally-built dist and make it live. Each file rides as a multipart
44
- * part whose filename is its dist-relative path (the backend reconstructs the
45
- * tree). Requires an index.html at the dist root.
46
- */
47
- export declare function publishUpload(cfg: ResolvedConfig, projectId: string, distDir: string, opts?: {
48
- note?: string;
49
- commitSha?: string;
50
- }): Promise<PublishResult>;
51
- /** The working tree's current commit, for publish provenance. Undefined if not a repo. */
52
- export declare function gitHeadSha(cwd: string): Promise<string | undefined>;
@@ -1,146 +0,0 @@
1
- // The lifecycle verbs' backend calls, isolated from the MCP SDK wiring (server.ts)
2
- // so they're unit-testable and the transport can change without touching them.
3
- // Every call is PAT-authed (Bearer). The publish path uploads an already-built
4
- // dist as-is — the backend never rebuilds (Principle 0).
5
- import { promises as fs } from "node:fs";
6
- import path from "node:path";
7
- import { execFile } from "node:child_process";
8
- import { promisify } from "node:util";
9
- const execFileAsync = promisify(execFile);
10
- export class BackendError extends Error {
11
- status;
12
- constructor(message, status) {
13
- super(message);
14
- this.status = status;
15
- this.name = "BackendError";
16
- }
17
- }
18
- async function authFetch(cfg, pathname, init = {}) {
19
- return fetch(`${cfg.apiUrl}${pathname}`, {
20
- ...init,
21
- headers: { ...(init.headers ?? {}), authorization: `Bearer ${cfg.pat}` },
22
- });
23
- }
24
- async function safeText(res) {
25
- try {
26
- return (await res.text()).slice(0, 300);
27
- }
28
- catch {
29
- return "";
30
- }
31
- }
32
- /**
33
- * Create a backend project (reuses POST /projects). A `slug` pins the permanent
34
- * <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
35
- * taken slug comes back 409, surfaced so the caller can re-ask.
36
- */
37
- export async function createProject(cfg, name, slug) {
38
- const res = await authFetch(cfg, "/projects", {
39
- method: "POST",
40
- headers: { "content-type": "application/json" },
41
- body: JSON.stringify({ name, ...(slug ? { slug } : {}) }),
42
- });
43
- if (res.status === 409) {
44
- throw new BackendError(`slug "${slug}" is already in use — pick another.`, 409);
45
- }
46
- if (!res.ok) {
47
- throw new BackendError(`create project failed (${res.status}): ${await safeText(res)}`, res.status);
48
- }
49
- const p = (await res.json());
50
- const projectId = p._id ?? p.id;
51
- if (!projectId || !p.slug) {
52
- throw new BackendError("create project: response missing id/slug");
53
- }
54
- return { projectId, slug: p.slug, name: p.name ?? name };
55
- }
56
- /** Mint a per-session dev tunnel for `localPort` (the backend holds CF creds). */
57
- export async function mintTunnel(cfg, projectId, localPort) {
58
- const res = await authFetch(cfg, `/projects/${projectId}/tunnel`, {
59
- method: "POST",
60
- headers: { "content-type": "application/json" },
61
- body: JSON.stringify({ port: localPort }),
62
- });
63
- if (!res.ok) {
64
- throw new BackendError(`tunnel mint failed (${res.status}): ${await safeText(res)}`, res.status);
65
- }
66
- return (await res.json());
67
- }
68
- /** Tear down a session tunnel (DNS route + tunnel). Best-effort; never throws. */
69
- export async function reapTunnel(cfg, projectId, ref) {
70
- try {
71
- await authFetch(cfg, `/projects/${projectId}/tunnel/reap`, {
72
- method: "POST",
73
- headers: { "content-type": "application/json" },
74
- body: JSON.stringify(ref),
75
- });
76
- }
77
- catch {
78
- // Reap is a cleanup courtesy — a failure must never crash the preview teardown.
79
- }
80
- }
81
- /** Recursively list files under `dir`, returning dir-relative POSIX paths. */
82
- export async function walkDir(dir, base = dir) {
83
- const out = [];
84
- let entries;
85
- try {
86
- entries = await fs.readdir(dir, { withFileTypes: true });
87
- }
88
- catch (err) {
89
- if (err.code === "ENOENT") {
90
- throw new BackendError(`dist directory not found: ${dir}`);
91
- }
92
- throw err;
93
- }
94
- for (const entry of entries) {
95
- const full = path.join(dir, entry.name);
96
- if (entry.isDirectory())
97
- out.push(...(await walkDir(full, base)));
98
- else if (entry.isFile())
99
- out.push(path.relative(base, full).split(path.sep).join("/"));
100
- }
101
- return out;
102
- }
103
- /**
104
- * Upload a locally-built dist and make it live. Each file rides as a multipart
105
- * part whose filename is its dist-relative path (the backend reconstructs the
106
- * tree). Requires an index.html at the dist root.
107
- */
108
- export async function publishUpload(cfg, projectId, distDir, opts = {}) {
109
- const files = await walkDir(distDir);
110
- if (!files.includes("index.html")) {
111
- throw new BackendError(`dist at ${distDir} has no index.html at its root — did the build run?`);
112
- }
113
- const form = new FormData();
114
- // The real dist-relative paths ride as an ordered manifest — a multipart filename
115
- // can't carry a directory (FormData strips it to a basename), so the backend maps
116
- // paths[i] → files[i] to rebuild the tree.
117
- form.append("paths", JSON.stringify(files));
118
- for (const rel of files) {
119
- const bytes = await fs.readFile(path.join(distDir, rel));
120
- // Uint8Array (Buffer) is a valid BlobPart; the filename is cosmetic (the backend
121
- // uses the manifest), but pass the basename so server logs read sensibly.
122
- form.append("files", new Blob([bytes]), path.basename(rel));
123
- }
124
- if (opts.note)
125
- form.append("note", opts.note);
126
- if (opts.commitSha)
127
- form.append("commitSha", opts.commitSha);
128
- const res = await authFetch(cfg, `/projects/${projectId}/publish/upload`, {
129
- method: "POST",
130
- body: form,
131
- });
132
- if (!res.ok) {
133
- throw new BackendError(`publish failed (${res.status}): ${await safeText(res)}`, res.status);
134
- }
135
- return (await res.json());
136
- }
137
- /** The working tree's current commit, for publish provenance. Undefined if not a repo. */
138
- export async function gitHeadSha(cwd) {
139
- try {
140
- const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "HEAD"]);
141
- return stdout.trim();
142
- }
143
- catch {
144
- return undefined;
145
- }
146
- }
package/dist/mcp/cli.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/mcp/cli.js DELETED
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env node
2
- // `harness-mcp` — the agent-side MCP server. An MCP agent spawns this over
3
- // stdio. Point it at a non-default relay with --relay-url.
4
- import { runStdio } from "./server.js";
5
- const urlArg = process.argv.indexOf("--relay-url");
6
- const relayUrl = urlArg !== -1 ? process.argv[urlArg + 1] : undefined;
7
- runStdio({ relayUrl }).catch((err) => {
8
- console.error("[harness-mcp] fatal:", err);
9
- process.exit(1);
10
- });
@@ -1,13 +0,0 @@
1
- import type { DiagEvent, LogEvent, NetworkEvent, RelayQuery, RelayResult, TraceEvent } from "../shared/events.js";
2
- export interface RelayClient {
3
- query(q: RelayQuery): Promise<RelayResult>;
4
- }
5
- /** A RelayClient backed by the relay's HTTP /query endpoint. */
6
- export declare function httpRelayClient(baseUrl: string): RelayClient;
7
- export declare function formatLog(e: LogEvent, now: number): string;
8
- export declare function formatNetwork(e: NetworkEvent, now: number): string;
9
- export declare function formatTrace(e: TraceEvent, now: number): string;
10
- /** Render a RelayResult for one kind into the text block a tool returns. */
11
- export declare function renderResult(result: RelayResult, kind: DiagEvent["kind"], now: number): string;
12
- /** Only-errors convenience filter applied on top of a log query result. */
13
- export declare function filterErrors(result: RelayResult): RelayResult;
@@ -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
- }
@@ -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>;
@@ -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>;