@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
|
@@ -17,5 +17,10 @@ export interface HarnessProviderProps {
|
|
|
17
17
|
captureConsole?: boolean;
|
|
18
18
|
captureNetwork?: boolean;
|
|
19
19
|
captureTrace?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Mount the floating "Send feedback" button (the reverse-channel capture
|
|
22
|
+
* overlay). Defaults to `enabled` — on in preview, gone in production.
|
|
23
|
+
*/
|
|
24
|
+
feedback?: boolean;
|
|
20
25
|
}
|
|
21
26
|
export declare function HarnessProvider(props: HarnessProviderProps): ReactNode;
|
|
@@ -52,9 +52,20 @@ export function HarnessProvider(props) {
|
|
|
52
52
|
captureTrace: props.captureTrace,
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
let unmountFeedback;
|
|
56
|
+
if (props.feedback !== false) {
|
|
57
|
+
// Separate dynamic import so the capture overlay (and its DOM code) also
|
|
58
|
+
// drops from a production bundle.
|
|
59
|
+
void import("./annotate.js").then(({ mountFeedbackButton }) => {
|
|
60
|
+
if (cancelled)
|
|
61
|
+
return;
|
|
62
|
+
unmountFeedback = mountFeedbackButton({ sessionId: props.sessionId });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
55
65
|
return () => {
|
|
56
66
|
cancelled = true;
|
|
57
67
|
teardown?.();
|
|
68
|
+
unmountFeedback?.();
|
|
58
69
|
};
|
|
59
70
|
// Instrumentation is installed once for the provider's lifetime; option
|
|
60
71
|
// changes mid-session are not a supported case.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AnnotationInput, AnnotationSpec } from "../shared/events.js";
|
|
2
|
+
/**
|
|
3
|
+
* Capture the current frame as a PNG data URL. Prefers the largest <canvas> (the
|
|
4
|
+
* AR/three.js surface); returns an empty-ish 1x1 PNG if there is none.
|
|
5
|
+
*
|
|
6
|
+
* Caveat: reading a WebGL canvas requires it to have been created with
|
|
7
|
+
* `preserveDrawingBuffer: true`, or the read can come back blank. r3f/three set
|
|
8
|
+
* this via `gl={{ preserveDrawingBuffer: true }}`. DOM-only content isn't captured
|
|
9
|
+
* here (that needs html2canvas, deliberately not a dependency).
|
|
10
|
+
*/
|
|
11
|
+
export declare function captureScreenshot(): string;
|
|
12
|
+
export interface SendAnnotationOptions {
|
|
13
|
+
/** Relay HTTP base (default: `<origin>/__harness`). */
|
|
14
|
+
relayHttpUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
/** POST an annotation to the relay. Resolves with the relay-assigned id + seq. */
|
|
17
|
+
export declare function sendAnnotation(input: AnnotationInput, opts?: SendAnnotationOptions): Promise<{
|
|
18
|
+
id: string;
|
|
19
|
+
seq: number;
|
|
20
|
+
}>;
|
|
21
|
+
export interface FeedbackButtonOptions extends SendAnnotationOptions {
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
/** Collect the creator's message. Default: window.prompt. */
|
|
24
|
+
promptMessage?: () => string | null | Promise<string | null>;
|
|
25
|
+
/** Override the captured spec (strokes/pins/labels). Default: empty. */
|
|
26
|
+
spec?: AnnotationSpec;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Mount a minimal floating "Send feedback" button. On click it collects a message,
|
|
30
|
+
* captures the frame, and sends the annotation. Returns an unmount function. This
|
|
31
|
+
* is the smallest useful Send action; a richer draw-on-frame overlay can replace
|
|
32
|
+
* the capture/prompt without changing the wire contract.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mountFeedbackButton(opts?: FeedbackButtonOptions): () => void;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The in-app capture overlay's Send action — the client half of the reverse
|
|
2
|
+
// channel. It captures the current frame, packages the creator's message + spec,
|
|
3
|
+
// and POSTs it to the relay over the same tunnel the app is served on. The relay
|
|
4
|
+
// (see the CLI's relay/annotations) stamps + persists it and unblocks a waiting
|
|
5
|
+
// `vincentt feedback --wait`.
|
|
6
|
+
//
|
|
7
|
+
// Loaded via dynamic import from HarnessProvider (like the instrumentation), so a
|
|
8
|
+
// production build never ships it. DOM-only, no React.
|
|
9
|
+
/** Default relay HTTP base: the same origin the app is served on + the harness path. */
|
|
10
|
+
function defaultRelayHttpUrl() {
|
|
11
|
+
return `${window.location.origin}/__harness`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Capture the current frame as a PNG data URL. Prefers the largest <canvas> (the
|
|
15
|
+
* AR/three.js surface); returns an empty-ish 1x1 PNG if there is none.
|
|
16
|
+
*
|
|
17
|
+
* Caveat: reading a WebGL canvas requires it to have been created with
|
|
18
|
+
* `preserveDrawingBuffer: true`, or the read can come back blank. r3f/three set
|
|
19
|
+
* this via `gl={{ preserveDrawingBuffer: true }}`. DOM-only content isn't captured
|
|
20
|
+
* here (that needs html2canvas, deliberately not a dependency).
|
|
21
|
+
*/
|
|
22
|
+
export function captureScreenshot() {
|
|
23
|
+
const canvases = Array.from(document.querySelectorAll("canvas"));
|
|
24
|
+
const biggest = canvases.sort((a, b) => b.width * b.height - a.width * a.height)[0];
|
|
25
|
+
if (biggest) {
|
|
26
|
+
try {
|
|
27
|
+
return biggest.toDataURL("image/png");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Tainted canvas (cross-origin texture without CORS) — fall through.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const fallback = document.createElement("canvas");
|
|
34
|
+
fallback.width = fallback.height = 1;
|
|
35
|
+
return fallback.toDataURL("image/png");
|
|
36
|
+
}
|
|
37
|
+
/** POST an annotation to the relay. Resolves with the relay-assigned id + seq. */
|
|
38
|
+
export async function sendAnnotation(input, opts = {}) {
|
|
39
|
+
const base = opts.relayHttpUrl ?? defaultRelayHttpUrl();
|
|
40
|
+
const res = await fetch(`${base}/annotation`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "content-type": "application/json" },
|
|
43
|
+
body: JSON.stringify(input),
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
throw new Error(`annotation POST failed: ${res.status}`);
|
|
47
|
+
return (await res.json());
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Mount a minimal floating "Send feedback" button. On click it collects a message,
|
|
51
|
+
* captures the frame, and sends the annotation. Returns an unmount function. This
|
|
52
|
+
* is the smallest useful Send action; a richer draw-on-frame overlay can replace
|
|
53
|
+
* the capture/prompt without changing the wire contract.
|
|
54
|
+
*/
|
|
55
|
+
export function mountFeedbackButton(opts = {}) {
|
|
56
|
+
if (typeof document === "undefined")
|
|
57
|
+
return () => undefined;
|
|
58
|
+
const btn = document.createElement("button");
|
|
59
|
+
btn.textContent = "Send feedback";
|
|
60
|
+
Object.assign(btn.style, {
|
|
61
|
+
position: "fixed",
|
|
62
|
+
right: "12px",
|
|
63
|
+
bottom: "12px",
|
|
64
|
+
zIndex: "2147483647",
|
|
65
|
+
padding: "10px 14px",
|
|
66
|
+
borderRadius: "10px",
|
|
67
|
+
border: "none",
|
|
68
|
+
background: "linear-gradient(135deg,#7c3aed,#2563eb)",
|
|
69
|
+
color: "#fff",
|
|
70
|
+
font: "600 13px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
|
|
71
|
+
boxShadow: "0 4px 16px rgba(0,0,0,.25)",
|
|
72
|
+
cursor: "pointer",
|
|
73
|
+
});
|
|
74
|
+
const setBusy = (busy, label) => {
|
|
75
|
+
btn.disabled = busy;
|
|
76
|
+
btn.textContent = label ?? "Send feedback";
|
|
77
|
+
btn.style.opacity = busy ? "0.6" : "1";
|
|
78
|
+
};
|
|
79
|
+
btn.addEventListener("click", async () => {
|
|
80
|
+
const getMsg = opts.promptMessage ?? (() => window.prompt("Feedback for the agent:"));
|
|
81
|
+
const message = await getMsg();
|
|
82
|
+
if (message == null || message.trim() === "")
|
|
83
|
+
return;
|
|
84
|
+
setBusy(true, "Sending…");
|
|
85
|
+
try {
|
|
86
|
+
const screenshot = captureScreenshot();
|
|
87
|
+
const input = {
|
|
88
|
+
message: message.trim(),
|
|
89
|
+
screenshot,
|
|
90
|
+
spec: opts.spec ?? {},
|
|
91
|
+
sessionId: opts.sessionId,
|
|
92
|
+
};
|
|
93
|
+
await sendAnnotation(input, { relayHttpUrl: opts.relayHttpUrl });
|
|
94
|
+
setBusy(false, "Sent ✓");
|
|
95
|
+
window.setTimeout(() => setBusy(false), 1500);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
setBusy(false, "Failed — retry");
|
|
99
|
+
console.error("[harness] annotation send failed:", err);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
document.body.appendChild(btn);
|
|
103
|
+
return () => btn.remove();
|
|
104
|
+
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { HarnessProvider } from "./HarnessProvider.js";
|
|
2
2
|
export type { HarnessProviderProps } from "./HarnessProvider.js";
|
|
3
|
+
export { sendAnnotation, captureScreenshot, mountFeedbackButton, type SendAnnotationOptions, type FeedbackButtonOptions, } from "./annotate.js";
|
|
3
4
|
export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
|
|
5
|
+
export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
|
package/dist/client/index.js
CHANGED
package/dist/shared/events.d.ts
CHANGED
|
@@ -75,4 +75,54 @@ export interface RelayResult {
|
|
|
75
75
|
/** Sessions the relay has seen events for (so the agent can disambiguate). */
|
|
76
76
|
sessions: string[];
|
|
77
77
|
}
|
|
78
|
+
/** A freehand stroke over the frame, in normalized [0,1] frame coordinates. */
|
|
79
|
+
export interface AnnotationStroke {
|
|
80
|
+
points: {
|
|
81
|
+
x: number;
|
|
82
|
+
y: number;
|
|
83
|
+
}[];
|
|
84
|
+
color?: string;
|
|
85
|
+
}
|
|
86
|
+
/** A point marker on the frame, in normalized [0,1] frame coordinates. */
|
|
87
|
+
export interface AnnotationPin {
|
|
88
|
+
x: number;
|
|
89
|
+
y: number;
|
|
90
|
+
label?: string;
|
|
91
|
+
}
|
|
92
|
+
/** The drawn overlay accompanying an annotation. All fields optional — a bare
|
|
93
|
+
* message with a screenshot is a valid annotation. */
|
|
94
|
+
export interface AnnotationSpec {
|
|
95
|
+
strokes?: AnnotationStroke[];
|
|
96
|
+
pins?: AnnotationPin[];
|
|
97
|
+
labels?: string[];
|
|
98
|
+
note?: string;
|
|
99
|
+
}
|
|
100
|
+
/** Client → relay POST body (`POST /__harness/annotation`). The relay stamps the
|
|
101
|
+
* id, receipt time, project binding, and on-disk screenshot path. */
|
|
102
|
+
export interface AnnotationInput {
|
|
103
|
+
/** The creator's message to the agent. */
|
|
104
|
+
message: string;
|
|
105
|
+
/** Annotated frame as a data URL (image/png). */
|
|
106
|
+
screenshot: string;
|
|
107
|
+
spec?: AnnotationSpec;
|
|
108
|
+
/** Optional preview session id, to correlate with diag events. */
|
|
109
|
+
sessionId?: string;
|
|
110
|
+
}
|
|
111
|
+
/** A persisted annotation the agent consumes (via `vincentt feedback --wait`). */
|
|
112
|
+
export interface Annotation {
|
|
113
|
+
/** Short relay-assigned id (e.g. "a7f3"). */
|
|
114
|
+
id: string;
|
|
115
|
+
/** Monotonic per-project cursor; a consumer polls "after this seq". */
|
|
116
|
+
seq: number;
|
|
117
|
+
/** Receipt time, stamped by the relay (not the browser). */
|
|
118
|
+
createdAt: Timestamp;
|
|
119
|
+
/** The bound project directory this annotation belongs to. */
|
|
120
|
+
projectDir: string;
|
|
121
|
+
/** The creator's message to the agent. */
|
|
122
|
+
message: string;
|
|
123
|
+
/** Absolute path to the full-res annotated screenshot on disk. */
|
|
124
|
+
screenshotPath: string;
|
|
125
|
+
spec: AnnotationSpec;
|
|
126
|
+
sessionId?: string;
|
|
127
|
+
}
|
|
78
128
|
export {};
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Vincentt AR dev-loop harness — in-app diagnostics
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Vincentt AR dev-loop harness — in-app diagnostics provider + wire contract",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"files": [
|
|
9
|
-
"
|
|
9
|
+
"dist"
|
|
10
10
|
],
|
|
11
11
|
"main": "./dist/client/index.js",
|
|
12
12
|
"module": "./dist/client/index.js",
|
|
@@ -16,46 +16,20 @@
|
|
|
16
16
|
"types": "./dist/client/index.d.ts",
|
|
17
17
|
"import": "./dist/client/index.js"
|
|
18
18
|
},
|
|
19
|
-
"./
|
|
20
|
-
"types": "./dist/
|
|
21
|
-
"import": "./dist/
|
|
22
|
-
},
|
|
23
|
-
"./preview": {
|
|
24
|
-
"types": "./dist/preview/index.d.ts",
|
|
25
|
-
"import": "./dist/preview/index.js"
|
|
19
|
+
"./events": {
|
|
20
|
+
"types": "./dist/shared/events.d.ts",
|
|
21
|
+
"import": "./dist/shared/events.js"
|
|
26
22
|
}
|
|
27
23
|
},
|
|
28
|
-
"bin": {
|
|
29
|
-
"harness": "./dist/cli/index.js",
|
|
30
|
-
"harness-relay": "./dist/relay/cli.js",
|
|
31
|
-
"harness-mcp": "./dist/mcp/cli.js"
|
|
32
|
-
},
|
|
33
24
|
"peerDependencies": {
|
|
34
25
|
"react": "^18.2.0"
|
|
35
26
|
},
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
-
"cloudflared": "^0.7.1",
|
|
39
|
-
"ws": "^8.18.0",
|
|
40
|
-
"zod": "^3.23.8"
|
|
41
|
-
},
|
|
42
27
|
"devDependencies": {
|
|
43
|
-
"@types/
|
|
44
|
-
"@types/react": "^18.3.12",
|
|
45
|
-
"@types/ws": "^8.5.13",
|
|
46
|
-
"prettier": "3.3.3",
|
|
47
|
-
"ts-node": "^10.9.2",
|
|
48
|
-
"typescript": "^5.6.3",
|
|
49
|
-
"vitest": "^2.1.5"
|
|
28
|
+
"@types/react": "^18.3.12"
|
|
50
29
|
},
|
|
51
30
|
"scripts": {
|
|
52
31
|
"build": "tsc -p tsconfig.build.json",
|
|
53
|
-
"relay": "node --loader ts-node/esm src/relay/cli.ts",
|
|
54
|
-
"mcp": "node --loader ts-node/esm src/mcp/cli.ts",
|
|
55
|
-
"test": "vitest run",
|
|
56
|
-
"test:watch": "vitest",
|
|
57
32
|
"typecheck": "tsc --noEmit",
|
|
58
|
-
"
|
|
59
|
-
"format": "prettier --write ."
|
|
33
|
+
"test": "vitest run"
|
|
60
34
|
}
|
|
61
35
|
}
|
package/README.md
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# @vincentt-xr/harness
|
|
2
|
-
|
|
3
|
-
The Vincentt AR dev-loop harness. It is **one always-on diagnostics channel**
|
|
4
|
-
between the AR app running on a phone and the coding agent running on a laptop,
|
|
5
|
-
so the agent can see what the phone sees — console logs, network requests,
|
|
6
|
-
performance — without the developer hand-ferrying DevTools traces or pasting
|
|
7
|
-
console output.
|
|
8
|
-
|
|
9
|
-
Agent-agnostic: the agent side is a plain **MCP server**, so any MCP-capable
|
|
10
|
-
agent (Claude Code, Codex, Cursor, Cline, …) uses it the same way.
|
|
11
|
-
|
|
12
|
-
## The three parts
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
PHONE (preview app) RELAY (laptop) AGENT (laptop)
|
|
16
|
-
┌──────────────────┐ ┌────────────────┐ ┌────────────────────┐
|
|
17
|
-
│ HarnessProvider │ │ harness-relay │ │ harness-mcp │
|
|
18
|
-
│ • patch console │─ws→│ ring buffer │←──→│ diag_logs │
|
|
19
|
-
│ • wrap fetch/XHR │ │ per session │http│ diag_network │
|
|
20
|
-
│ • sample perf │ │ │ │ diag_trace │
|
|
21
|
-
└──────────────────┘ └────────────────┘ └────────────────────┘
|
|
22
|
-
src/client (npm pkg) src/relay (bin) src/mcp (bin)
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
- **`src/client`** — the in-app half; the published npm package. `HarnessProvider`
|
|
26
|
-
patches `console.*`, wraps `fetch`/`XHR`, and samples `performance`, shipping
|
|
27
|
-
events to the relay over a WebSocket. Auto-on in a dev build, no-op (and
|
|
28
|
-
tree-shaken) in production.
|
|
29
|
-
- **`src/relay`** — a small WebSocket + HTTP server beside Vite. Holds a bounded
|
|
30
|
-
ring buffer of recent events per session. The phone pushes; the agent pulls.
|
|
31
|
-
- **`src/mcp`** — the agent-agnostic MCP server. Exposes `diag_logs`,
|
|
32
|
-
`diag_network`, `diag_trace` that pull from the relay.
|
|
33
|
-
|
|
34
|
-
- **`src/shared/events.ts`** — the wire contract all three import, so they can't
|
|
35
|
-
drift on the event shape.
|
|
36
|
-
|
|
37
|
-
## Use it
|
|
38
|
-
|
|
39
|
-
**In the app (once):**
|
|
40
|
-
|
|
41
|
-
```tsx
|
|
42
|
-
import { HarnessProvider } from "@vincentt-xr/harness";
|
|
43
|
-
|
|
44
|
-
<HarnessProvider>
|
|
45
|
-
<App />
|
|
46
|
-
</HarnessProvider>;
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
That's the whole author-facing surface. It self-activates in dev and disappears
|
|
50
|
-
in the production build.
|
|
51
|
-
|
|
52
|
-
**Run the relay** beside your dev server (the preview tunnel carries its socket
|
|
53
|
-
to the phone):
|
|
54
|
-
|
|
55
|
-
```
|
|
56
|
-
npx harness-relay # listens on :7331, ws path /__harness
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
**Point your agent at the MCP server.** In the agent's MCP config:
|
|
60
|
-
|
|
61
|
-
```json
|
|
62
|
-
{
|
|
63
|
-
"mcpServers": {
|
|
64
|
-
"vincentt-harness": { "command": "npx", "args": ["harness-mcp"] }
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Then, while the app is open on the phone, the agent calls `diag_logs`,
|
|
70
|
-
`diag_network`, `diag_trace` to read what the device is doing — no DevTools, no
|
|
71
|
-
copy-paste.
|
|
72
|
-
|
|
73
|
-
## Develop
|
|
74
|
-
|
|
75
|
-
```
|
|
76
|
-
pnpm install
|
|
77
|
-
pnpm test # unit tests (pure event-shaping, buffer, ring store, formatting, fps)
|
|
78
|
-
pnpm typecheck
|
|
79
|
-
pnpm build # emits dist/ (client = npm surface; relay/mcp = bins)
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
## Status
|
|
83
|
-
|
|
84
|
-
Local-first. The diagnostics limb is built and proven end-to-end
|
|
85
|
-
(phone → relay → agent). The platform lifecycle (per-project server-minted
|
|
86
|
-
tunnels, `createProject`/`publish`, auth) is a separate later phase; the same
|
|
87
|
-
client + relay also feed the production client-review service.
|
package/dist/cli/index.d.ts
DELETED
package/dist/cli/index.js
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
});
|
package/dist/login/login.d.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
}>;
|
package/dist/login/login.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
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
|
-
}
|