@vincentt-xr/harness 0.3.0 → 0.4.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,55 @@
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
+ });
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 ? "&check;" : "&times;";
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
+ }
@@ -23,6 +23,17 @@ export async function shutdownActivePreview() {
23
23
  activePreview = null;
24
24
  await preview.stop();
25
25
  }
26
+ /** Resolve a call's project dir: an explicit `projectDir` arg (absolute, or
27
+ * relative to the server base) overrides the base; otherwise the base is used. */
28
+ function resolveProjectDir(base, arg) {
29
+ return arg ? path.resolve(base, arg) : base;
30
+ }
31
+ const projectDirInput = {
32
+ projectDir: z
33
+ .string()
34
+ .optional()
35
+ .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."),
36
+ };
26
37
  /** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
27
38
  async function guard(run) {
28
39
  try {
@@ -88,9 +99,12 @@ export function createHarnessMcp(opts = {}) {
88
99
  const result = await relayClient.query({ kind: "trace", sessionId, since, limit });
89
100
  return { content: [{ type: "text", text: renderResult(result, "trace", now()) }] };
90
101
  });
91
- // The cwd the agent spawned this server in IS the creator's project directory —
92
- // where the .vincentt/project.json binding is read and written.
93
- const projectCwd = opts.cwd ?? process.cwd();
102
+ // The project directory the lifecycle verbs operate on. Do NOT assume the agent
103
+ // spawned the server in it only Claude Code reliably does; Cursor/Copilot/Codex
104
+ // spawn MCP servers from the editor root or home. VINCENTT_PROJECT_DIR (settable
105
+ // in every host's MCP config `env`) is the portable knob; a per-call `projectDir`
106
+ // arg overrides it. See docs/harness-agent-agnostic.md.
107
+ const baseProjectDir = opts.cwd ?? process.env.VINCENTT_PROJECT_DIR ?? process.cwd();
94
108
  server.registerTool("project_create", {
95
109
  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.",
96
110
  inputSchema: {
@@ -106,8 +120,10 @@ export function createHarnessMcp(opts = {}) {
106
120
  .boolean()
107
121
  .optional()
108
122
  .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."),
123
+ ...projectDirInput,
109
124
  },
110
- }, async ({ name, slug, scaffold }) => guard(async () => {
125
+ }, async ({ name, slug, scaffold, projectDir }) => guard(async () => {
126
+ const projectCwd = resolveProjectDir(baseProjectDir, projectDir);
111
127
  const existing = await loadProjectBinding(projectCwd);
112
128
  if (existing) {
113
129
  return `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}). Delete .vincentt/project.json to rebind.`;
@@ -154,8 +170,10 @@ export function createHarnessMcp(opts = {}) {
154
170
  .string()
155
171
  .optional()
156
172
  .describe("Optional release note recorded with the version."),
173
+ ...projectDirInput,
157
174
  },
158
- }, async ({ distDir, note }) => guard(async () => {
175
+ }, async ({ distDir, note, projectDir }) => guard(async () => {
176
+ const projectCwd = resolveProjectDir(baseProjectDir, projectDir);
159
177
  const binding = await loadProjectBinding(projectCwd);
160
178
  if (!binding) {
161
179
  return "No project is bound to this directory. Run project_create first.";
@@ -171,15 +189,15 @@ export function createHarnessMcp(opts = {}) {
171
189
  }));
172
190
  server.registerTool("preview_start", {
173
191
  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.",
174
- inputSchema: {},
175
- }, async () => guard(async () => {
192
+ inputSchema: { ...projectDirInput },
193
+ }, async ({ projectDir }) => guard(async () => {
176
194
  if (activePreview) {
177
195
  return `A preview is already running:\n${activePreview.url}\nCall preview_stop first to restart.`;
178
196
  }
179
197
  // App stdout would corrupt the MCP channel; keep it off stdout entirely and
180
198
  // route harness progress to stderr.
181
199
  activePreview = await startPreview({
182
- projectCwd,
200
+ projectCwd: resolveProjectDir(baseProjectDir, projectDir),
183
201
  onLog: (m) => console.error(`[preview] ${m}`),
184
202
  });
185
203
  // Point the diag tools at this preview's relay (its port is OS-assigned).
@@ -31,7 +31,16 @@ export async function ensureCloudflared(opts = {}) {
31
31
  const log = opts.onLog ?? (() => undefined);
32
32
  log("cloudflared not found — downloading a one-time copy…");
33
33
  await mkdir(path.dirname(cloudflared.bin), { recursive: true });
34
- await cloudflared.install(cloudflared.bin);
34
+ // The download may print progress. On an MCP stdio server, ANY stdout write
35
+ // corrupts the JSON-RPC stream — redirect stdout→stderr for its duration.
36
+ const realWrite = process.stdout.write.bind(process.stdout);
37
+ process.stdout.write = process.stderr.write.bind(process.stderr);
38
+ try {
39
+ await cloudflared.install(cloudflared.bin);
40
+ }
41
+ finally {
42
+ process.stdout.write = realWrite;
43
+ }
35
44
  log(`cloudflared ready at ${cloudflared.bin}`);
36
45
  return cloudflared.bin;
37
46
  }
@@ -18,7 +18,9 @@ export interface StartPreviewOptions {
18
18
  appStdio?: "ignore" | "inherit";
19
19
  /** How long to wait for cloudflared to register before giving up (default 45s). */
20
20
  registerTimeoutMs?: number;
21
- /** How long to wait for the app to serve before returning anyway (default 20s). */
21
+ /** How long to wait for the app to serve before returning anyway (default 8s).
22
+ * Kept short so an MCP tool call stays within strict clients' ~30s timeout;
23
+ * a not-yet-ready app still returns (appReady=false), it isn't an error. */
22
24
  appReadyTimeoutMs?: number;
23
25
  /** Progress sink (relay/cloudflared lines). Never write app stdout to MCP stdout. */
24
26
  onLog?: (message: string) => void;
@@ -16,7 +16,7 @@ import { startSessionTunnel } from "./tunnel.js";
16
16
  import { proxyWeb, proxyWs } from "./proxy.js";
17
17
  import { getFreePort, isPortListening, waitForApp } from "./net.js";
18
18
  export async function startPreview(opts) {
19
- const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 20_000, onLog = () => undefined, } = opts;
19
+ const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
20
20
  // Resolve ports. The app port defaults to 5173; the relay + front ports are
21
21
  // harness-internal, so auto-pick free ones (the relay port is returned for the
22
22
  // MCP diag_* tools to point at) instead of colliding on fixed defaults.
@@ -1,4 +1,4 @@
1
- export declare const TEMPLATE_REPO = "git@github.com:vincentt-xr/v2-template.git";
1
+ export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
2
2
  /** Injectable git runner (real git by default; a fake in tests avoids the network). */
3
3
  export type GitRunner = (args: string[]) => Promise<void>;
4
4
  /**
@@ -9,7 +9,7 @@ import os from "node:os";
9
9
  import path from "node:path";
10
10
  import { promisify } from "node:util";
11
11
  const execFileAsync = promisify(execFile);
12
- export const TEMPLATE_REPO = "git@github.com:vincentt-xr/v2-template.git";
12
+ export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
13
13
  const realGit = async (args) => {
14
14
  await execFileAsync("git", args);
15
15
  };
@@ -15,6 +15,12 @@ export interface ProjectBinding {
15
15
  export declare const MACHINE_CONFIG_PATH: string;
16
16
  export declare function projectBindingPath(cwd: string): string;
17
17
  export declare function loadMachineConfig(): Promise<Partial<MachineConfig>>;
18
+ /**
19
+ * Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
20
+ * a freshly minted PAT). Preserves any other fields already present. The file
21
+ * holds a bearer credential, so the dir/file get owner-only perms.
22
+ */
23
+ export declare function writeMachineConfig(patch: Partial<MachineConfig>): Promise<string>;
18
24
  export declare function loadProjectBinding(cwd: string): Promise<ProjectBinding | undefined>;
19
25
  /**
20
26
  * Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
@@ -29,6 +29,20 @@ async function readJsonIfExists(p) {
29
29
  export async function loadMachineConfig() {
30
30
  return (await readJsonIfExists(MACHINE_CONFIG_PATH)) ?? {};
31
31
  }
32
+ /**
33
+ * Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
34
+ * a freshly minted PAT). Preserves any other fields already present. The file
35
+ * holds a bearer credential, so the dir/file get owner-only perms.
36
+ */
37
+ export async function writeMachineConfig(patch) {
38
+ const next = { ...(await loadMachineConfig()), ...patch };
39
+ await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
40
+ await fs.writeFile(MACHINE_CONFIG_PATH, JSON.stringify(next, null, 2) + "\n", {
41
+ encoding: "utf8",
42
+ mode: 0o600,
43
+ });
44
+ return MACHINE_CONFIG_PATH;
45
+ }
32
46
  export async function loadProjectBinding(cwd) {
33
47
  return readJsonIfExists(projectBindingPath(cwd));
34
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/harness",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Vincentt AR dev-loop harness — in-app diagnostics client + relay + agent-agnostic MCP server",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  }
27
27
  },
28
28
  "bin": {
29
+ "harness": "./dist/cli/index.js",
29
30
  "harness-relay": "./dist/relay/cli.js",
30
31
  "harness-mcp": "./dist/mcp/cli.js"
31
32
  },