@vincentt-xr/harness 0.2.0 → 0.4.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/cli/index.d.ts +2 -0
- package/dist/cli/index.js +55 -0
- package/dist/login/login.d.ts +34 -0
- package/dist/login/login.js +148 -0
- package/dist/mcp/server.js +62 -17
- package/dist/preview/cloudflared.js +10 -1
- package/dist/preview/net.d.ts +6 -0
- package/dist/preview/net.js +56 -0
- package/dist/preview/runner.d.ts +13 -4
- package/dist/preview/runner.js +30 -7
- package/dist/scaffold/index.d.ts +8 -1
- package/dist/scaffold/index.js +14 -1
- package/dist/shared/config.d.ts +6 -0
- package/dist/shared/config.js +14 -0
- package/package.json +2 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `harness` — top-level CLI. Today it dispatches a single subcommand, `login`,
|
|
3
|
+
// which obtains a Personal Access Token through the editor's /oauth flow and
|
|
4
|
+
// writes it to ~/.vincentt/config.json (the MCP verbs then read it).
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { runLogin } from "../login/login.js";
|
|
7
|
+
const DEFAULT_EDITOR_URL = "https://editor.vincentt.studio";
|
|
8
|
+
const DEFAULT_API_URL = "https://api.vincentt.studio";
|
|
9
|
+
function flag(name) {
|
|
10
|
+
const i = process.argv.indexOf(name);
|
|
11
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
12
|
+
}
|
|
13
|
+
function usage() {
|
|
14
|
+
console.log(`Usage: harness login [options]\n\n` +
|
|
15
|
+
` Sign in and save a Vincentt access token to ~/.vincentt/config.json.\n\n` +
|
|
16
|
+
`Options:\n` +
|
|
17
|
+
` --editor <url> Editor base URL (default ${DEFAULT_EDITOR_URL}, or $VINCENTT_EDITOR_URL)\n` +
|
|
18
|
+
` --api <url> Backend API base URL (default ${DEFAULT_API_URL}, or $VINCENTT_API_URL)\n` +
|
|
19
|
+
` --name <label> Token label shown in the editor (default "Harness CLI (<host>)")\n`);
|
|
20
|
+
}
|
|
21
|
+
async function login() {
|
|
22
|
+
const editorUrl = flag("--editor") ?? process.env.VINCENTT_EDITOR_URL ?? DEFAULT_EDITOR_URL;
|
|
23
|
+
const apiUrl = flag("--api") ?? process.env.VINCENTT_API_URL ?? DEFAULT_API_URL;
|
|
24
|
+
const tokenName = flag("--name") ?? `Harness CLI (${os.hostname()})`;
|
|
25
|
+
console.log("Opening your browser to authorize this device…");
|
|
26
|
+
const { apiUrl: saved, configPath } = await runLogin({
|
|
27
|
+
editorUrl,
|
|
28
|
+
apiUrl,
|
|
29
|
+
tokenName,
|
|
30
|
+
onUrl: (url) => console.log(`\nIf your browser didn't open, visit:\n ${url}\n`),
|
|
31
|
+
});
|
|
32
|
+
console.log(`\n✓ Signed in. Access token saved to ${configPath}`);
|
|
33
|
+
console.log(` API: ${saved}`);
|
|
34
|
+
}
|
|
35
|
+
async function main() {
|
|
36
|
+
const cmd = process.argv[2];
|
|
37
|
+
switch (cmd) {
|
|
38
|
+
case "login":
|
|
39
|
+
await login();
|
|
40
|
+
break;
|
|
41
|
+
case undefined:
|
|
42
|
+
case "-h":
|
|
43
|
+
case "--help":
|
|
44
|
+
usage();
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
48
|
+
usage();
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
main().catch((err) => {
|
|
53
|
+
console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface LoginOptions {
|
|
2
|
+
/** Editor base URL that serves the /oauth consent page. */
|
|
3
|
+
editorUrl: string;
|
|
4
|
+
/** Backend API base URL the minted PAT authenticates against (paired with editorUrl). */
|
|
5
|
+
apiUrl: string;
|
|
6
|
+
/** Human label stored with the token (shown in the editor's token list). */
|
|
7
|
+
tokenName: string;
|
|
8
|
+
/** Abort if the browser round-trip doesn't complete in time. Default 5 min. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Set false to skip launching a browser (tests / headless). Default true. */
|
|
11
|
+
openInBrowser?: boolean;
|
|
12
|
+
/** Called with the authorize URL once the listener is up (for a printable fallback). */
|
|
13
|
+
onUrl?: (url: string) => void;
|
|
14
|
+
}
|
|
15
|
+
/** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
|
|
16
|
+
export declare function normalizeBaseUrl(u: string): string;
|
|
17
|
+
export declare function buildAuthorizeUrl(editorBase: string, redirectUri: string, state: string, tokenName: string): string;
|
|
18
|
+
export type CallbackResult = {
|
|
19
|
+
ok: true;
|
|
20
|
+
token: string;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
/** Interpret the editor's loopback redirect. State is verified first, always. */
|
|
26
|
+
export declare function parseCallback(params: URLSearchParams, expectedState: string): CallbackResult;
|
|
27
|
+
/**
|
|
28
|
+
* Run the loopback OAuth round-trip and persist the resulting PAT. Resolves with
|
|
29
|
+
* the apiUrl written to config; rejects on cancel, state mismatch, or timeout.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runLogin(opts: LoginOptions): Promise<{
|
|
32
|
+
apiUrl: string;
|
|
33
|
+
configPath: string;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// `harness login` — obtain a Personal Access Token via the editor's /oauth flow.
|
|
2
|
+
//
|
|
3
|
+
// The CLI can't mint a PAT directly (POST /access-tokens is Cognito-only, so a
|
|
4
|
+
// machine token can never mint another). Instead it delegates to the editor:
|
|
5
|
+
// 1. Start a loopback HTTP listener on 127.0.0.1:<ephemeral port>.
|
|
6
|
+
// 2. Open the browser to <editor>/oauth?redirect_uri=…&state=…&name=… — the
|
|
7
|
+
// editor reuses its own passwordless login + a consent screen and mints the
|
|
8
|
+
// PAT on the user's behalf.
|
|
9
|
+
// 3. The editor redirects ?token=<vpat_…>&state=… back to the loopback.
|
|
10
|
+
// 4. Verify state, write the PAT to ~/.vincentt/config.json.
|
|
11
|
+
// The PAT crosses only over loopback on the user's own machine.
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { getFreePort } from "../preview/net.js";
|
|
16
|
+
import { writeMachineConfig } from "../shared/config.js";
|
|
17
|
+
/** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
|
|
18
|
+
export function normalizeBaseUrl(u) {
|
|
19
|
+
if (/^https?:\/\//i.test(u))
|
|
20
|
+
return u;
|
|
21
|
+
const local = /^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(u);
|
|
22
|
+
return `${local ? "http" : "https"}://${u}`;
|
|
23
|
+
}
|
|
24
|
+
export function buildAuthorizeUrl(editorBase, redirectUri, state, tokenName) {
|
|
25
|
+
const u = new URL("/oauth", normalizeBaseUrl(editorBase));
|
|
26
|
+
u.searchParams.set("redirect_uri", redirectUri);
|
|
27
|
+
u.searchParams.set("state", state);
|
|
28
|
+
u.searchParams.set("name", tokenName);
|
|
29
|
+
return u.toString();
|
|
30
|
+
}
|
|
31
|
+
/** Interpret the editor's loopback redirect. State is verified first, always. */
|
|
32
|
+
export function parseCallback(params, expectedState) {
|
|
33
|
+
if (params.get("state") !== expectedState) {
|
|
34
|
+
return { ok: false, message: "State mismatch — ignoring an unexpected callback." };
|
|
35
|
+
}
|
|
36
|
+
const error = params.get("error");
|
|
37
|
+
if (error) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
message: error === "access_denied" ? "Authorization was cancelled." : `Authorization failed: ${error}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const token = params.get("token");
|
|
44
|
+
if (!token)
|
|
45
|
+
return { ok: false, message: "No token in the authorization response." };
|
|
46
|
+
return { ok: true, token };
|
|
47
|
+
}
|
|
48
|
+
function escapeHtml(s) {
|
|
49
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
50
|
+
}
|
|
51
|
+
// Standalone success/failure page shown in the browser tab after the loopback
|
|
52
|
+
// round-trip. Fully self-contained (inline CSS, no assets) since it's served off
|
|
53
|
+
// the CLI's own listener; theme-aware to match the editor's sand/ink palette.
|
|
54
|
+
function browserPage(ok, heading, body) {
|
|
55
|
+
const accent = ok ? "#16a34a" : "#dc2626";
|
|
56
|
+
const glyph = ok ? "✓" : "×";
|
|
57
|
+
return `<!doctype html>
|
|
58
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Vincentt XR</title>
|
|
60
|
+
<style>
|
|
61
|
+
:root { color-scheme: light dark; }
|
|
62
|
+
* { box-sizing: border-box; }
|
|
63
|
+
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
|
64
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
65
|
+
background:#f4efe6; color:#1c1917; }
|
|
66
|
+
.card { width:min(92vw,380px); background:#fff; border:1px solid #e7e0d3; border-radius:16px;
|
|
67
|
+
padding:40px 36px; text-align:center; box-shadow:0 10px 40px rgba(0,0,0,.08); }
|
|
68
|
+
.brand { display:flex; align-items:center; justify-content:center; gap:8px; margin-bottom:26px;
|
|
69
|
+
font-weight:700; font-size:15px; letter-spacing:-.01em; }
|
|
70
|
+
.dot { width:26px; height:26px; border-radius:8px; background:linear-gradient(135deg,#7c3aed,#2563eb); }
|
|
71
|
+
.glyph { width:48px; height:48px; margin:0 auto 16px; border-radius:50%; color:#fff;
|
|
72
|
+
display:flex; align-items:center; justify-content:center; font-size:26px; line-height:1; background:${accent}; }
|
|
73
|
+
h1 { font-size:19px; margin:0 0 6px; }
|
|
74
|
+
p { font-size:13px; opacity:.7; margin:0; line-height:1.5; }
|
|
75
|
+
@media (prefers-color-scheme: dark) {
|
|
76
|
+
body { background:#1c1917; color:#f4efe6; }
|
|
77
|
+
.card { background:#292524; border-color:#3f3a36; }
|
|
78
|
+
}
|
|
79
|
+
</style></head>
|
|
80
|
+
<body><div class="card">
|
|
81
|
+
<div class="brand"><span class="dot"></span>Vincentt XR</div>
|
|
82
|
+
<div class="glyph">${glyph}</div>
|
|
83
|
+
<h1>${escapeHtml(heading)}</h1>
|
|
84
|
+
<p>${escapeHtml(body)}</p>
|
|
85
|
+
</div></body></html>`;
|
|
86
|
+
}
|
|
87
|
+
function openBrowser(url) {
|
|
88
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
89
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
90
|
+
try {
|
|
91
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
92
|
+
child.on("error", () => { });
|
|
93
|
+
child.unref();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Falls back to the printed URL (onUrl) — never fatal.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Run the loopback OAuth round-trip and persist the resulting PAT. Resolves with
|
|
101
|
+
* the apiUrl written to config; rejects on cancel, state mismatch, or timeout.
|
|
102
|
+
*/
|
|
103
|
+
export async function runLogin(opts) {
|
|
104
|
+
const state = randomBytes(16).toString("hex");
|
|
105
|
+
const port = await getFreePort();
|
|
106
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
107
|
+
const authorizeUrl = buildAuthorizeUrl(opts.editorUrl, redirectUri, state, opts.tokenName);
|
|
108
|
+
const token = await new Promise((resolve, reject) => {
|
|
109
|
+
const server = createServer((req, res) => {
|
|
110
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
111
|
+
if (url.pathname !== "/callback") {
|
|
112
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
113
|
+
res.end("Not found");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const result = parseCallback(url.searchParams, state);
|
|
117
|
+
res.writeHead(result.ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
|
|
118
|
+
res.end(result.ok
|
|
119
|
+
? browserPage(true, "You're signed in", "You can close this tab and return to your terminal.")
|
|
120
|
+
: browserPage(false, "Sign-in failed", result.message));
|
|
121
|
+
// A state-mismatch response is answered but must NOT settle the flow — a
|
|
122
|
+
// stray/forged request shouldn't tear down the listener the real callback
|
|
123
|
+
// still needs.
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
server.close();
|
|
126
|
+
resolve(result.token);
|
|
127
|
+
}
|
|
128
|
+
else if (url.searchParams.get("state") === state) {
|
|
129
|
+
server.close();
|
|
130
|
+
reject(new Error(result.message));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
server.on("error", reject);
|
|
134
|
+
server.listen(port, "127.0.0.1", () => {
|
|
135
|
+
opts.onUrl?.(authorizeUrl);
|
|
136
|
+
if (opts.openInBrowser !== false)
|
|
137
|
+
openBrowser(authorizeUrl);
|
|
138
|
+
});
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
server.close();
|
|
141
|
+
reject(new Error("Timed out waiting for browser authorization."));
|
|
142
|
+
}, opts.timeoutMs ?? 5 * 60_000);
|
|
143
|
+
timer.unref();
|
|
144
|
+
});
|
|
145
|
+
const apiUrl = opts.apiUrl.replace(/\/$/, "");
|
|
146
|
+
const configPath = await writeMachineConfig({ apiUrl, pat: token });
|
|
147
|
+
return { apiUrl, configPath };
|
|
148
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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.
|
|
@@ -23,6 +23,17 @@ export async function shutdownActivePreview() {
|
|
|
23
23
|
activePreview = null;
|
|
24
24
|
await preview.stop();
|
|
25
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
|
+
};
|
|
26
37
|
/** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
|
|
27
38
|
async function guard(run) {
|
|
28
39
|
try {
|
|
@@ -48,7 +59,10 @@ const sharedInput = {
|
|
|
48
59
|
.describe("Cap the number of events returned (newest kept)."),
|
|
49
60
|
};
|
|
50
61
|
export function createHarnessMcp(opts = {}) {
|
|
51
|
-
const
|
|
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;
|
|
52
66
|
const now = opts.now ?? Date.now;
|
|
53
67
|
const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
|
|
54
68
|
server.registerTool("diag_logs", {
|
|
@@ -59,7 +73,7 @@ export function createHarnessMcp(opts = {}) {
|
|
|
59
73
|
},
|
|
60
74
|
}, async ({ sessionId, since, limit, errorsOnly }) => {
|
|
61
75
|
const q = { kind: "log", sessionId, since, limit };
|
|
62
|
-
let result = await
|
|
76
|
+
let result = await relayClient.query(q);
|
|
63
77
|
if (errorsOnly)
|
|
64
78
|
result = filterErrors(result);
|
|
65
79
|
return { content: [{ type: "text", text: renderResult(result, "log", now()) }] };
|
|
@@ -68,7 +82,12 @@ export function createHarnessMcp(opts = {}) {
|
|
|
68
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.",
|
|
69
83
|
inputSchema: sharedInput,
|
|
70
84
|
}, async ({ sessionId, since, limit }) => {
|
|
71
|
-
const result = await
|
|
85
|
+
const result = await relayClient.query({
|
|
86
|
+
kind: "network",
|
|
87
|
+
sessionId,
|
|
88
|
+
since,
|
|
89
|
+
limit,
|
|
90
|
+
});
|
|
72
91
|
return {
|
|
73
92
|
content: [{ type: "text", text: renderResult(result, "network", now()) }],
|
|
74
93
|
};
|
|
@@ -77,12 +96,15 @@ export function createHarnessMcp(opts = {}) {
|
|
|
77
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.",
|
|
78
97
|
inputSchema: sharedInput,
|
|
79
98
|
}, async ({ sessionId, since, limit }) => {
|
|
80
|
-
const result = await
|
|
99
|
+
const result = await relayClient.query({ kind: "trace", sessionId, since, limit });
|
|
81
100
|
return { content: [{ type: "text", text: renderResult(result, "trace", now()) }] };
|
|
82
101
|
});
|
|
83
|
-
// The
|
|
84
|
-
//
|
|
85
|
-
|
|
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();
|
|
86
108
|
server.registerTool("project_create", {
|
|
87
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.",
|
|
88
110
|
inputSchema: {
|
|
@@ -98,8 +120,10 @@ export function createHarnessMcp(opts = {}) {
|
|
|
98
120
|
.boolean()
|
|
99
121
|
.optional()
|
|
100
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,
|
|
101
124
|
},
|
|
102
|
-
}, async ({ name, slug, scaffold }) => guard(async () => {
|
|
125
|
+
}, async ({ name, slug, scaffold, projectDir }) => guard(async () => {
|
|
126
|
+
const projectCwd = resolveProjectDir(baseProjectDir, projectDir);
|
|
103
127
|
const existing = await loadProjectBinding(projectCwd);
|
|
104
128
|
if (existing) {
|
|
105
129
|
return `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}). Delete .vincentt/project.json to rebind.`;
|
|
@@ -116,9 +140,21 @@ export function createHarnessMcp(opts = {}) {
|
|
|
116
140
|
projectId: created.projectId,
|
|
117
141
|
slug: created.slug,
|
|
118
142
|
});
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 +
|
|
122
158
|
`Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
|
|
123
159
|
`Binding written to ${bindingPath} (gitignored).\n` +
|
|
124
160
|
`Build locally, then run project_publish to go live at ${created.slug}.vincentt.app.`);
|
|
@@ -134,8 +170,10 @@ export function createHarnessMcp(opts = {}) {
|
|
|
134
170
|
.string()
|
|
135
171
|
.optional()
|
|
136
172
|
.describe("Optional release note recorded with the version."),
|
|
173
|
+
...projectDirInput,
|
|
137
174
|
},
|
|
138
|
-
}, async ({ distDir, note }) => guard(async () => {
|
|
175
|
+
}, async ({ distDir, note, projectDir }) => guard(async () => {
|
|
176
|
+
const projectCwd = resolveProjectDir(baseProjectDir, projectDir);
|
|
139
177
|
const binding = await loadProjectBinding(projectCwd);
|
|
140
178
|
if (!binding) {
|
|
141
179
|
return "No project is bound to this directory. Run project_create first.";
|
|
@@ -151,20 +189,25 @@ export function createHarnessMcp(opts = {}) {
|
|
|
151
189
|
}));
|
|
152
190
|
server.registerTool("preview_start", {
|
|
153
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.",
|
|
154
|
-
inputSchema: {},
|
|
155
|
-
}, async () => guard(async () => {
|
|
192
|
+
inputSchema: { ...projectDirInput },
|
|
193
|
+
}, async ({ projectDir }) => guard(async () => {
|
|
156
194
|
if (activePreview) {
|
|
157
195
|
return `A preview is already running:\n${activePreview.url}\nCall preview_stop first to restart.`;
|
|
158
196
|
}
|
|
159
197
|
// App stdout would corrupt the MCP channel; keep it off stdout entirely and
|
|
160
198
|
// route harness progress to stderr.
|
|
161
199
|
activePreview = await startPreview({
|
|
162
|
-
projectCwd,
|
|
200
|
+
projectCwd: resolveProjectDir(baseProjectDir, projectDir),
|
|
163
201
|
onLog: (m) => console.error(`[preview] ${m}`),
|
|
164
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.`;
|
|
165
208
|
return (`Live preview running — open on your device:\n${activePreview.url}\n\n` +
|
|
166
209
|
`Diagnostics are live: diag_logs / diag_network / diag_trace now read from this device.\n` +
|
|
167
|
-
`Call preview_stop when finished
|
|
210
|
+
`Call preview_stop when finished.${readiness}`);
|
|
168
211
|
}));
|
|
169
212
|
server.registerTool("preview_stop", {
|
|
170
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.",
|
|
@@ -173,6 +216,8 @@ export function createHarnessMcp(opts = {}) {
|
|
|
173
216
|
if (!activePreview)
|
|
174
217
|
return "No preview is running.";
|
|
175
218
|
await shutdownActivePreview();
|
|
219
|
+
// The preview's relay is gone; send diag reads back to the default.
|
|
220
|
+
relayClient = defaultRelay;
|
|
176
221
|
return "Preview stopped. Tunnel and dev server torn down.";
|
|
177
222
|
}));
|
|
178
223
|
return server;
|
|
@@ -31,7 +31,16 @@ export async function ensureCloudflared(opts = {}) {
|
|
|
31
31
|
const log = opts.onLog ?? (() => undefined);
|
|
32
32
|
log("cloudflared not found — downloading a one-time copy…");
|
|
33
33
|
await mkdir(path.dirname(cloudflared.bin), { recursive: true });
|
|
34
|
-
|
|
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
|
+
}
|
|
35
44
|
log(`cloudflared ready at ${cloudflared.bin}`);
|
|
36
45
|
return cloudflared.bin;
|
|
37
46
|
}
|
|
@@ -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
|
+
}
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
8
|
+
/** Relay port. Default: an OS-assigned free port (returned as relayPort). */
|
|
8
9
|
relayPort?: number;
|
|
9
|
-
/**
|
|
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,22 @@ 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 8s).
|
|
22
|
+
* Kept short so an MCP tool call stays within strict clients' ~30s timeout;
|
|
23
|
+
* a not-yet-ready app still returns (appReady=false), it isn't an error. */
|
|
24
|
+
appReadyTimeoutMs?: number;
|
|
20
25
|
/** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
|
|
21
26
|
onLog?: (message: string) => void;
|
|
22
27
|
}
|
|
23
28
|
export interface RunningPreview {
|
|
24
29
|
/** Public https URL to open on the device. */
|
|
25
30
|
url: string;
|
|
26
|
-
/** Relay port the diag_* tools query. */
|
|
31
|
+
/** Relay port the diag_* tools query (may be OS-assigned). */
|
|
27
32
|
relayPort: number;
|
|
33
|
+
/** The app dev-serve port the tunnel ultimately serves. */
|
|
34
|
+
appPort: number;
|
|
35
|
+
/** Whether the app was responding when we returned (false = URL may be blank). */
|
|
36
|
+
appReady: boolean;
|
|
28
37
|
/** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
|
|
29
38
|
stop: () => Promise<void>;
|
|
30
39
|
}
|
package/dist/preview/runner.js
CHANGED
|
@@ -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,
|
|
19
|
+
const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
|
|
20
|
+
// Resolve ports. The app port defaults to 5173; the relay + front ports are
|
|
21
|
+
// harness-internal, so auto-pick free ones (the relay port is returned for the
|
|
22
|
+
// MCP diag_* tools to point at) instead of colliding on fixed defaults.
|
|
23
|
+
const appPort = opts.appPort ?? 5173;
|
|
24
|
+
const relayPort = opts.relayPort ?? (await getFreePort());
|
|
25
|
+
const frontPort = opts.frontPort ?? (await getFreePort());
|
|
26
|
+
// If a dev server is already up on appPort, reuse it — don't spawn a second one
|
|
27
|
+
// that would fail to bind and leave the tunnel serving a broken origin.
|
|
28
|
+
const reuseApp = await isPortListening(appPort);
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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();
|
package/dist/scaffold/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const TEMPLATE_REPO = "
|
|
1
|
+
export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
|
|
2
2
|
/** Injectable git runner (real git by default; a fake in tests avoids the network). */
|
|
3
3
|
export type GitRunner = (args: string[]) => Promise<void>;
|
|
4
4
|
/**
|
|
@@ -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>;
|
package/dist/scaffold/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import os from "node:os";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
const execFileAsync = promisify(execFile);
|
|
12
|
-
export const TEMPLATE_REPO = "
|
|
12
|
+
export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
|
|
13
13
|
const realGit = async (args) => {
|
|
14
14
|
await execFileAsync("git", args);
|
|
15
15
|
};
|
|
@@ -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/dist/shared/config.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface ProjectBinding {
|
|
|
15
15
|
export declare const MACHINE_CONFIG_PATH: string;
|
|
16
16
|
export declare function projectBindingPath(cwd: string): string;
|
|
17
17
|
export declare function loadMachineConfig(): Promise<Partial<MachineConfig>>;
|
|
18
|
+
/**
|
|
19
|
+
* Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
|
|
20
|
+
* a freshly minted PAT). Preserves any other fields already present. The file
|
|
21
|
+
* holds a bearer credential, so the dir/file get owner-only perms.
|
|
22
|
+
*/
|
|
23
|
+
export declare function writeMachineConfig(patch: Partial<MachineConfig>): Promise<string>;
|
|
18
24
|
export declare function loadProjectBinding(cwd: string): Promise<ProjectBinding | undefined>;
|
|
19
25
|
/**
|
|
20
26
|
* Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
|
package/dist/shared/config.js
CHANGED
|
@@ -29,6 +29,20 @@ async function readJsonIfExists(p) {
|
|
|
29
29
|
export async function loadMachineConfig() {
|
|
30
30
|
return (await readJsonIfExists(MACHINE_CONFIG_PATH)) ?? {};
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
|
|
34
|
+
* a freshly minted PAT). Preserves any other fields already present. The file
|
|
35
|
+
* holds a bearer credential, so the dir/file get owner-only perms.
|
|
36
|
+
*/
|
|
37
|
+
export async function writeMachineConfig(patch) {
|
|
38
|
+
const next = { ...(await loadMachineConfig()), ...patch };
|
|
39
|
+
await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
40
|
+
await fs.writeFile(MACHINE_CONFIG_PATH, JSON.stringify(next, null, 2) + "\n", {
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
mode: 0o600,
|
|
43
|
+
});
|
|
44
|
+
return MACHINE_CONFIG_PATH;
|
|
45
|
+
}
|
|
32
46
|
export async function loadProjectBinding(cwd) {
|
|
33
47
|
return readJsonIfExists(projectBindingPath(cwd));
|
|
34
48
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"bin": {
|
|
29
|
+
"harness": "./dist/cli/index.js",
|
|
29
30
|
"harness-relay": "./dist/relay/cli.js",
|
|
30
31
|
"harness-mcp": "./dist/mcp/cli.js"
|
|
31
32
|
},
|