@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,45 @@
|
|
|
1
|
+
import { type RelayClient } from "./diagnostics.js";
|
|
2
|
+
import { type RunningPreview } from "../preview/index.js";
|
|
3
|
+
import type { DiagEventKind, RelayResult } from "@vincentt-xr/harness/events";
|
|
4
|
+
export interface CreateOptions {
|
|
5
|
+
name?: string;
|
|
6
|
+
slug?: string;
|
|
7
|
+
scaffold?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface CreateResult {
|
|
10
|
+
message: string;
|
|
11
|
+
alreadyBound: boolean;
|
|
12
|
+
slug?: string;
|
|
13
|
+
projectId?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function projectCreate(projectCwd: string, opts?: CreateOptions): Promise<CreateResult>;
|
|
16
|
+
export interface PublishOptions {
|
|
17
|
+
distDir?: string;
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PublishOutcome {
|
|
21
|
+
message: string;
|
|
22
|
+
notBound: boolean;
|
|
23
|
+
version?: number;
|
|
24
|
+
liveUrl?: string;
|
|
25
|
+
url?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function projectPublish(projectCwd: string, opts?: PublishOptions): Promise<PublishOutcome>;
|
|
28
|
+
export declare function getActivePreview(): RunningPreview | null;
|
|
29
|
+
export interface PreviewStartResult {
|
|
30
|
+
preview: RunningPreview;
|
|
31
|
+
/** false when a preview was already running (the existing one is returned). */
|
|
32
|
+
started: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare function previewStart(projectCwd: string, onLog: (msg: string) => void): Promise<PreviewStartResult>;
|
|
35
|
+
export declare function previewStop(): Promise<boolean>;
|
|
36
|
+
export interface DiagQueryOptions {
|
|
37
|
+
sessionId?: string;
|
|
38
|
+
since?: number;
|
|
39
|
+
limit?: number;
|
|
40
|
+
errorsOnly?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** Raw diagnostics query against the running relay (used by CLI --json). */
|
|
43
|
+
export declare function diagQuery(projectCwd: string, kind: DiagEventKind, opts?: DiagQueryOptions, relay?: RelayClient): Promise<RelayResult>;
|
|
44
|
+
/** Rendered diagnostics text (used by CLI default + MCP tools). */
|
|
45
|
+
export declare function diagRead(projectCwd: string, kind: DiagEventKind, opts?: DiagQueryOptions, relay?: RelayClient, now?: () => number): Promise<string>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The dev-loop implementation, shared by the `vincentt` CLI verbs and the MCP
|
|
2
|
+
// adapter so the two surfaces can never diverge. Pure orchestration over the
|
|
3
|
+
// lower-level pieces (backend calls, scaffold, preview, diagnostics); no CLI or
|
|
4
|
+
// MCP SDK concerns leak in here. Both surfaces render the returned data their
|
|
5
|
+
// own way.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createProject, gitHeadSha, publishUpload } from "./backend.js";
|
|
8
|
+
import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
|
|
9
|
+
import { clearRelayInfo, readRelayInfo, writeRelayInfo } from "./relayInfo.js";
|
|
10
|
+
import { loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
|
|
11
|
+
import { installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
|
|
12
|
+
import { startPreview } from "../preview/index.js";
|
|
13
|
+
export async function projectCreate(projectCwd, opts = {}) {
|
|
14
|
+
const existing = await loadProjectBinding(projectCwd);
|
|
15
|
+
if (existing) {
|
|
16
|
+
return {
|
|
17
|
+
message: `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}). Delete .vincentt/project.json to rebind.`,
|
|
18
|
+
alreadyBound: true,
|
|
19
|
+
slug: existing.slug,
|
|
20
|
+
projectId: existing.projectId,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// Resolve config first so a missing PAT fails before any clone/side effect.
|
|
24
|
+
const cfg = await resolveConfig(projectCwd);
|
|
25
|
+
let scaffolded = false;
|
|
26
|
+
if (opts.scaffold !== false && (await needsScaffold(projectCwd))) {
|
|
27
|
+
await scaffoldFromTemplate(projectCwd);
|
|
28
|
+
scaffolded = true;
|
|
29
|
+
}
|
|
30
|
+
const created = await createProject(cfg, opts.name ?? path.basename(projectCwd), opts.slug);
|
|
31
|
+
const bindingPath = await writeProjectBinding(projectCwd, {
|
|
32
|
+
projectId: created.projectId,
|
|
33
|
+
slug: created.slug,
|
|
34
|
+
});
|
|
35
|
+
let scaffoldNote = "";
|
|
36
|
+
if (scaffolded) {
|
|
37
|
+
scaffoldNote = "Scaffolded the v2-template starter into this directory.\n";
|
|
38
|
+
try {
|
|
39
|
+
await installDependencies(projectCwd);
|
|
40
|
+
scaffoldNote += "Installed dependencies (npm install).\n";
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
scaffoldNote += `Note: \`npm install\` failed (${msg}) — run it manually before preview.\n`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
message: scaffoldNote +
|
|
49
|
+
`Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
|
|
50
|
+
`Binding written to ${bindingPath} (gitignored).\n` +
|
|
51
|
+
`Build locally, then publish to go live at ${created.slug}.vincentt.app.`,
|
|
52
|
+
alreadyBound: false,
|
|
53
|
+
slug: created.slug,
|
|
54
|
+
projectId: created.projectId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function projectPublish(projectCwd, opts = {}) {
|
|
58
|
+
const binding = await loadProjectBinding(projectCwd);
|
|
59
|
+
if (!binding) {
|
|
60
|
+
return {
|
|
61
|
+
message: "No project is bound to this directory. Run `vincentt create` first.",
|
|
62
|
+
notBound: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const cfg = await resolveConfig(projectCwd);
|
|
66
|
+
const dist = path.resolve(projectCwd, opts.distDir ?? "dist");
|
|
67
|
+
const commitSha = await gitHeadSha(projectCwd);
|
|
68
|
+
const result = await publishUpload(cfg, binding.projectId, dist, {
|
|
69
|
+
note: opts.note,
|
|
70
|
+
commitSha,
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
message: `Published v${result.version}. Live at ${result.liveUrl}\nThis version: ${result.url}`,
|
|
74
|
+
notBound: false,
|
|
75
|
+
version: result.version,
|
|
76
|
+
liveUrl: result.liveUrl,
|
|
77
|
+
url: result.url,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// ---- preview (one per process) ----------------------------------------------
|
|
81
|
+
let activePreview = null;
|
|
82
|
+
let activeProjectCwd = null;
|
|
83
|
+
export function getActivePreview() {
|
|
84
|
+
return activePreview;
|
|
85
|
+
}
|
|
86
|
+
export async function previewStart(projectCwd, onLog) {
|
|
87
|
+
if (activePreview)
|
|
88
|
+
return { preview: activePreview, started: false };
|
|
89
|
+
const preview = await startPreview({ projectCwd, onLog });
|
|
90
|
+
activePreview = preview;
|
|
91
|
+
activeProjectCwd = projectCwd;
|
|
92
|
+
// Record the OS-assigned relay port so a separate `vincentt logs`/`feedback`
|
|
93
|
+
// invocation can find this preview's relay.
|
|
94
|
+
await writeRelayInfo(projectCwd, {
|
|
95
|
+
relayPort: preview.relayPort,
|
|
96
|
+
url: preview.url,
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
harnessPath: "/__harness",
|
|
99
|
+
});
|
|
100
|
+
return { preview, started: true };
|
|
101
|
+
}
|
|
102
|
+
export async function previewStop() {
|
|
103
|
+
if (!activePreview)
|
|
104
|
+
return false;
|
|
105
|
+
const preview = activePreview;
|
|
106
|
+
const cwd = activeProjectCwd;
|
|
107
|
+
activePreview = null;
|
|
108
|
+
activeProjectCwd = null;
|
|
109
|
+
await preview.stop();
|
|
110
|
+
if (cwd)
|
|
111
|
+
await clearRelayInfo(cwd);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
/** Resolve the relay client for a project's running preview, or throw a hint. */
|
|
115
|
+
async function relayForProject(projectCwd, override) {
|
|
116
|
+
if (override)
|
|
117
|
+
return override;
|
|
118
|
+
const info = await readRelayInfo(projectCwd);
|
|
119
|
+
if (!info) {
|
|
120
|
+
throw new Error("No running preview for this project — run `vincentt preview` first.");
|
|
121
|
+
}
|
|
122
|
+
return httpRelayClient(`http://localhost:${info.relayPort}`);
|
|
123
|
+
}
|
|
124
|
+
/** Raw diagnostics query against the running relay (used by CLI --json). */
|
|
125
|
+
export async function diagQuery(projectCwd, kind, opts = {}, relay) {
|
|
126
|
+
const client = await relayForProject(projectCwd, relay);
|
|
127
|
+
const q = {
|
|
128
|
+
kind,
|
|
129
|
+
sessionId: opts.sessionId,
|
|
130
|
+
since: opts.since,
|
|
131
|
+
limit: opts.limit,
|
|
132
|
+
};
|
|
133
|
+
const result = await client.query(q);
|
|
134
|
+
return kind === "log" && opts.errorsOnly ? filterErrors(result) : result;
|
|
135
|
+
}
|
|
136
|
+
/** Rendered diagnostics text (used by CLI default + MCP tools). */
|
|
137
|
+
export async function diagRead(projectCwd, kind, opts = {}, relay, now = Date.now) {
|
|
138
|
+
const result = await diagQuery(projectCwd, kind, opts, relay);
|
|
139
|
+
return renderResult(result, kind, now());
|
|
140
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface RelayInfo {
|
|
2
|
+
/** The relay's localhost HTTP/WS port (OS-assigned per preview run). */
|
|
3
|
+
relayPort: number;
|
|
4
|
+
/** The public preview URL, for display. */
|
|
5
|
+
url: string;
|
|
6
|
+
/** The preview process pid, so a stale file can be detected. */
|
|
7
|
+
pid: number;
|
|
8
|
+
/** WS/HTTP path prefix routed to the relay (default /__harness). */
|
|
9
|
+
harnessPath: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function relayInfoPath(cwd: string): string;
|
|
12
|
+
export declare function writeRelayInfo(cwd: string, info: RelayInfo): Promise<string>;
|
|
13
|
+
export declare function readRelayInfo(cwd: string): Promise<RelayInfo | undefined>;
|
|
14
|
+
export declare function clearRelayInfo(cwd: string): Promise<void>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A running preview writes its OS-assigned relay port to <project>/.vincentt/relay.json
|
|
2
|
+
// so a SEPARATE CLI invocation (`vincentt logs`, `vincentt feedback --wait`) can
|
|
3
|
+
// find the relay to talk to. Written on preview start, cleared on stop. Lives under
|
|
4
|
+
// the gitignored .vincentt/ dir, so it never rides a commit.
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
const RELAY_INFO_FILE = "relay.json";
|
|
8
|
+
const VINCENTT_DIR = ".vincentt";
|
|
9
|
+
export function relayInfoPath(cwd) {
|
|
10
|
+
return path.join(cwd, VINCENTT_DIR, RELAY_INFO_FILE);
|
|
11
|
+
}
|
|
12
|
+
export async function writeRelayInfo(cwd, info) {
|
|
13
|
+
const p = relayInfoPath(cwd);
|
|
14
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
15
|
+
await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", "utf8");
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
export async function readRelayInfo(cwd) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(await fs.readFile(relayInfoPath(cwd), "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err.code === "ENOENT")
|
|
24
|
+
return undefined;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function clearRelayInfo(cwd) {
|
|
29
|
+
try {
|
|
30
|
+
await fs.unlink(relayInfoPath(cwd));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Already gone — clearing a non-existent info file is a no-op.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface LoginOptions {
|
|
2
|
+
/** Editor base URL that serves the /oauth consent page. */
|
|
3
|
+
editorUrl: string;
|
|
4
|
+
/** Backend API base URL the minted PAT authenticates against (paired with editorUrl). */
|
|
5
|
+
apiUrl: string;
|
|
6
|
+
/** Human label stored with the token (shown in the editor's token list). */
|
|
7
|
+
tokenName: string;
|
|
8
|
+
/** Abort if the browser round-trip doesn't complete in time. Default 5 min. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Set false to skip launching a browser (tests / headless). Default true. */
|
|
11
|
+
openInBrowser?: boolean;
|
|
12
|
+
/** Called with the authorize URL once the listener is up (for a printable fallback). */
|
|
13
|
+
onUrl?: (url: string) => void;
|
|
14
|
+
}
|
|
15
|
+
/** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
|
|
16
|
+
export declare function normalizeBaseUrl(u: string): string;
|
|
17
|
+
export declare function buildAuthorizeUrl(editorBase: string, redirectUri: string, state: string, tokenName: string): string;
|
|
18
|
+
export type CallbackResult = {
|
|
19
|
+
ok: true;
|
|
20
|
+
token: string;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
/** Interpret the editor's loopback redirect. State is verified first, always. */
|
|
26
|
+
export declare function parseCallback(params: URLSearchParams, expectedState: string): CallbackResult;
|
|
27
|
+
/**
|
|
28
|
+
* Run the loopback OAuth round-trip and persist the resulting PAT. Resolves with
|
|
29
|
+
* the apiUrl written to config; rejects on cancel, state mismatch, or timeout.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runLogin(opts: LoginOptions): Promise<{
|
|
32
|
+
apiUrl: string;
|
|
33
|
+
configPath: string;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// `harness login` — obtain a Personal Access Token via the editor's /oauth flow.
|
|
2
|
+
//
|
|
3
|
+
// The CLI can't mint a PAT directly (POST /access-tokens is Cognito-only, so a
|
|
4
|
+
// machine token can never mint another). Instead it delegates to the editor:
|
|
5
|
+
// 1. Start a loopback HTTP listener on 127.0.0.1:<ephemeral port>.
|
|
6
|
+
// 2. Open the browser to <editor>/oauth?redirect_uri=…&state=…&name=… — the
|
|
7
|
+
// editor reuses its own passwordless login + a consent screen and mints the
|
|
8
|
+
// PAT on the user's behalf.
|
|
9
|
+
// 3. The editor redirects ?token=<vpat_…>&state=… back to the loopback.
|
|
10
|
+
// 4. Verify state, write the PAT to ~/.vincentt/config.json.
|
|
11
|
+
// The PAT crosses only over loopback on the user's own machine.
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { getFreePort } from "../preview/net.js";
|
|
16
|
+
import { writeMachineConfig } from "../shared/config.js";
|
|
17
|
+
/** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
|
|
18
|
+
export function normalizeBaseUrl(u) {
|
|
19
|
+
if (/^https?:\/\//i.test(u))
|
|
20
|
+
return u;
|
|
21
|
+
const local = /^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(u);
|
|
22
|
+
return `${local ? "http" : "https"}://${u}`;
|
|
23
|
+
}
|
|
24
|
+
export function buildAuthorizeUrl(editorBase, redirectUri, state, tokenName) {
|
|
25
|
+
const u = new URL("/oauth", normalizeBaseUrl(editorBase));
|
|
26
|
+
u.searchParams.set("redirect_uri", redirectUri);
|
|
27
|
+
u.searchParams.set("state", state);
|
|
28
|
+
u.searchParams.set("name", tokenName);
|
|
29
|
+
return u.toString();
|
|
30
|
+
}
|
|
31
|
+
/** Interpret the editor's loopback redirect. State is verified first, always. */
|
|
32
|
+
export function parseCallback(params, expectedState) {
|
|
33
|
+
if (params.get("state") !== expectedState) {
|
|
34
|
+
return { ok: false, message: "State mismatch — ignoring an unexpected callback." };
|
|
35
|
+
}
|
|
36
|
+
const error = params.get("error");
|
|
37
|
+
if (error) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
message: error === "access_denied" ? "Authorization was cancelled." : `Authorization failed: ${error}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const token = params.get("token");
|
|
44
|
+
if (!token)
|
|
45
|
+
return { ok: false, message: "No token in the authorization response." };
|
|
46
|
+
return { ok: true, token };
|
|
47
|
+
}
|
|
48
|
+
function escapeHtml(s) {
|
|
49
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
50
|
+
}
|
|
51
|
+
// Standalone success/failure page shown in the browser tab after the loopback
|
|
52
|
+
// round-trip. Fully self-contained (inline CSS, no assets) since it's served off
|
|
53
|
+
// the CLI's own listener; theme-aware to match the editor's sand/ink palette.
|
|
54
|
+
function browserPage(ok, heading, body) {
|
|
55
|
+
const accent = ok ? "#16a34a" : "#dc2626";
|
|
56
|
+
const glyph = ok ? "✓" : "×";
|
|
57
|
+
return `<!doctype html>
|
|
58
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Vincentt XR</title>
|
|
60
|
+
<style>
|
|
61
|
+
:root { color-scheme: light dark; }
|
|
62
|
+
* { box-sizing: border-box; }
|
|
63
|
+
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
|
64
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
65
|
+
background:#f4efe6; color:#1c1917; }
|
|
66
|
+
.card { width:min(92vw,380px); background:#fff; border:1px solid #e7e0d3; border-radius:16px;
|
|
67
|
+
padding:40px 36px; text-align:center; box-shadow:0 10px 40px rgba(0,0,0,.08); }
|
|
68
|
+
.brand { display:flex; align-items:center; justify-content:center; gap:8px; margin-bottom:26px;
|
|
69
|
+
font-weight:700; font-size:15px; letter-spacing:-.01em; }
|
|
70
|
+
.dot { width:26px; height:26px; border-radius:8px; background:linear-gradient(135deg,#7c3aed,#2563eb); }
|
|
71
|
+
.glyph { width:48px; height:48px; margin:0 auto 16px; border-radius:50%; color:#fff;
|
|
72
|
+
display:flex; align-items:center; justify-content:center; font-size:26px; line-height:1; background:${accent}; }
|
|
73
|
+
h1 { font-size:19px; margin:0 0 6px; }
|
|
74
|
+
p { font-size:13px; opacity:.7; margin:0; line-height:1.5; }
|
|
75
|
+
@media (prefers-color-scheme: dark) {
|
|
76
|
+
body { background:#1c1917; color:#f4efe6; }
|
|
77
|
+
.card { background:#292524; border-color:#3f3a36; }
|
|
78
|
+
}
|
|
79
|
+
</style></head>
|
|
80
|
+
<body><div class="card">
|
|
81
|
+
<div class="brand"><span class="dot"></span>Vincentt XR</div>
|
|
82
|
+
<div class="glyph">${glyph}</div>
|
|
83
|
+
<h1>${escapeHtml(heading)}</h1>
|
|
84
|
+
<p>${escapeHtml(body)}</p>
|
|
85
|
+
</div></body></html>`;
|
|
86
|
+
}
|
|
87
|
+
function openBrowser(url) {
|
|
88
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
89
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
90
|
+
try {
|
|
91
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
92
|
+
child.on("error", () => { });
|
|
93
|
+
child.unref();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Falls back to the printed URL (onUrl) — never fatal.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Run the loopback OAuth round-trip and persist the resulting PAT. Resolves with
|
|
101
|
+
* the apiUrl written to config; rejects on cancel, state mismatch, or timeout.
|
|
102
|
+
*/
|
|
103
|
+
export async function runLogin(opts) {
|
|
104
|
+
const state = randomBytes(16).toString("hex");
|
|
105
|
+
const port = await getFreePort();
|
|
106
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
107
|
+
const authorizeUrl = buildAuthorizeUrl(opts.editorUrl, redirectUri, state, opts.tokenName);
|
|
108
|
+
const token = await new Promise((resolve, reject) => {
|
|
109
|
+
const server = createServer((req, res) => {
|
|
110
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
111
|
+
if (url.pathname !== "/callback") {
|
|
112
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
113
|
+
res.end("Not found");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const result = parseCallback(url.searchParams, state);
|
|
117
|
+
res.writeHead(result.ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
|
|
118
|
+
res.end(result.ok
|
|
119
|
+
? browserPage(true, "You're signed in", "You can close this tab and return to your terminal.")
|
|
120
|
+
: browserPage(false, "Sign-in failed", result.message));
|
|
121
|
+
// A state-mismatch response is answered but must NOT settle the flow — a
|
|
122
|
+
// stray/forged request shouldn't tear down the listener the real callback
|
|
123
|
+
// still needs.
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
server.close();
|
|
126
|
+
resolve(result.token);
|
|
127
|
+
}
|
|
128
|
+
else if (url.searchParams.get("state") === state) {
|
|
129
|
+
server.close();
|
|
130
|
+
reject(new Error(result.message));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
server.on("error", reject);
|
|
134
|
+
server.listen(port, "127.0.0.1", () => {
|
|
135
|
+
opts.onUrl?.(authorizeUrl);
|
|
136
|
+
if (opts.openInBrowser !== false)
|
|
137
|
+
openBrowser(authorizeUrl);
|
|
138
|
+
});
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
server.close();
|
|
141
|
+
reject(new Error("Timed out waiting for browser authorization."));
|
|
142
|
+
}, opts.timeoutMs ?? 5 * 60_000);
|
|
143
|
+
timer.unref();
|
|
144
|
+
});
|
|
145
|
+
const apiUrl = opts.apiUrl.replace(/\/$/, "");
|
|
146
|
+
const configPath = await writeMachineConfig({ apiUrl, pat: token });
|
|
147
|
+
return { apiUrl, configPath };
|
|
148
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type RelayClient } from "../lib/diagnostics.js";
|
|
3
|
+
export interface McpOptions {
|
|
4
|
+
/** Base URL of the relay's HTTP endpoint (default matches the relay CLI). */
|
|
5
|
+
relayUrl?: string;
|
|
6
|
+
/** Injected for tests; defaults to an HTTP client against relayUrl. */
|
|
7
|
+
relay?: RelayClient;
|
|
8
|
+
/** Injected clock for deterministic relative-time rendering in tests. */
|
|
9
|
+
now?: () => number;
|
|
10
|
+
/** Project directory for the lifecycle verbs' binding. Defaults to process.cwd(). */
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Reap the running preview, if any. Called by preview_stop and on server exit. */
|
|
14
|
+
export declare function shutdownActivePreview(): Promise<void>;
|
|
15
|
+
export declare function createHarnessMcp(opts?: McpOptions): McpServer;
|
|
16
|
+
export declare function runStdio(opts?: McpOptions): Promise<void>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The agent-agnostic MCP server: a THIN adapter over the shared dev-loop lib
|
|
2
|
+
// (../lib/lifecycle). Any MCP-capable agent (Claude Code, Codex, Cursor, Cline,
|
|
3
|
+
// …) points its MCP config at `vincentt mcp` and gets the same verbs the CLI
|
|
4
|
+
// exposes — diagnostics PULL tools plus the create/publish/preview lifecycle.
|
|
5
|
+
// This file is only SDK wiring + argument shapes; all behavior lives in the lib,
|
|
6
|
+
// so the CLI and MCP surfaces can never diverge.
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { httpRelayClient } from "../lib/diagnostics.js";
|
|
12
|
+
import { diagRead, getActivePreview, previewStart, previewStop, projectCreate, projectPublish, } from "../lib/lifecycle.js";
|
|
13
|
+
/** Resolve a call's project dir: an explicit `projectDir` arg (absolute, or
|
|
14
|
+
* relative to the server base) overrides the base; otherwise the base is used. */
|
|
15
|
+
function resolveProjectDir(base, arg) {
|
|
16
|
+
return arg ? path.resolve(base, arg) : base;
|
|
17
|
+
}
|
|
18
|
+
const projectDirInput = {
|
|
19
|
+
projectDir: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Absolute path (or path relative to the server's working dir) of the project. Defaults to VINCENTT_PROJECT_DIR or the server's cwd. Set this when your agent spawns MCP servers outside the project directory."),
|
|
23
|
+
};
|
|
24
|
+
/** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
|
|
25
|
+
async function guard(run) {
|
|
26
|
+
try {
|
|
27
|
+
return { content: [{ type: "text", text: await run() }] };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
31
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Reap the running preview, if any. Called by preview_stop and on server exit. */
|
|
35
|
+
export async function shutdownActivePreview() {
|
|
36
|
+
await previewStop();
|
|
37
|
+
}
|
|
38
|
+
const sharedInput = {
|
|
39
|
+
sessionId: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Filter to one preview session (phone). Omit for all."),
|
|
43
|
+
since: z
|
|
44
|
+
.number()
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Return only events with seq greater than this. Use the latestSeq from a prior call to poll for new events."),
|
|
47
|
+
limit: z
|
|
48
|
+
.number()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("Cap the number of events returned (newest kept)."),
|
|
51
|
+
};
|
|
52
|
+
export function createHarnessMcp(opts = {}) {
|
|
53
|
+
// Diag tools read through this explicit client so the MCP server stays
|
|
54
|
+
// self-contained (works against a standalone relay without a relay.json file).
|
|
55
|
+
// preview_start re-points it at the live preview's relay port (OS-assigned).
|
|
56
|
+
let relayClient = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
|
|
57
|
+
const now = opts.now ?? Date.now;
|
|
58
|
+
// The project directory the lifecycle verbs operate on. Do NOT assume the agent
|
|
59
|
+
// spawned the server in it — only Claude Code reliably does; Cursor/Copilot/Codex
|
|
60
|
+
// spawn MCP servers from the editor root or home. VINCENTT_PROJECT_DIR (settable
|
|
61
|
+
// in every host's MCP config `env`) is the portable knob; a per-call `projectDir`
|
|
62
|
+
// arg overrides it.
|
|
63
|
+
const baseProjectDir = opts.cwd ?? process.env.VINCENTT_PROJECT_DIR ?? process.cwd();
|
|
64
|
+
const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
|
|
65
|
+
server.registerTool("diag_logs", {
|
|
66
|
+
description: "Read console logs the preview app produced on the device. Use `errorsOnly` to see just errors, `since` to poll for new logs. Replaces the user pasting console output.",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
...sharedInput,
|
|
69
|
+
errorsOnly: z.boolean().optional().describe("Return only error-level logs."),
|
|
70
|
+
},
|
|
71
|
+
}, async ({ sessionId, since, limit, errorsOnly }) => ({
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: "text",
|
|
75
|
+
text: await diagRead(baseProjectDir, "log", { sessionId, since, limit, errorsOnly }, relayClient, now),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
}));
|
|
79
|
+
server.registerTool("diag_network", {
|
|
80
|
+
description: "Read fetch/XHR requests the preview app made on the device (method, URL, status, duration). Use to find failed asset loads or slow calls without DevTools.",
|
|
81
|
+
inputSchema: sharedInput,
|
|
82
|
+
}, async ({ sessionId, since, limit }) => ({
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: await diagRead(baseProjectDir, "network", { sessionId, since, limit }, relayClient, now),
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
}));
|
|
90
|
+
server.registerTool("diag_trace", {
|
|
91
|
+
description: "Read performance samples from the device (fps, longest main-thread task, long-task count, phase marks). Replaces hand-exporting a DevTools trace to spot jank.",
|
|
92
|
+
inputSchema: sharedInput,
|
|
93
|
+
}, async ({ sessionId, since, limit }) => ({
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: "text",
|
|
97
|
+
text: await diagRead(baseProjectDir, "trace", { sessionId, since, limit }, relayClient, now),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
}));
|
|
101
|
+
server.registerTool("project_create", {
|
|
102
|
+
description: "Create a Vincentt project this working directory publishes to. If the directory has no app yet, it scaffolds the v2-template starter into it (like GitHub's Use-this-template); then it writes a local .vincentt/project.json binding and reserves <slug>.vincentt.app. Ask the user for a project name AND a slug before calling; pass what they give. Omit either to accept a default — name = folder name, slug = a platform-assigned catchy one. The slug is the permanent public subdomain (locked once published), so confirm it with the user. Run once per new app; publish with project_publish to go live.",
|
|
103
|
+
inputSchema: {
|
|
104
|
+
name: z
|
|
105
|
+
.string()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Display name for the project. Defaults to the working-directory name."),
|
|
108
|
+
slug: z
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Desired subdomain slug (<slug>.vincentt.app), lowercase kebab-case. Omit to let the platform assign one. Permanent once published."),
|
|
112
|
+
scaffold: z
|
|
113
|
+
.boolean()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("Whether to scaffold the v2-template starter when the directory has no app. Defaults to true; set false to bind an existing/empty directory without cloning."),
|
|
116
|
+
...projectDirInput,
|
|
117
|
+
},
|
|
118
|
+
}, async ({ name, slug, scaffold, projectDir }) => guard(async () => {
|
|
119
|
+
const result = await projectCreate(resolveProjectDir(baseProjectDir, projectDir), {
|
|
120
|
+
name,
|
|
121
|
+
slug,
|
|
122
|
+
scaffold,
|
|
123
|
+
});
|
|
124
|
+
return result.message;
|
|
125
|
+
}));
|
|
126
|
+
server.registerTool("project_publish", {
|
|
127
|
+
description: "Publish the locally-built dist so this project goes live at <slug>.vincentt.app. Build first (e.g. npm run build), then call this — the dist is uploaded as-is and the server never rebuilds. Requires a prior project_create binding.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
distDir: z
|
|
130
|
+
.string()
|
|
131
|
+
.optional()
|
|
132
|
+
.describe("Path to the built dist directory, relative to the project (default: dist)."),
|
|
133
|
+
note: z.string().optional().describe("Optional release note recorded with the version."),
|
|
134
|
+
...projectDirInput,
|
|
135
|
+
},
|
|
136
|
+
}, async ({ distDir, note, projectDir }) => guard(async () => {
|
|
137
|
+
const result = await projectPublish(resolveProjectDir(baseProjectDir, projectDir), {
|
|
138
|
+
distDir,
|
|
139
|
+
note,
|
|
140
|
+
});
|
|
141
|
+
return result.message;
|
|
142
|
+
}));
|
|
143
|
+
server.registerTool("preview_start", {
|
|
144
|
+
description: "Start a live on-device preview: serves the app, opens a public https tunnel, and wires the diagnostics relay so diag_logs/diag_network/diag_trace read from the connected device. Returns the URL to open on a phone. Requires a project_create binding; cloudflared is auto-provisioned if missing. One preview at a time — call preview_stop before starting another. The dev server keeps running until preview_stop, so start it once and iterate.",
|
|
145
|
+
inputSchema: { ...projectDirInput },
|
|
146
|
+
}, async ({ projectDir }) => guard(async () => {
|
|
147
|
+
if (getActivePreview()) {
|
|
148
|
+
return `A preview is already running:\n${getActivePreview().url}\nCall preview_stop first to restart.`;
|
|
149
|
+
}
|
|
150
|
+
// App stdout would corrupt the MCP channel; route harness progress to stderr.
|
|
151
|
+
const { preview } = await previewStart(resolveProjectDir(baseProjectDir, projectDir), (m) => console.error(`[preview] ${m}`));
|
|
152
|
+
// Point the diag tools at this preview's relay (its port is OS-assigned).
|
|
153
|
+
relayClient = httpRelayClient(`http://localhost:${preview.relayPort}`);
|
|
154
|
+
const readiness = preview.appReady
|
|
155
|
+
? ""
|
|
156
|
+
: `\nNote: the app on :${preview.appPort} isn't responding yet — the URL may be blank until its build finishes. Check diag_logs.`;
|
|
157
|
+
return (`Live preview running — open on your device:\n${preview.url}\n\n` +
|
|
158
|
+
`Diagnostics are live: diag_logs / diag_network / diag_trace now read from this device.\n` +
|
|
159
|
+
`Call preview_stop when finished.${readiness}`);
|
|
160
|
+
}));
|
|
161
|
+
server.registerTool("preview_stop", {
|
|
162
|
+
description: "Stop the running preview: tears down the tunnel (and its DNS route), the app dev server, and the diagnostics relay. Safe to call when nothing is running.",
|
|
163
|
+
inputSchema: {},
|
|
164
|
+
}, async () => guard(async () => {
|
|
165
|
+
const stopped = await previewStop();
|
|
166
|
+
if (!stopped)
|
|
167
|
+
return "No preview is running.";
|
|
168
|
+
// The preview's relay is gone; send diag reads back to the default.
|
|
169
|
+
relayClient = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
|
|
170
|
+
return "Preview stopped. Tunnel and dev server torn down.";
|
|
171
|
+
}));
|
|
172
|
+
return server;
|
|
173
|
+
}
|
|
174
|
+
export async function runStdio(opts = {}) {
|
|
175
|
+
const server = createHarnessMcp(opts);
|
|
176
|
+
const transport = new StdioServerTransport();
|
|
177
|
+
await server.connect(transport);
|
|
178
|
+
// A running preview owns a child process + a live backend tunnel; reap both when
|
|
179
|
+
// the agent disconnects so we never leak a tunnel/DNS route past the session.
|
|
180
|
+
const shutdown = async () => {
|
|
181
|
+
await shutdownActivePreview();
|
|
182
|
+
process.exit(0);
|
|
183
|
+
};
|
|
184
|
+
process.on("SIGINT", shutdown);
|
|
185
|
+
process.on("SIGTERM", shutdown);
|
|
186
|
+
// stdout is the MCP channel — status goes to stderr only.
|
|
187
|
+
console.error("[vincentt mcp] connected over stdio");
|
|
188
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface EnsureCloudflaredOptions {
|
|
2
|
+
/** Progress sink for the one-time download (it is ~40MB and takes a few seconds). */
|
|
3
|
+
onLog?: (message: string) => void;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Return a spawnable cloudflared command/path, provisioning one if the host has
|
|
7
|
+
* none. Resolves to `"cloudflared"` when a system install is on PATH, else to the
|
|
8
|
+
* `cloudflared` package's managed binary (already fetched by its postinstall, or
|
|
9
|
+
* downloaded here on demand when install scripts were skipped). Throws only if the
|
|
10
|
+
* download itself fails (offline / blocked egress) — surface that to the user
|
|
11
|
+
* with the manual `brew install cloudflared` fallback.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureCloudflared(opts?: EnsureCloudflaredOptions): Promise<string>;
|