@vincentt-xr/cli 0.1.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/deprecated/harness-mcp.d.ts +2 -0
- package/dist/cli/deprecated/harness-mcp.js +7 -0
- package/dist/cli/deprecated/harness-relay.d.ts +2 -0
- package/dist/cli/deprecated/harness-relay.js +7 -0
- package/dist/cli/deprecated/harness.d.ts +2 -0
- package/dist/cli/deprecated/harness.js +7 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +243 -0
- package/dist/lib/backend.d.ts +61 -0
- package/dist/lib/backend.js +155 -0
- package/dist/lib/diagnostics.d.ts +13 -0
- package/dist/lib/diagnostics.js +61 -0
- package/dist/lib/feedback.d.ts +15 -0
- package/dist/lib/feedback.js +58 -0
- package/dist/lib/lifecycle.d.ts +45 -0
- package/dist/lib/lifecycle.js +140 -0
- package/dist/lib/relayInfo.d.ts +14 -0
- package/dist/lib/relayInfo.js +35 -0
- package/dist/login/login.d.ts +34 -0
- package/dist/login/login.js +148 -0
- package/dist/mcp/server.d.ts +16 -0
- package/dist/mcp/server.js +188 -0
- package/dist/preview/cloudflared.d.ts +13 -0
- package/dist/preview/cloudflared.js +46 -0
- package/dist/preview/index.d.ts +3 -0
- package/dist/preview/index.js +6 -0
- package/dist/preview/net.d.ts +6 -0
- package/dist/preview/net.js +56 -0
- package/dist/preview/proxy.d.ts +4 -0
- package/dist/preview/proxy.js +49 -0
- package/dist/preview/runner.d.ts +45 -0
- package/dist/preview/runner.js +111 -0
- package/dist/preview/tunnel.d.ts +14 -0
- package/dist/preview/tunnel.js +28 -0
- package/dist/relay/annotations.d.ts +30 -0
- package/dist/relay/annotations.js +126 -0
- package/dist/relay/injectors.d.ts +29 -0
- package/dist/relay/injectors.js +72 -0
- package/dist/relay/server.d.ts +24 -0
- package/dist/relay/server.js +186 -0
- package/dist/relay/store.d.ts +13 -0
- package/dist/relay/store.js +68 -0
- package/dist/scaffold/index.d.ts +26 -0
- package/dist/scaffold/index.js +85 -0
- package/dist/shared/config.d.ts +47 -0
- package/dist/shared/config.js +105 -0
- package/package.json +41 -0
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
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";
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
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;
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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>;
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
projectDir: projectCwd,
|
|
64
|
+
onLog: (m) => onLog(`[relay] ${m}`),
|
|
65
|
+
});
|
|
66
|
+
front = createServer((req, res) => proxyWeb(req, res, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
67
|
+
front.on("upgrade", (req, socket, head) => proxyWs(req, socket, head, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
68
|
+
await new Promise((resolve) => front.listen(frontPort, resolve));
|
|
69
|
+
session = await startSessionTunnel(projectCwd, frontPort);
|
|
70
|
+
tunnel = spawn(cloudflaredBin, ["tunnel", "run", "--token", session.runToken], {
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
await waitForRegister(tunnel, registerTimeoutMs);
|
|
74
|
+
// Don't hand back a live URL that serves a blank page: wait for the app to
|
|
75
|
+
// actually respond. Non-fatal — a slow build still returns, flagged not-ready.
|
|
76
|
+
const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
|
|
77
|
+
if (!appReady) {
|
|
78
|
+
onLog(`app on :${appPort} isn't responding yet — the URL may be blank until it builds`);
|
|
79
|
+
}
|
|
80
|
+
return { url: session.url, relayPort, appPort, appReady, stop };
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
await stop();
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve once cloudflared reports a live edge connection, reject if it exits
|
|
89
|
+
* first or never registers. Exported for unit testing the log-scan/timeout logic.
|
|
90
|
+
*/
|
|
91
|
+
export function waitForRegister(tunnel, timeoutMs) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
const finish = (fn) => {
|
|
95
|
+
if (settled)
|
|
96
|
+
return;
|
|
97
|
+
settled = true;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
fn();
|
|
100
|
+
};
|
|
101
|
+
const scan = (buf) => {
|
|
102
|
+
if (/Registered tunnel connection|Connection [^ ]+ registered/.test(String(buf))) {
|
|
103
|
+
finish(resolve);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
tunnel.stdout?.on("data", scan);
|
|
107
|
+
tunnel.stderr?.on("data", scan);
|
|
108
|
+
tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).`))));
|
|
109
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.`))), timeoutMs);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type MintedTunnel } from "../lib/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>;
|
|
@@ -0,0 +1,28 @@
|
|
|
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 "../lib/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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Annotation, AnnotationInput } from "@vincentt-xr/harness/events";
|
|
2
|
+
export declare const ANNOTATIONS_DIR = ".vincentt/annotations";
|
|
3
|
+
export declare function annotationsDir(projectDir: string): string;
|
|
4
|
+
export declare class AnnotationStore {
|
|
5
|
+
private readonly projectDir;
|
|
6
|
+
private readonly dir;
|
|
7
|
+
private items;
|
|
8
|
+
private seq;
|
|
9
|
+
private waiters;
|
|
10
|
+
private loaded;
|
|
11
|
+
constructor(projectDir: string, dir?: string);
|
|
12
|
+
/** Load persisted annotations so seq continues across preview restarts. */
|
|
13
|
+
init(): Promise<void>;
|
|
14
|
+
/** Persist and register a new annotation, then wake matching long-poll waiters. */
|
|
15
|
+
add(input: AnnotationInput, opts: {
|
|
16
|
+
now: number;
|
|
17
|
+
id: string;
|
|
18
|
+
}): Promise<Annotation>;
|
|
19
|
+
/** The first annotation with seq greater than `after`, if one is already held. */
|
|
20
|
+
peekAfter(after: number): Annotation | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Long-poll: resolve with the first annotation whose seq is greater than
|
|
23
|
+
* `after`, either immediately (already held) or as soon as one arrives. Resolves
|
|
24
|
+
* null if none arrives within `timeoutMs`.
|
|
25
|
+
*/
|
|
26
|
+
nextAfter(after: number, timeoutMs: number): Promise<Annotation | null>;
|
|
27
|
+
private wake;
|
|
28
|
+
/** Release any parked waiters on teardown so nothing hangs. */
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The relay's annotation store: the agent-agnostic core of the reverse channel.
|
|
2
|
+
// The device POSTs an annotation (screenshot + spec + message) over the preview
|
|
3
|
+
// tunnel; this store stamps + persists it and unblocks any long-poll consumer
|
|
4
|
+
// waiting on it (`vincentt feedback --wait`). One store per preview run, bound to
|
|
5
|
+
// one project directory.
|
|
6
|
+
//
|
|
7
|
+
// Persistence lives under <project>/.vincentt/annotations/:
|
|
8
|
+
// • <id>.png — the full-res annotated screenshot
|
|
9
|
+
// • store.ndjson — one JSON line per annotation (also gives seq continuity
|
|
10
|
+
// across preview restarts, so a consumer's saved cursor stays
|
|
11
|
+
// valid)
|
|
12
|
+
// The seq is a monotonic per-project cursor: a consumer asks "next after seq N".
|
|
13
|
+
import { promises as fs } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
export const ANNOTATIONS_DIR = ".vincentt/annotations";
|
|
16
|
+
const STORE_FILE = "store.ndjson";
|
|
17
|
+
export function annotationsDir(projectDir) {
|
|
18
|
+
return path.join(projectDir, ANNOTATIONS_DIR);
|
|
19
|
+
}
|
|
20
|
+
function decodeDataUrl(dataUrl) {
|
|
21
|
+
const comma = dataUrl.indexOf(",");
|
|
22
|
+
const payload = comma === -1 ? dataUrl : dataUrl.slice(comma + 1);
|
|
23
|
+
return Buffer.from(payload, "base64");
|
|
24
|
+
}
|
|
25
|
+
export class AnnotationStore {
|
|
26
|
+
projectDir;
|
|
27
|
+
dir;
|
|
28
|
+
items = [];
|
|
29
|
+
seq = 0;
|
|
30
|
+
waiters = [];
|
|
31
|
+
loaded = false;
|
|
32
|
+
constructor(projectDir, dir = annotationsDir(projectDir)) {
|
|
33
|
+
this.projectDir = projectDir;
|
|
34
|
+
this.dir = dir;
|
|
35
|
+
}
|
|
36
|
+
/** Load persisted annotations so seq continues across preview restarts. */
|
|
37
|
+
async init() {
|
|
38
|
+
if (this.loaded)
|
|
39
|
+
return;
|
|
40
|
+
this.loaded = true;
|
|
41
|
+
try {
|
|
42
|
+
const text = await fs.readFile(path.join(this.dir, STORE_FILE), "utf8");
|
|
43
|
+
for (const line of text.split("\n")) {
|
|
44
|
+
if (!line.trim())
|
|
45
|
+
continue;
|
|
46
|
+
const a = JSON.parse(line);
|
|
47
|
+
this.items.push(a);
|
|
48
|
+
if (a.seq > this.seq)
|
|
49
|
+
this.seq = a.seq;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err.code !== "ENOENT")
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Persist and register a new annotation, then wake matching long-poll waiters. */
|
|
58
|
+
async add(input, opts) {
|
|
59
|
+
await this.init();
|
|
60
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
61
|
+
const screenshotPath = path.join(this.dir, `${opts.id}.png`);
|
|
62
|
+
await fs.writeFile(screenshotPath, decodeDataUrl(input.screenshot));
|
|
63
|
+
const spec = input.spec ?? {};
|
|
64
|
+
const annotation = {
|
|
65
|
+
id: opts.id,
|
|
66
|
+
seq: ++this.seq,
|
|
67
|
+
createdAt: opts.now,
|
|
68
|
+
projectDir: this.projectDir,
|
|
69
|
+
message: input.message,
|
|
70
|
+
screenshotPath,
|
|
71
|
+
spec,
|
|
72
|
+
sessionId: input.sessionId,
|
|
73
|
+
};
|
|
74
|
+
this.items.push(annotation);
|
|
75
|
+
await fs.appendFile(path.join(this.dir, STORE_FILE), JSON.stringify(annotation) + "\n");
|
|
76
|
+
this.wake(annotation);
|
|
77
|
+
return annotation;
|
|
78
|
+
}
|
|
79
|
+
/** The first annotation with seq greater than `after`, if one is already held. */
|
|
80
|
+
peekAfter(after) {
|
|
81
|
+
return this.items.find((a) => a.seq > after);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Long-poll: resolve with the first annotation whose seq is greater than
|
|
85
|
+
* `after`, either immediately (already held) or as soon as one arrives. Resolves
|
|
86
|
+
* null if none arrives within `timeoutMs`.
|
|
87
|
+
*/
|
|
88
|
+
nextAfter(after, timeoutMs) {
|
|
89
|
+
const held = this.peekAfter(after);
|
|
90
|
+
if (held)
|
|
91
|
+
return Promise.resolve(held);
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const waiter = {
|
|
94
|
+
after,
|
|
95
|
+
resolve,
|
|
96
|
+
timer: setTimeout(() => {
|
|
97
|
+
this.waiters = this.waiters.filter((w) => w !== waiter);
|
|
98
|
+
resolve(null);
|
|
99
|
+
}, timeoutMs),
|
|
100
|
+
};
|
|
101
|
+
waiter.timer.unref?.();
|
|
102
|
+
this.waiters.push(waiter);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
wake(a) {
|
|
106
|
+
const remaining = [];
|
|
107
|
+
for (const w of this.waiters) {
|
|
108
|
+
if (a.seq > w.after) {
|
|
109
|
+
clearTimeout(w.timer);
|
|
110
|
+
w.resolve(a);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
remaining.push(w);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.waiters = remaining;
|
|
117
|
+
}
|
|
118
|
+
/** Release any parked waiters on teardown so nothing hangs. */
|
|
119
|
+
close() {
|
|
120
|
+
for (const w of this.waiters) {
|
|
121
|
+
clearTimeout(w.timer);
|
|
122
|
+
w.resolve(null);
|
|
123
|
+
}
|
|
124
|
+
this.waiters = [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Annotation } from "@vincentt-xr/harness/events";
|
|
2
|
+
export interface Injector {
|
|
3
|
+
/** Stable id for config/logging: "claude-code" | "paste" | … */
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** Cheap best-effort readiness probe. The dispatcher skips unavailable injectors. */
|
|
6
|
+
isAvailable(): Promise<boolean>;
|
|
7
|
+
/**
|
|
8
|
+
* Deliver so the agent acts with zero user action. Resolves when the host
|
|
9
|
+
* accepted the injection (not when the agent finishes). Throws if it couldn't —
|
|
10
|
+
* the dispatcher falls through to the next injector (paste never throws).
|
|
11
|
+
*/
|
|
12
|
+
inject(a: Annotation): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface Dispatcher {
|
|
15
|
+
dispatch(a: Annotation): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
/** Walk the injector chain; first available that accepts wins, else fall through. */
|
|
18
|
+
export declare function createDispatcher(injectors: Injector[], onLog?: (msg: string) => void): Dispatcher;
|
|
19
|
+
/**
|
|
20
|
+
* Claude Code passive injector: append the annotation to a queue a running `/loop`
|
|
21
|
+
* drains. Available only when the queue dir exists (the loop has been set up), so a
|
|
22
|
+
* project with no loop configured falls through to the fallback.
|
|
23
|
+
*/
|
|
24
|
+
export declare function claudeCodeInjector(queuePath: string): Injector;
|
|
25
|
+
/**
|
|
26
|
+
* Universal never-fail fallback. When no zero-action injector accepted the
|
|
27
|
+
* annotation, stage a ready prompt so the worst case is a single paste.
|
|
28
|
+
*/
|
|
29
|
+
export declare function pasteInjector(onReady: (prompt: string) => void): Injector;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The one per-agent seam of the reverse channel (docs/harness-annotation-push.md).
|
|
2
|
+
// Everything upstream — capture, relay, store — is agent-agnostic; turning a
|
|
3
|
+
// stored annotation into an agent turn goes through an Injector. The dispatcher
|
|
4
|
+
// walks an ordered chain: the first available injector that accepts wins, and on
|
|
5
|
+
// failure it falls through to the next. The final injector never fails.
|
|
6
|
+
//
|
|
7
|
+
// This change ships only the Claude Code passive injector (append to the queue a
|
|
8
|
+
// running `/loop` drains) plus the never-fail paste fallback. Active injectors and
|
|
9
|
+
// other agents' injectors are follow-ups behind this same interface.
|
|
10
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
/** Walk the injector chain; first available that accepts wins, else fall through. */
|
|
14
|
+
export function createDispatcher(injectors, onLog = () => undefined) {
|
|
15
|
+
return {
|
|
16
|
+
async dispatch(a) {
|
|
17
|
+
for (const inj of injectors) {
|
|
18
|
+
try {
|
|
19
|
+
if (!(await inj.isAvailable()))
|
|
20
|
+
continue;
|
|
21
|
+
await inj.inject(a);
|
|
22
|
+
onLog(`annotation ${a.id} → ${inj.name}`);
|
|
23
|
+
return inj.name;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
onLog(`injector ${inj.name} failed: ${err.message}; falling back`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Unreachable when a never-fail fallback (pasteInjector) terminates the chain.
|
|
30
|
+
throw new Error("no injector accepted the annotation");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Claude Code passive injector: append the annotation to a queue a running `/loop`
|
|
36
|
+
* drains. Available only when the queue dir exists (the loop has been set up), so a
|
|
37
|
+
* project with no loop configured falls through to the fallback.
|
|
38
|
+
*/
|
|
39
|
+
export function claudeCodeInjector(queuePath) {
|
|
40
|
+
return {
|
|
41
|
+
name: "claude-code",
|
|
42
|
+
async isAvailable() {
|
|
43
|
+
return existsSync(path.dirname(queuePath));
|
|
44
|
+
},
|
|
45
|
+
async inject(a) {
|
|
46
|
+
await mkdir(path.dirname(queuePath), { recursive: true });
|
|
47
|
+
const line = JSON.stringify({
|
|
48
|
+
id: a.id,
|
|
49
|
+
seq: a.seq,
|
|
50
|
+
message: a.message,
|
|
51
|
+
screenshot: a.screenshotPath,
|
|
52
|
+
spec: a.spec,
|
|
53
|
+
}) + "\n";
|
|
54
|
+
await appendFile(queuePath, line, "utf8");
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Universal never-fail fallback. When no zero-action injector accepted the
|
|
60
|
+
* annotation, stage a ready prompt so the worst case is a single paste.
|
|
61
|
+
*/
|
|
62
|
+
export function pasteInjector(onReady) {
|
|
63
|
+
return {
|
|
64
|
+
name: "paste",
|
|
65
|
+
async isAvailable() {
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
async inject(a) {
|
|
69
|
+
onReady(`Review annotation ${a.id}: ${a.message}\nScreenshot: ${a.screenshotPath}`);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { EventStore } from "./store.js";
|
|
2
|
+
import { AnnotationStore } from "./annotations.js";
|
|
3
|
+
import { type Dispatcher } from "./injectors.js";
|
|
4
|
+
export interface RelayOptions {
|
|
5
|
+
port?: number;
|
|
6
|
+
/** WS path the client connects to (default matches HarnessProvider). */
|
|
7
|
+
path?: string;
|
|
8
|
+
capacity?: number;
|
|
9
|
+
onLog?: (msg: string) => void;
|
|
10
|
+
/** Bind the reverse channel to this project dir (enables annotation routes). */
|
|
11
|
+
projectDir?: string;
|
|
12
|
+
/** Injected for tests; else built from projectDir. */
|
|
13
|
+
annotationStore?: AnnotationStore;
|
|
14
|
+
/** Injected for tests; else a Claude-Code-passive → paste chain. */
|
|
15
|
+
dispatcher?: Dispatcher;
|
|
16
|
+
/** Injected for tests. */
|
|
17
|
+
makeId?: () => string;
|
|
18
|
+
}
|
|
19
|
+
export interface RelayHandle {
|
|
20
|
+
store: EventStore;
|
|
21
|
+
annotations?: AnnotationStore;
|
|
22
|
+
close: () => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function startRelay(opts?: RelayOptions): RelayHandle;
|