@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.
- 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 -34
- package/README.md +0 -87
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -55
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- 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 -239
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- 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 -45
- 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 -39
- package/dist/shared/config.js +0 -90
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,45 +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 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;
|
|
25
|
-
/** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
|
|
26
|
-
onLog?: (message: string) => void;
|
|
27
|
-
}
|
|
28
|
-
export interface RunningPreview {
|
|
29
|
-
/** Public https URL to open on the device. */
|
|
30
|
-
url: string;
|
|
31
|
-
/** Relay port the diag_* tools query (may be OS-assigned). */
|
|
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;
|
|
37
|
-
/** Tear down tunnel (+ DNS route), app dev serve, front proxy, and relay. */
|
|
38
|
-
stop: () => Promise<void>;
|
|
39
|
-
}
|
|
40
|
-
export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
|
|
41
|
-
/**
|
|
42
|
-
* Resolve once cloudflared reports a live edge connection, reject if it exits
|
|
43
|
-
* first or never registers. Exported for unit testing the log-scan/timeout logic.
|
|
44
|
-
*/
|
|
45
|
-
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 = 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);
|
|
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>;
|
package/dist/preview/tunnel.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// Per-session dev tunnel: resolve the project binding + machine config and mint a
|
|
2
|
-
// named tunnel via the backend (which holds the Cloudflare account creds). Stays
|
|
3
|
-
// free of any child-process / cloudflared coupling — the caller runs cloudflared
|
|
4
|
-
// with the returned run token (see runner.ts) and calls reap() on exit.
|
|
5
|
-
import { loadProjectBinding, resolveConfig } from "../shared/config.js";
|
|
6
|
-
import { mintTunnel, reapTunnel } from "../mcp/backend.js";
|
|
7
|
-
/**
|
|
8
|
-
* Mint a per-session dev tunnel for the project bound to `projectCwd`, routing
|
|
9
|
-
* <slug>-<token>.<apex> to the local `localPort`. Returns the URL to open and a
|
|
10
|
-
* `reap()` for teardown. Throws with an actionable message when the directory is
|
|
11
|
-
* unbound (run project_create) or no backend/PAT is configured.
|
|
12
|
-
*/
|
|
13
|
-
export async function startSessionTunnel(projectCwd, localPort) {
|
|
14
|
-
const binding = await loadProjectBinding(projectCwd);
|
|
15
|
-
if (!binding) {
|
|
16
|
-
throw new Error("No project bound to this directory — run project_create first.");
|
|
17
|
-
}
|
|
18
|
-
const cfg = await resolveConfig(projectCwd);
|
|
19
|
-
const minted = await mintTunnel(cfg, binding.projectId, localPort);
|
|
20
|
-
return {
|
|
21
|
-
...minted,
|
|
22
|
-
url: `https://${minted.hostname}`,
|
|
23
|
-
reap: () => reapTunnel(cfg, binding.projectId, {
|
|
24
|
-
tunnelId: minted.tunnelId,
|
|
25
|
-
dnsRecordId: minted.dnsRecordId,
|
|
26
|
-
}),
|
|
27
|
-
};
|
|
28
|
-
}
|
package/dist/relay/cli.d.ts
DELETED
package/dist/relay/cli.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// `harness-relay` — start the diagnostics relay beside Vite. The preview loop
|
|
3
|
-
// spawns this (or you run it directly). Prints a line the tunnel step can read.
|
|
4
|
-
import { startRelay } from "./server.js";
|
|
5
|
-
const portArg = process.argv.indexOf("--port");
|
|
6
|
-
const port = portArg !== -1 ? Number(process.argv[portArg + 1]) : 7331;
|
|
7
|
-
startRelay({ port, onLog: (m) => console.log(`[harness-relay] ${m}`) });
|
package/dist/relay/server.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { EventStore } from "./store.js";
|
|
2
|
-
export interface RelayOptions {
|
|
3
|
-
port?: number;
|
|
4
|
-
/** WS path the client connects to (default matches HarnessProvider). */
|
|
5
|
-
path?: string;
|
|
6
|
-
capacity?: number;
|
|
7
|
-
onLog?: (msg: string) => void;
|
|
8
|
-
}
|
|
9
|
-
export declare function startRelay(opts?: RelayOptions): {
|
|
10
|
-
store: EventStore;
|
|
11
|
-
close: () => Promise<void>;
|
|
12
|
-
};
|
package/dist/relay/server.js
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// The relay: a WebSocket + HTTP server that sits beside Vite. The phone's
|
|
2
|
-
// harness client pushes event batches over WS; the MCP server pulls via a
|
|
3
|
-
// localhost HTTP endpoint. Deliberately tiny — all the buffering/querying logic
|
|
4
|
-
// is in store.ts. One relay process per preview run; the cloudflared tunnel
|
|
5
|
-
// carries the WS path to the phone.
|
|
6
|
-
import { createServer } from "node:http";
|
|
7
|
-
import { WebSocketServer } from "ws";
|
|
8
|
-
import { EventStore } from "./store.js";
|
|
9
|
-
export function startRelay(opts = {}) {
|
|
10
|
-
const port = opts.port ?? 7331;
|
|
11
|
-
const path = opts.path ?? "/__harness";
|
|
12
|
-
const store = new EventStore(opts.capacity);
|
|
13
|
-
const log = opts.onLog ?? (() => undefined);
|
|
14
|
-
const http = createServer((req, res) => handleHttp(req, res, store, log));
|
|
15
|
-
const wss = new WebSocketServer({ server: http, path });
|
|
16
|
-
wss.on("connection", (ws) => {
|
|
17
|
-
log("client connected");
|
|
18
|
-
ws.on("message", (data) => {
|
|
19
|
-
try {
|
|
20
|
-
const msg = JSON.parse(String(data));
|
|
21
|
-
if (msg.type === "events" && Array.isArray(msg.events)) {
|
|
22
|
-
store.ingest(msg.sessionId, msg.events);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
// ignore malformed frames — a bad client must not crash the relay
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
ws.on("close", () => log("client disconnected"));
|
|
30
|
-
});
|
|
31
|
-
http.listen(port, () => log(`relay listening on :${port} (ws ${path}, http /query)`));
|
|
32
|
-
return {
|
|
33
|
-
store,
|
|
34
|
-
close: () => new Promise((resolve) => {
|
|
35
|
-
wss.close();
|
|
36
|
-
http.close(() => resolve());
|
|
37
|
-
}),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
// The MCP server (running on the same laptop) GETs /query with the RelayQuery
|
|
41
|
-
// as JSON in the body or querystring. Localhost-only by deployment; no auth in
|
|
42
|
-
// the local dev loop (the platform phase adds it).
|
|
43
|
-
function handleHttp(req, res, store, log) {
|
|
44
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
45
|
-
if (url.pathname !== "/query") {
|
|
46
|
-
res.writeHead(404).end();
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
readQuery(req, url)
|
|
50
|
-
.then((q) => {
|
|
51
|
-
const result = store.query(q);
|
|
52
|
-
res
|
|
53
|
-
.writeHead(200, { "content-type": "application/json" })
|
|
54
|
-
.end(JSON.stringify(result));
|
|
55
|
-
})
|
|
56
|
-
.catch((err) => {
|
|
57
|
-
log(`query error: ${err}`);
|
|
58
|
-
res.writeHead(400).end();
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
async function readQuery(req, url) {
|
|
62
|
-
if (req.method === "POST") {
|
|
63
|
-
const body = await readBody(req);
|
|
64
|
-
return body ? JSON.parse(body) : {};
|
|
65
|
-
}
|
|
66
|
-
const q = {};
|
|
67
|
-
const p = url.searchParams;
|
|
68
|
-
if (p.get("sessionId"))
|
|
69
|
-
q.sessionId = p.get("sessionId");
|
|
70
|
-
if (p.get("kind"))
|
|
71
|
-
q.kind = p.get("kind");
|
|
72
|
-
if (p.get("since"))
|
|
73
|
-
q.since = Number(p.get("since"));
|
|
74
|
-
if (p.get("limit"))
|
|
75
|
-
q.limit = Number(p.get("limit"));
|
|
76
|
-
return q;
|
|
77
|
-
}
|
|
78
|
-
function readBody(req) {
|
|
79
|
-
return new Promise((resolve, reject) => {
|
|
80
|
-
let data = "";
|
|
81
|
-
req.on("data", (c) => (data += c));
|
|
82
|
-
req.on("end", () => resolve(data));
|
|
83
|
-
req.on("error", reject);
|
|
84
|
-
});
|
|
85
|
-
}
|
package/dist/relay/store.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { DiagEvent, RelayQuery, RelayResult } from "../shared/events.js";
|
|
2
|
-
export declare class EventStore {
|
|
3
|
-
private readonly capacity;
|
|
4
|
-
private readonly buf;
|
|
5
|
-
private readonly sessionsSeen;
|
|
6
|
-
private latest;
|
|
7
|
-
constructor(capacity?: number);
|
|
8
|
-
/** Ingest a batch from a client. Tracks the session and advances latestSeq. */
|
|
9
|
-
ingest(sessionId: string, events: DiagEvent[]): void;
|
|
10
|
-
/** Answer an MCP query against the buffer. Newest-biased when limited. */
|
|
11
|
-
query(q?: RelayQuery): RelayResult;
|
|
12
|
-
size(): number;
|
|
13
|
-
}
|
package/dist/relay/store.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// The relay's memory: a bounded ring buffer of recent diagnostics events,
|
|
2
|
-
// queryable the way the MCP server asks ("network events since seq 40", "last
|
|
3
|
-
// 20 errors"). Pure and dependency-free so it is fully unit-testable; the WS
|
|
4
|
-
// server in server.ts is a thin wrapper that feeds this and answers queries
|
|
5
|
-
// from it.
|
|
6
|
-
export class EventStore {
|
|
7
|
-
capacity;
|
|
8
|
-
buf = [];
|
|
9
|
-
sessionsSeen = new Set();
|
|
10
|
-
latest = -1;
|
|
11
|
-
constructor(capacity = 5000) {
|
|
12
|
-
this.capacity = capacity;
|
|
13
|
-
}
|
|
14
|
-
/** Ingest a batch from a client. Tracks the session and advances latestSeq. */
|
|
15
|
-
ingest(sessionId, events) {
|
|
16
|
-
this.sessionsSeen.add(sessionId);
|
|
17
|
-
for (const e of events) {
|
|
18
|
-
// Tag ownership on the stored copy so cross-session queries can filter.
|
|
19
|
-
this.buf.push(withSession(e, sessionId));
|
|
20
|
-
if (e.seq > this.latest)
|
|
21
|
-
this.latest = e.seq;
|
|
22
|
-
}
|
|
23
|
-
// Ring: drop oldest beyond capacity.
|
|
24
|
-
if (this.buf.length > this.capacity) {
|
|
25
|
-
this.buf.splice(0, this.buf.length - this.capacity);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
/** Answer an MCP query against the buffer. Newest-biased when limited. */
|
|
29
|
-
query(q = {}) {
|
|
30
|
-
let out = this.buf.filter((e) => {
|
|
31
|
-
if (q.sessionId && sessionOf(e) !== q.sessionId)
|
|
32
|
-
return false;
|
|
33
|
-
if (q.kind && e.kind !== q.kind)
|
|
34
|
-
return false;
|
|
35
|
-
if (q.since !== undefined && e.seq <= q.since)
|
|
36
|
-
return false;
|
|
37
|
-
return true;
|
|
38
|
-
});
|
|
39
|
-
if (q.limit !== undefined && out.length > q.limit) {
|
|
40
|
-
out = out.slice(out.length - q.limit); // keep the newest `limit`
|
|
41
|
-
}
|
|
42
|
-
return {
|
|
43
|
-
events: out.map(stripSession),
|
|
44
|
-
latestSeq: this.latest,
|
|
45
|
-
sessions: [...this.sessionsSeen],
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
size() {
|
|
49
|
-
return this.buf.length;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
// The store tags each event with its owning session out-of-band (a symbol key)
|
|
53
|
-
// so it survives in the buffer without polluting the wire type. Queries strip
|
|
54
|
-
// it before returning.
|
|
55
|
-
const SESSION = Symbol("session");
|
|
56
|
-
function withSession(e, sessionId) {
|
|
57
|
-
return Object.assign(Object.create(Object.getPrototypeOf(e)), e, {
|
|
58
|
-
[SESSION]: sessionId,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
function sessionOf(e) {
|
|
62
|
-
return e[SESSION];
|
|
63
|
-
}
|
|
64
|
-
function stripSession(e) {
|
|
65
|
-
const copy = { ...e };
|
|
66
|
-
delete copy[SESSION];
|
|
67
|
-
return copy;
|
|
68
|
-
}
|
package/dist/scaffold/index.d.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
|
|
2
|
-
/** Injectable git runner (real git by default; a fake in tests avoids the network). */
|
|
3
|
-
export type GitRunner = (args: string[]) => Promise<void>;
|
|
4
|
-
/**
|
|
5
|
-
* True when `dir` has no app yet (no package.json) → safe to scaffold into.
|
|
6
|
-
* A directory that already holds an app is left untouched (project_create just binds).
|
|
7
|
-
*/
|
|
8
|
-
export declare function needsScaffold(dir: string): Promise<boolean>;
|
|
9
|
-
export interface ScaffoldOptions {
|
|
10
|
-
repo?: string;
|
|
11
|
-
/** Test seam — defaults to spawning real git. */
|
|
12
|
-
runGit?: GitRunner;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
|
|
16
|
-
* copies the files in (never overwriting anything already there), then inits a
|
|
17
|
-
* fresh repo. Throws a clear error if the clone fails (e.g. no git access).
|
|
18
|
-
*/
|
|
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
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// Scaffold a new app from the v2-template GitHub template as part of
|
|
2
|
-
// project_create. Shallow-clones the template, drops its git history, copies the
|
|
3
|
-
// files into the target, and inits a fresh repo the creator owns — the local-first
|
|
4
|
-
// equivalent of GitHub's "Use this template". Node-only (spawns git); not imported
|
|
5
|
-
// by browser consumers.
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
7
|
-
import { promises as fs } from "node:fs";
|
|
8
|
-
import os from "node:os";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import { promisify } from "node:util";
|
|
11
|
-
const execFileAsync = promisify(execFile);
|
|
12
|
-
export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
|
|
13
|
-
const realGit = async (args) => {
|
|
14
|
-
await execFileAsync("git", args);
|
|
15
|
-
};
|
|
16
|
-
/**
|
|
17
|
-
* True when `dir` has no app yet (no package.json) → safe to scaffold into.
|
|
18
|
-
* A directory that already holds an app is left untouched (project_create just binds).
|
|
19
|
-
*/
|
|
20
|
-
export async function needsScaffold(dir) {
|
|
21
|
-
try {
|
|
22
|
-
await fs.access(path.join(dir, "package.json"));
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return true;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
|
|
31
|
-
* copies the files in (never overwriting anything already there), then inits a
|
|
32
|
-
* fresh repo. Throws a clear error if the clone fails (e.g. no git access).
|
|
33
|
-
*/
|
|
34
|
-
export async function scaffoldFromTemplate(targetDir, opts = {}) {
|
|
35
|
-
const repo = opts.repo ?? TEMPLATE_REPO;
|
|
36
|
-
const runGit = opts.runGit ?? realGit;
|
|
37
|
-
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "vincentt-tmpl-"));
|
|
38
|
-
try {
|
|
39
|
-
try {
|
|
40
|
-
await runGit(["clone", "--depth", "1", repo, tmp]);
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
-
throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
|
|
45
|
-
}
|
|
46
|
-
await fs.rm(path.join(tmp, ".git"), { recursive: true, force: true });
|
|
47
|
-
// Copy template contents in without clobbering anything already in the target.
|
|
48
|
-
for (const entry of await fs.readdir(tmp)) {
|
|
49
|
-
await fs.cp(path.join(tmp, entry), path.join(targetDir, entry), {
|
|
50
|
-
recursive: true,
|
|
51
|
-
force: false,
|
|
52
|
-
errorOnExist: false,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
// Fresh history the creator owns (skip if the target is already a repo).
|
|
56
|
-
if (!(await isGitRepo(targetDir))) {
|
|
57
|
-
await runGit(["-C", targetDir, "init"]);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
await fs.rm(tmp, { recursive: true, force: true });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
async function isGitRepo(dir) {
|
|
65
|
-
try {
|
|
66
|
-
await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"]);
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
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
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
export interface MachineConfig {
|
|
2
|
-
/** Base URL of the Vincentt backend API (e.g. https://api.vincentt.studio). */
|
|
3
|
-
apiUrl: string;
|
|
4
|
-
/** Personal access token (bearer) for machine auth. */
|
|
5
|
-
pat: string;
|
|
6
|
-
}
|
|
7
|
-
export interface ProjectBinding {
|
|
8
|
-
/** The backend Project id this working tree publishes to. */
|
|
9
|
-
projectId: string;
|
|
10
|
-
/** The project's slug — its <slug>.<apex> host. Informational (server-authoritative). */
|
|
11
|
-
slug: string;
|
|
12
|
-
/** Optional per-project API override; else the machine config's apiUrl. */
|
|
13
|
-
apiUrl?: string;
|
|
14
|
-
}
|
|
15
|
-
export declare const MACHINE_CONFIG_PATH: string;
|
|
16
|
-
export declare function projectBindingPath(cwd: string): string;
|
|
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>;
|
|
24
|
-
export declare function loadProjectBinding(cwd: string): Promise<ProjectBinding | undefined>;
|
|
25
|
-
/**
|
|
26
|
-
* Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
|
|
27
|
-
* secret-free-but-tenant-scoped binding never rides a commit or `git archive`).
|
|
28
|
-
*/
|
|
29
|
-
export declare function writeProjectBinding(cwd: string, binding: ProjectBinding): Promise<string>;
|
|
30
|
-
export interface ResolvedConfig {
|
|
31
|
-
apiUrl: string;
|
|
32
|
-
pat: string;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Resolve the API URL + PAT for a machine call. Precedence: a project binding's
|
|
36
|
-
* apiUrl wins for the URL; env vars override the machine-config file; the file is
|
|
37
|
-
* the base. Throws a clear, actionable error when no PAT is configured.
|
|
38
|
-
*/
|
|
39
|
-
export declare function resolveConfig(cwd: string): Promise<ResolvedConfig>;
|