@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,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deprecated alias for `vincentt mcp`. Rewrites argv to the `mcp` subcommand
|
|
3
|
+
// (preserving flags like --relay-url) and dispatches to the unified CLI.
|
|
4
|
+
console.error("Note: `harness-mcp` is deprecated in favor of `vincentt mcp`.");
|
|
5
|
+
process.argv = [process.argv[0], process.argv[1], "mcp", ...process.argv.slice(2)];
|
|
6
|
+
await import("../index.js");
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deprecated alias for `vincentt relay`. Rewrites argv to the `relay` subcommand
|
|
3
|
+
// (preserving flags like --port) and dispatches to the unified CLI.
|
|
4
|
+
console.error("Note: `harness-relay` is deprecated in favor of `vincentt relay`.");
|
|
5
|
+
process.argv = [process.argv[0], process.argv[1], "relay", ...process.argv.slice(2)];
|
|
6
|
+
await import("../index.js");
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deprecated alias for `vincentt`. Kept for one release so the live dogfood setup
|
|
3
|
+
// and any early installs keep working. Prints a notice, then dispatches to the
|
|
4
|
+
// same CLI (`harness login` → `vincentt login`).
|
|
5
|
+
console.error("Note: `harness` is deprecated in favor of `vincentt` (e.g. `vincentt login`).");
|
|
6
|
+
await import("../index.js");
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `vincentt` — the unified CLI for the local-first dev loop. A human and a
|
|
3
|
+
// bash-capable coding agent drive the same verbs. No MCP registration is required
|
|
4
|
+
// for the default path; `vincentt mcp` exists only for hosts without a shell.
|
|
5
|
+
//
|
|
6
|
+
// Verb groups:
|
|
7
|
+
// auth login · logout · whoami
|
|
8
|
+
// dev loop create · publish · preview
|
|
9
|
+
// diagnostics logs · network · trace
|
|
10
|
+
// reverse feedback (--wait | --watch)
|
|
11
|
+
// servers relay · mcp
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { runLogin } from "../login/login.js";
|
|
15
|
+
import { clearMachineToken, loadMachineConfig, } from "../shared/config.js";
|
|
16
|
+
import { getMe } from "../lib/backend.js";
|
|
17
|
+
import { diagQuery, diagRead, previewStart, previewStop, projectCreate, projectPublish, } from "../lib/lifecycle.js";
|
|
18
|
+
import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
|
|
19
|
+
const DEFAULT_EDITOR_URL = "https://editor.vincentt.studio";
|
|
20
|
+
const DEFAULT_API_URL = "https://api.vincentt.studio";
|
|
21
|
+
// ---- arg helpers ------------------------------------------------------------
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
function flag(name) {
|
|
24
|
+
const i = argv.indexOf(name);
|
|
25
|
+
return i !== -1 ? argv[i + 1] : undefined;
|
|
26
|
+
}
|
|
27
|
+
function has(name) {
|
|
28
|
+
return argv.includes(name);
|
|
29
|
+
}
|
|
30
|
+
function numFlag(name) {
|
|
31
|
+
const v = flag(name);
|
|
32
|
+
return v !== undefined ? Number(v) : undefined;
|
|
33
|
+
}
|
|
34
|
+
function projectDir() {
|
|
35
|
+
const dir = flag("--dir");
|
|
36
|
+
return dir ? path.resolve(dir) : process.cwd();
|
|
37
|
+
}
|
|
38
|
+
// ---- auth -------------------------------------------------------------------
|
|
39
|
+
async function login() {
|
|
40
|
+
const editorUrl = flag("--editor") ?? process.env.VINCENTT_EDITOR_URL ?? DEFAULT_EDITOR_URL;
|
|
41
|
+
const apiUrl = flag("--api") ?? process.env.VINCENTT_API_URL ?? DEFAULT_API_URL;
|
|
42
|
+
const tokenName = flag("--name") ?? `Vincentt CLI (${os.hostname()})`;
|
|
43
|
+
console.log("Opening your browser to authorize this device…");
|
|
44
|
+
const { apiUrl: saved, configPath } = await runLogin({
|
|
45
|
+
editorUrl,
|
|
46
|
+
apiUrl,
|
|
47
|
+
tokenName,
|
|
48
|
+
onUrl: (url) => console.log(`\nIf your browser didn't open, visit:\n ${url}\n`),
|
|
49
|
+
});
|
|
50
|
+
console.log(`\n✓ Signed in. Access token saved to ${configPath}`);
|
|
51
|
+
console.log(` API: ${saved}`);
|
|
52
|
+
}
|
|
53
|
+
async function logout() {
|
|
54
|
+
const { path: p, hadToken } = await clearMachineToken();
|
|
55
|
+
console.log(hadToken ? `✓ Signed out. Token removed from ${p}` : "Already signed out.");
|
|
56
|
+
}
|
|
57
|
+
async function whoami() {
|
|
58
|
+
const machine = await loadMachineConfig();
|
|
59
|
+
const apiUrl = process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
|
|
60
|
+
const pat = process.env.VINCENTT_PAT ?? machine.pat;
|
|
61
|
+
if (!pat) {
|
|
62
|
+
console.log("Not signed in. Run `vincentt login`.");
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const cfg = { apiUrl: apiUrl.replace(/\/$/, ""), pat };
|
|
67
|
+
try {
|
|
68
|
+
const me = await getMe(cfg);
|
|
69
|
+
const who = me.email ?? me.name ?? me.id ?? "(unknown account)";
|
|
70
|
+
console.log(`Signed in as ${who}`);
|
|
71
|
+
console.log(` API: ${cfg.apiUrl}`);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
// Degrade: offline or a backend without /me still confirms a token is present.
|
|
75
|
+
console.log(`API: ${cfg.apiUrl}`);
|
|
76
|
+
console.log(`Token present but unverified (${err instanceof Error ? err.message : String(err)}).`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ---- dev loop ---------------------------------------------------------------
|
|
80
|
+
async function create() {
|
|
81
|
+
const result = await projectCreate(projectDir(), {
|
|
82
|
+
name: flag("--name"),
|
|
83
|
+
slug: flag("--slug"),
|
|
84
|
+
scaffold: has("--no-scaffold") ? false : undefined,
|
|
85
|
+
});
|
|
86
|
+
console.log(result.message);
|
|
87
|
+
if (result.alreadyBound)
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
}
|
|
90
|
+
async function publish() {
|
|
91
|
+
const result = await projectPublish(projectDir(), {
|
|
92
|
+
distDir: flag("--dist"),
|
|
93
|
+
note: flag("--note"),
|
|
94
|
+
});
|
|
95
|
+
console.log(result.message);
|
|
96
|
+
if (result.notBound)
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
}
|
|
99
|
+
async function preview() {
|
|
100
|
+
const { preview: p, started } = await previewStart(projectDir(), (m) => console.error(`[preview] ${m}`));
|
|
101
|
+
if (!started) {
|
|
102
|
+
console.log(`A preview is already running:\n${p.url}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log(`\nLive preview — open on your device:\n ${p.url}\n`);
|
|
106
|
+
console.log("Diagnostics: vincentt logs | network | trace");
|
|
107
|
+
console.log("Feedback: vincentt feedback --wait");
|
|
108
|
+
console.log("\nPress Ctrl-C to stop.");
|
|
109
|
+
if (!p.appReady) {
|
|
110
|
+
console.log(`\nNote: the app on :${p.appPort} isn't responding yet — the URL may be blank until its build finishes.`);
|
|
111
|
+
}
|
|
112
|
+
// Keep the process alive until interrupted; reap the tunnel/dev-server on exit.
|
|
113
|
+
await new Promise((resolve) => {
|
|
114
|
+
const stop = async () => {
|
|
115
|
+
console.error("\nStopping preview…");
|
|
116
|
+
await previewStop();
|
|
117
|
+
resolve();
|
|
118
|
+
};
|
|
119
|
+
process.on("SIGINT", stop);
|
|
120
|
+
process.on("SIGTERM", stop);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// ---- diagnostics ------------------------------------------------------------
|
|
124
|
+
async function diag(kind) {
|
|
125
|
+
const cwd = projectDir();
|
|
126
|
+
const opts = {
|
|
127
|
+
sessionId: flag("--session"),
|
|
128
|
+
since: numFlag("--since"),
|
|
129
|
+
limit: numFlag("--limit"),
|
|
130
|
+
errorsOnly: has("--errors"),
|
|
131
|
+
};
|
|
132
|
+
if (has("--json")) {
|
|
133
|
+
console.log(JSON.stringify(await diagQuery(cwd, kind, opts), null, 2));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.log(await diagRead(cwd, kind, opts));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// ---- reverse channel --------------------------------------------------------
|
|
140
|
+
async function feedback() {
|
|
141
|
+
const cwd = projectDir();
|
|
142
|
+
const timeoutMs = (numFlag("--timeout") ?? 300) * 1000;
|
|
143
|
+
const watch = has("--watch");
|
|
144
|
+
if (!watch && !has("--wait")) {
|
|
145
|
+
console.error("Usage: vincentt feedback --wait | --watch [--timeout <seconds>]");
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
do {
|
|
150
|
+
const after = await readCursor(cwd);
|
|
151
|
+
const annotation = await fetchNext(cwd, { after, timeoutMs });
|
|
152
|
+
if (!annotation) {
|
|
153
|
+
// 204 re-arm signal: --wait exits empty, --watch loops.
|
|
154
|
+
if (!watch)
|
|
155
|
+
return;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Print BEFORE advancing the cursor → at-least-once (a crash re-delivers).
|
|
159
|
+
console.log(JSON.stringify(annotation));
|
|
160
|
+
await writeCursor(cwd, annotation.seq);
|
|
161
|
+
} while (watch);
|
|
162
|
+
}
|
|
163
|
+
// ---- servers ----------------------------------------------------------------
|
|
164
|
+
async function mcp() {
|
|
165
|
+
const { runStdio } = await import("../mcp/server.js");
|
|
166
|
+
await runStdio({ relayUrl: flag("--relay-url") });
|
|
167
|
+
}
|
|
168
|
+
async function relay() {
|
|
169
|
+
const { startRelay } = await import("../relay/server.js");
|
|
170
|
+
const port = numFlag("--port") ?? 7331;
|
|
171
|
+
const dir = flag("--dir");
|
|
172
|
+
startRelay({
|
|
173
|
+
port,
|
|
174
|
+
projectDir: dir ? path.resolve(dir) : undefined,
|
|
175
|
+
onLog: (m) => console.log(`[relay] ${m}`),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// ---- dispatch ---------------------------------------------------------------
|
|
179
|
+
function usage() {
|
|
180
|
+
console.log(`Vincentt CLI — local-first AR dev loop\n\n` +
|
|
181
|
+
`Usage: vincentt <command> [options]\n\n` +
|
|
182
|
+
`Auth\n` +
|
|
183
|
+
` login Sign in; save an access token to ~/.vincentt/config.json\n` +
|
|
184
|
+
` logout Remove the saved access token\n` +
|
|
185
|
+
` whoami Show the signed-in account (via GET /me)\n\n` +
|
|
186
|
+
`Dev loop\n` +
|
|
187
|
+
` create Create/bind a project (scaffolds the starter if empty)\n` +
|
|
188
|
+
` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
|
|
189
|
+
` preview Serve + tunnel the app for on-device preview\n\n` +
|
|
190
|
+
`Diagnostics (need a running preview)\n` +
|
|
191
|
+
` logs [--errors] Console logs from the device [--json]\n` +
|
|
192
|
+
` network fetch/XHR requests [--json]\n` +
|
|
193
|
+
` trace Performance samples [--json]\n\n` +
|
|
194
|
+
`Reverse channel\n` +
|
|
195
|
+
` feedback --wait Block for the next phone annotation, print it, exit\n` +
|
|
196
|
+
` feedback --watch Stream annotations as they arrive\n\n` +
|
|
197
|
+
`Servers\n` +
|
|
198
|
+
` relay Run the diagnostics/annotation relay\n` +
|
|
199
|
+
` mcp Run the MCP adapter (for hosts without a shell)\n\n` +
|
|
200
|
+
`Common options: --dir <path> (project dir, default cwd)\n`);
|
|
201
|
+
}
|
|
202
|
+
async function main() {
|
|
203
|
+
const cmd = argv[0];
|
|
204
|
+
switch (cmd) {
|
|
205
|
+
case "login":
|
|
206
|
+
return login();
|
|
207
|
+
case "logout":
|
|
208
|
+
return logout();
|
|
209
|
+
case "whoami":
|
|
210
|
+
return whoami();
|
|
211
|
+
case "create":
|
|
212
|
+
return create();
|
|
213
|
+
case "publish":
|
|
214
|
+
return publish();
|
|
215
|
+
case "preview":
|
|
216
|
+
return preview();
|
|
217
|
+
case "logs":
|
|
218
|
+
return diag("log");
|
|
219
|
+
case "network":
|
|
220
|
+
return diag("network");
|
|
221
|
+
case "trace":
|
|
222
|
+
return diag("trace");
|
|
223
|
+
case "feedback":
|
|
224
|
+
return feedback();
|
|
225
|
+
case "mcp":
|
|
226
|
+
return mcp();
|
|
227
|
+
case "relay":
|
|
228
|
+
return relay();
|
|
229
|
+
case undefined:
|
|
230
|
+
case "-h":
|
|
231
|
+
case "--help":
|
|
232
|
+
usage();
|
|
233
|
+
return;
|
|
234
|
+
default:
|
|
235
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
236
|
+
usage();
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
main().catch((err) => {
|
|
241
|
+
console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../shared/config.js";
|
|
2
|
+
export interface CreatedProject {
|
|
3
|
+
projectId: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PublishResult {
|
|
8
|
+
version: number;
|
|
9
|
+
/** Immutable versioned URL (<slug>.<apex>/v<n>/). */
|
|
10
|
+
url: string;
|
|
11
|
+
/** Stable live alias (<slug>.<apex>/). */
|
|
12
|
+
liveUrl: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class BackendError extends Error {
|
|
15
|
+
readonly status?: number | undefined;
|
|
16
|
+
constructor(message: string, status?: number | undefined);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create a backend project (reuses POST /projects). A `slug` pins the permanent
|
|
20
|
+
* <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
|
|
21
|
+
* taken slug comes back 409, surfaced so the caller can re-ask.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createProject(cfg: ResolvedConfig, name: string, slug?: string): Promise<CreatedProject>;
|
|
24
|
+
export interface MintedTunnel {
|
|
25
|
+
tunnelId: string;
|
|
26
|
+
/** The public host the phone opens: <slug>-<token>.<previewApex>. */
|
|
27
|
+
hostname: string;
|
|
28
|
+
/** cloudflared run token — `cloudflared tunnel run --token <this>`. */
|
|
29
|
+
runToken: string;
|
|
30
|
+
/** DNS record id, passed back on reap. */
|
|
31
|
+
dnsRecordId: string;
|
|
32
|
+
}
|
|
33
|
+
/** Mint a per-session dev tunnel for `localPort` (the backend holds CF creds). */
|
|
34
|
+
export declare function mintTunnel(cfg: ResolvedConfig, projectId: string, localPort: number): Promise<MintedTunnel>;
|
|
35
|
+
/** Tear down a session tunnel (DNS route + tunnel). Best-effort; never throws. */
|
|
36
|
+
export declare function reapTunnel(cfg: ResolvedConfig, projectId: string, ref: {
|
|
37
|
+
tunnelId: string;
|
|
38
|
+
dnsRecordId?: string;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
/** Recursively list files under `dir`, returning dir-relative POSIX paths. */
|
|
41
|
+
export declare function walkDir(dir: string, base?: string): Promise<string[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Upload a locally-built dist and make it live. Each file rides as a multipart
|
|
44
|
+
* part whose filename is its dist-relative path (the backend reconstructs the
|
|
45
|
+
* tree). Requires an index.html at the dist root.
|
|
46
|
+
*/
|
|
47
|
+
export declare function publishUpload(cfg: ResolvedConfig, projectId: string, distDir: string, opts?: {
|
|
48
|
+
note?: string;
|
|
49
|
+
commitSha?: string;
|
|
50
|
+
}): Promise<PublishResult>;
|
|
51
|
+
export interface Me {
|
|
52
|
+
id?: string;
|
|
53
|
+
email?: string;
|
|
54
|
+
name?: string;
|
|
55
|
+
networkId?: string;
|
|
56
|
+
}
|
|
57
|
+
/** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
|
|
58
|
+
* the caller can degrade to "token present, unverified". */
|
|
59
|
+
export declare function getMe(cfg: ResolvedConfig): Promise<Me>;
|
|
60
|
+
/** The working tree's current commit, for publish provenance. Undefined if not a repo. */
|
|
61
|
+
export declare function gitHeadSha(cwd: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The lifecycle verbs' backend calls, isolated from the MCP SDK wiring (server.ts)
|
|
2
|
+
// so they're unit-testable and the transport can change without touching them.
|
|
3
|
+
// Every call is PAT-authed (Bearer). The publish path uploads an already-built
|
|
4
|
+
// dist as-is — the backend never rebuilds (Principle 0).
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
export class BackendError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(message, status) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.name = "BackendError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function authFetch(cfg, pathname, init = {}) {
|
|
19
|
+
return fetch(`${cfg.apiUrl}${pathname}`, {
|
|
20
|
+
...init,
|
|
21
|
+
headers: { ...(init.headers ?? {}), authorization: `Bearer ${cfg.pat}` },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function safeText(res) {
|
|
25
|
+
try {
|
|
26
|
+
return (await res.text()).slice(0, 300);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Create a backend project (reuses POST /projects). A `slug` pins the permanent
|
|
34
|
+
* <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
|
|
35
|
+
* taken slug comes back 409, surfaced so the caller can re-ask.
|
|
36
|
+
*/
|
|
37
|
+
export async function createProject(cfg, name, slug) {
|
|
38
|
+
const res = await authFetch(cfg, "/projects", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify({ name, ...(slug ? { slug } : {}) }),
|
|
42
|
+
});
|
|
43
|
+
if (res.status === 409) {
|
|
44
|
+
throw new BackendError(`slug "${slug}" is already in use — pick another.`, 409);
|
|
45
|
+
}
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new BackendError(`create project failed (${res.status}): ${await safeText(res)}`, res.status);
|
|
48
|
+
}
|
|
49
|
+
const p = (await res.json());
|
|
50
|
+
const projectId = p._id ?? p.id;
|
|
51
|
+
if (!projectId || !p.slug) {
|
|
52
|
+
throw new BackendError("create project: response missing id/slug");
|
|
53
|
+
}
|
|
54
|
+
return { projectId, slug: p.slug, name: p.name ?? name };
|
|
55
|
+
}
|
|
56
|
+
/** Mint a per-session dev tunnel for `localPort` (the backend holds CF creds). */
|
|
57
|
+
export async function mintTunnel(cfg, projectId, localPort) {
|
|
58
|
+
const res = await authFetch(cfg, `/projects/${projectId}/tunnel`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "content-type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ port: localPort }),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
throw new BackendError(`tunnel mint failed (${res.status}): ${await safeText(res)}`, res.status);
|
|
65
|
+
}
|
|
66
|
+
return (await res.json());
|
|
67
|
+
}
|
|
68
|
+
/** Tear down a session tunnel (DNS route + tunnel). Best-effort; never throws. */
|
|
69
|
+
export async function reapTunnel(cfg, projectId, ref) {
|
|
70
|
+
try {
|
|
71
|
+
await authFetch(cfg, `/projects/${projectId}/tunnel/reap`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/json" },
|
|
74
|
+
body: JSON.stringify(ref),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Reap is a cleanup courtesy — a failure must never crash the preview teardown.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Recursively list files under `dir`, returning dir-relative POSIX paths. */
|
|
82
|
+
export async function walkDir(dir, base = dir) {
|
|
83
|
+
const out = [];
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err.code === "ENOENT") {
|
|
90
|
+
throw new BackendError(`dist directory not found: ${dir}`);
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const full = path.join(dir, entry.name);
|
|
96
|
+
if (entry.isDirectory())
|
|
97
|
+
out.push(...(await walkDir(full, base)));
|
|
98
|
+
else if (entry.isFile())
|
|
99
|
+
out.push(path.relative(base, full).split(path.sep).join("/"));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Upload a locally-built dist and make it live. Each file rides as a multipart
|
|
105
|
+
* part whose filename is its dist-relative path (the backend reconstructs the
|
|
106
|
+
* tree). Requires an index.html at the dist root.
|
|
107
|
+
*/
|
|
108
|
+
export async function publishUpload(cfg, projectId, distDir, opts = {}) {
|
|
109
|
+
const files = await walkDir(distDir);
|
|
110
|
+
if (!files.includes("index.html")) {
|
|
111
|
+
throw new BackendError(`dist at ${distDir} has no index.html at its root — did the build run?`);
|
|
112
|
+
}
|
|
113
|
+
const form = new FormData();
|
|
114
|
+
// The real dist-relative paths ride as an ordered manifest — a multipart filename
|
|
115
|
+
// can't carry a directory (FormData strips it to a basename), so the backend maps
|
|
116
|
+
// paths[i] → files[i] to rebuild the tree.
|
|
117
|
+
form.append("paths", JSON.stringify(files));
|
|
118
|
+
for (const rel of files) {
|
|
119
|
+
const bytes = await fs.readFile(path.join(distDir, rel));
|
|
120
|
+
// Uint8Array (Buffer) is a valid BlobPart; the filename is cosmetic (the backend
|
|
121
|
+
// uses the manifest), but pass the basename so server logs read sensibly.
|
|
122
|
+
form.append("files", new Blob([bytes]), path.basename(rel));
|
|
123
|
+
}
|
|
124
|
+
if (opts.note)
|
|
125
|
+
form.append("note", opts.note);
|
|
126
|
+
if (opts.commitSha)
|
|
127
|
+
form.append("commitSha", opts.commitSha);
|
|
128
|
+
const res = await authFetch(cfg, `/projects/${projectId}/publish/upload`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
body: form,
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
throw new BackendError(`publish failed (${res.status}): ${await safeText(res)}`, res.status);
|
|
134
|
+
}
|
|
135
|
+
return (await res.json());
|
|
136
|
+
}
|
|
137
|
+
/** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
|
|
138
|
+
* the caller can degrade to "token present, unverified". */
|
|
139
|
+
export async function getMe(cfg) {
|
|
140
|
+
const res = await authFetch(cfg, "/me");
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
throw new BackendError(`/me failed (${res.status}): ${await safeText(res)}`, res.status);
|
|
143
|
+
}
|
|
144
|
+
return (await res.json());
|
|
145
|
+
}
|
|
146
|
+
/** The working tree's current commit, for publish provenance. Undefined if not a repo. */
|
|
147
|
+
export async function gitHeadSha(cwd) {
|
|
148
|
+
try {
|
|
149
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "rev-parse", "HEAD"]);
|
|
150
|
+
return stdout.trim();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DiagEvent, LogEvent, NetworkEvent, RelayQuery, RelayResult, TraceEvent } from "@vincentt-xr/harness/events";
|
|
2
|
+
export interface RelayClient {
|
|
3
|
+
query(q: RelayQuery): Promise<RelayResult>;
|
|
4
|
+
}
|
|
5
|
+
/** A RelayClient backed by the relay's HTTP /query endpoint. */
|
|
6
|
+
export declare function httpRelayClient(baseUrl: string): RelayClient;
|
|
7
|
+
export declare function formatLog(e: LogEvent, now: number): string;
|
|
8
|
+
export declare function formatNetwork(e: NetworkEvent, now: number): string;
|
|
9
|
+
export declare function formatTrace(e: TraceEvent, now: number): string;
|
|
10
|
+
/** Render a RelayResult for one kind into the text block a tool returns. */
|
|
11
|
+
export declare function renderResult(result: RelayResult, kind: DiagEvent["kind"], now: number): string;
|
|
12
|
+
/** Only-errors convenience filter applied on top of a log query result. */
|
|
13
|
+
export declare function filterErrors(result: RelayResult): RelayResult;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The MCP server's logic, independent of the MCP SDK surface: fetch events from
|
|
2
|
+
// the relay and format them into the compact text an agent reads. Kept separate
|
|
3
|
+
// from the SDK wiring (server.ts) so the formatting is unit-testable and the SDK
|
|
4
|
+
// version can churn without touching this.
|
|
5
|
+
/** A RelayClient backed by the relay's HTTP /query endpoint. */
|
|
6
|
+
export function httpRelayClient(baseUrl) {
|
|
7
|
+
return {
|
|
8
|
+
async query(q) {
|
|
9
|
+
const res = await fetch(`${baseUrl}/query`, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "content-type": "application/json" },
|
|
12
|
+
body: JSON.stringify(q),
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok)
|
|
15
|
+
throw new Error(`relay /query returned ${res.status}`);
|
|
16
|
+
return (await res.json());
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Human-time from the client timestamp, relative to now, for the agent's read. */
|
|
21
|
+
function ago(t, now) {
|
|
22
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
23
|
+
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
|
|
24
|
+
}
|
|
25
|
+
export function formatLog(e, now) {
|
|
26
|
+
const level = e.level.toUpperCase().padEnd(5);
|
|
27
|
+
const origin = e.origin ? ` (${e.origin})` : "";
|
|
28
|
+
return `[${level}] ${e.message}${origin} · ${ago(e.t, now)} #${e.seq}`;
|
|
29
|
+
}
|
|
30
|
+
export function formatNetwork(e, now) {
|
|
31
|
+
const status = e.error ? `ERR ${e.error}` : String(e.status);
|
|
32
|
+
return `${e.method} ${e.url} → ${status} (${e.durationMs}ms) · ${ago(e.t, now)} #${e.seq}`;
|
|
33
|
+
}
|
|
34
|
+
export function formatTrace(e, now) {
|
|
35
|
+
const marks = e.marks.length ? ` marks: ${e.marks.join(", ")}` : "";
|
|
36
|
+
return (`${e.fps.toFixed(0)}fps over ${e.windowMs}ms · longest task ${e.longestTaskMs}ms · ` +
|
|
37
|
+
`${e.longTaskCount} long task(s)${marks} · ${ago(e.t, now)} #${e.seq}`);
|
|
38
|
+
}
|
|
39
|
+
/** Render a RelayResult for one kind into the text block a tool returns. */
|
|
40
|
+
export function renderResult(result, kind, now) {
|
|
41
|
+
const lines = result.events.map((e) => {
|
|
42
|
+
if (e.kind === "log")
|
|
43
|
+
return formatLog(e, now);
|
|
44
|
+
if (e.kind === "network")
|
|
45
|
+
return formatNetwork(e, now);
|
|
46
|
+
return formatTrace(e, now);
|
|
47
|
+
});
|
|
48
|
+
const header = result.sessions.length > 1 ? `sessions: ${result.sessions.join(", ")}\n` : "";
|
|
49
|
+
const cursor = `\n\n(latestSeq ${result.latestSeq} — pass since=${result.latestSeq} to get only newer ${kind} events)`;
|
|
50
|
+
if (lines.length === 0) {
|
|
51
|
+
return `${header}No ${kind} events${result.latestSeq >= 0 ? " matched" : " yet — is the preview app open on the device?"}.${result.latestSeq >= 0 ? cursor : ""}`;
|
|
52
|
+
}
|
|
53
|
+
return `${header}${lines.join("\n")}${cursor}`;
|
|
54
|
+
}
|
|
55
|
+
/** Only-errors convenience filter applied on top of a log query result. */
|
|
56
|
+
export function filterErrors(result) {
|
|
57
|
+
return {
|
|
58
|
+
...result,
|
|
59
|
+
events: result.events.filter((e) => e.kind === "log" && e.level === "error"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Annotation } from "@vincentt-xr/harness/events";
|
|
2
|
+
export declare function cursorPath(cwd: string): string;
|
|
3
|
+
/** Last-consumed annotation seq for this project (0 if none consumed yet). */
|
|
4
|
+
export declare function readCursor(cwd: string): Promise<number>;
|
|
5
|
+
/** Advance the cursor. Called ONLY after the annotation was handed off. */
|
|
6
|
+
export declare function writeCursor(cwd: string, seq: number): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Long-poll the relay for the next annotation after `after`. Resolves the
|
|
9
|
+
* annotation, or null when the poll window elapsed with nothing new. Throws a
|
|
10
|
+
* clear hint when no preview relay is running for this project.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fetchNext(cwd: string, opts: {
|
|
13
|
+
after: number;
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
}): Promise<Annotation | null>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The agent-agnostic reverse-channel consumer behind `vincentt feedback --wait`.
|
|
2
|
+
// Long-polls the running preview's relay for the next annotation after a locally
|
|
3
|
+
// persisted cursor. Delivery is at-least-once: the caller advances the cursor only
|
|
4
|
+
// after it has successfully handed the annotation off (printed it), so a crash
|
|
5
|
+
// before that re-delivers on the next call.
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { readRelayInfo } from "./relayInfo.js";
|
|
9
|
+
const CURSOR_FILE = ".vincentt/annotations/offset";
|
|
10
|
+
export function cursorPath(cwd) {
|
|
11
|
+
return path.join(cwd, CURSOR_FILE);
|
|
12
|
+
}
|
|
13
|
+
/** Last-consumed annotation seq for this project (0 if none consumed yet). */
|
|
14
|
+
export async function readCursor(cwd) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await fs.readFile(cursorPath(cwd), "utf8");
|
|
17
|
+
return Number(raw.trim()) || 0;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err.code === "ENOENT")
|
|
21
|
+
return 0;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Advance the cursor. Called ONLY after the annotation was handed off. */
|
|
26
|
+
export async function writeCursor(cwd, seq) {
|
|
27
|
+
const p = cursorPath(cwd);
|
|
28
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
29
|
+
await fs.writeFile(p, `${seq}\n`, "utf8");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Long-poll the relay for the next annotation after `after`. Resolves the
|
|
33
|
+
* annotation, or null when the poll window elapsed with nothing new. Throws a
|
|
34
|
+
* clear hint when no preview relay is running for this project.
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchNext(cwd, opts) {
|
|
37
|
+
const info = await readRelayInfo(cwd);
|
|
38
|
+
if (!info) {
|
|
39
|
+
throw new Error("No running preview for this project — run `vincentt preview` first.");
|
|
40
|
+
}
|
|
41
|
+
const base = `http://localhost:${info.relayPort}`;
|
|
42
|
+
const u = new URL(`${info.harnessPath}/feedback/next`, base);
|
|
43
|
+
u.searchParams.set("project", cwd);
|
|
44
|
+
u.searchParams.set("after", String(opts.after));
|
|
45
|
+
u.searchParams.set("timeout", String(opts.timeoutMs));
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(u);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`Could not reach the preview relay on :${info.relayPort} (${err.message}). Is the preview still running?`);
|
|
52
|
+
}
|
|
53
|
+
if (res.status === 204)
|
|
54
|
+
return null;
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw new Error(`relay feedback/next returned ${res.status}`);
|
|
57
|
+
return (await res.json());
|
|
58
|
+
}
|