@vincentt-xr/cli 0.1.4 → 0.2.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.
Files changed (72) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +13 -0
  3. package/dist/cli/args.js +41 -0
  4. package/dist/cli/index.js +362 -92
  5. package/dist/lib/backend.d.ts +45 -5
  6. package/dist/lib/backend.js +99 -18
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +73 -15
  12. package/dist/lib/lifecycle.js +111 -114
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +47 -20
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/relay/server.d.ts +32 -0
  51. package/dist/relay/server.js +80 -12
  52. package/dist/relay/store.d.ts +32 -3
  53. package/dist/relay/store.js +76 -22
  54. package/dist/relay/testers.d.ts +90 -0
  55. package/dist/relay/testers.js +144 -0
  56. package/dist/shared/authError.d.ts +1 -0
  57. package/dist/shared/authError.js +11 -0
  58. package/dist/shared/config.d.ts +73 -5
  59. package/dist/shared/config.js +133 -20
  60. package/package.json +3 -7
  61. package/dist/cli/deprecated/harness-mcp.d.ts +0 -2
  62. package/dist/cli/deprecated/harness-mcp.js +0 -7
  63. package/dist/cli/deprecated/harness-relay.d.ts +0 -2
  64. package/dist/cli/deprecated/harness-relay.js +0 -7
  65. package/dist/cli/deprecated/harness.d.ts +0 -2
  66. package/dist/cli/deprecated/harness.js +0 -7
  67. package/dist/login/login.d.ts +0 -34
  68. package/dist/login/login.js +0 -148
  69. package/dist/preview/cloudflared.d.ts +0 -13
  70. package/dist/preview/cloudflared.js +0 -46
  71. package/dist/preview/tunnel.d.ts +0 -14
  72. package/dist/preview/tunnel.js +0 -28
@@ -0,0 +1,72 @@
1
+ // `vincentt login` transcript orchestration — the L1–L4 flows from the f1 design's
2
+ // CLI section, using the design-system CLI voice (→ in-progress, ✓ success, ✗ failure;
3
+ // never prompt for an org; one success line names identity + active org; every error ends
4
+ // with the exact next command; a token is NEVER printed; no stack traces).
5
+ //
6
+ // The IO boundary (print, current identity, run the PKCE flow, clock) is injected so every
7
+ // L1–L4 branch is unit-testable with no real browser, socket, or config file.
8
+ /** Collapse the home dir to `~` for display. `~/.vincentt/config.json` in every transcript. */
9
+ export function displayConfigPath(absPath, home) {
10
+ if (home && absPath.startsWith(home))
11
+ return `~${absPath.slice(home.length)}`;
12
+ return absPath;
13
+ }
14
+ /** The active org is the personal one (login lands on personal; the CLI never prompts). */
15
+ export function activeOrg(memberships) {
16
+ return memberships.find((m) => m.orgKind === "personal") ?? memberships[0];
17
+ }
18
+ /**
19
+ * The success line's org clause: "Active org: <personal>" plus, when the user belongs to
20
+ * team orgs too, "· also a member of: A, B". A platform-invited creator with only a personal
21
+ * org gets just the active-org line (no trailing clause).
22
+ */
23
+ export function orgSummaryLine(identity) {
24
+ const active = activeOrg(identity.memberships);
25
+ const activeName = active?.orgName ?? "Personal";
26
+ const others = identity.memberships
27
+ .filter((m) => m !== active)
28
+ .map((m) => m.orgName)
29
+ .filter(Boolean);
30
+ const base = ` Active org: ${activeName}`;
31
+ return others.length ? `${base} · also a member of: ${others.join(", ")}` : base;
32
+ }
33
+ /**
34
+ * Drive `vincentt login`. Returns an exit code (0 ok, 1 failed). Never throws for expected
35
+ * failure states (timeout, cancel, state-mismatch) — they print a ✗ line ending with the
36
+ * exact retry command and return 1. No stack trace, no token printed.
37
+ */
38
+ export async function loginCommand(deps, opts = {}) {
39
+ const displayPath = displayConfigPath(deps.configPath, deps.home);
40
+ if (opts.force) {
41
+ deps.print("→ Replacing the existing token on this machine…");
42
+ await deps.clearToken();
43
+ }
44
+ else {
45
+ // L2 — already signed in: no browser, no re-mint.
46
+ const existing = await deps.currentToken();
47
+ if (existing) {
48
+ const who = await deps.whoAmI().catch(() => undefined);
49
+ const email = who?.email ?? "your account";
50
+ deps.print(`✓ Already signed in as ${email} (token in ${displayPath})`);
51
+ deps.print(" Re-run with `vincentt login --force` to sign in as someone else.");
52
+ return 0;
53
+ }
54
+ }
55
+ // L1 / L4 — run the loopback + PKCE flow.
56
+ try {
57
+ const { identity, configPath } = await deps.runLogin();
58
+ deps.print(`✓ Signed in as ${identity.email}`);
59
+ deps.print(orgSummaryLine(identity));
60
+ deps.print(` Token saved to ${displayConfigPath(configPath, deps.home)}`);
61
+ deps.print("");
62
+ deps.print("Run `vincentt --help` to see what you can do.");
63
+ return 0;
64
+ }
65
+ catch (err) {
66
+ // L4 — timeout / callback failure: ✗ line + the exact next command. No token written.
67
+ const message = err instanceof Error ? err.message : "Sign-in failed.";
68
+ deps.print(`✗ ${message}`);
69
+ deps.print(" Run `vincentt login` to try again.");
70
+ return 1;
71
+ }
72
+ }
@@ -0,0 +1,47 @@
1
+ /** The PKCE + state material minted once per login attempt. */
2
+ export interface PkceMaterial {
3
+ /** Opaque anti-CSRF token echoed back on the loopback callback; verified constant-time. */
4
+ state: string;
5
+ /** The secret the CLI keeps; sent to /exchange. Never leaves the machine until then. */
6
+ codeVerifier: string;
7
+ /** base64url(SHA-256(codeVerifier)); sent to the browser/console, binds the exchange. */
8
+ codeChallenge: string;
9
+ }
10
+ /** SHA-256 → base64url. Exported for the exchange-binding assertion in tests. */
11
+ export declare function s256(input: string): string;
12
+ /**
13
+ * Mint fresh `state` + PKCE `code_verifier`/`code_challenge` (S256). The verifier is a
14
+ * high-entropy 32-byte value (well within RFC 7636's 43–128 char range once base64url'd).
15
+ */
16
+ export declare function makePkceMaterial(): PkceMaterial;
17
+ /**
18
+ * Constant-time equality for the returned `state` vs the expected one. Length-mismatched
19
+ * inputs return false without leaking timing (timingSafeEqual throws on unequal lengths).
20
+ */
21
+ export declare function stateMatches(expected: string, actual: string | null): boolean;
22
+ /** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
23
+ export declare function normalizeBaseUrl(u: string): string;
24
+ /**
25
+ * The console URL the browser opens: `/cli-auth?redirect_uri=<loopback>&state=…&code_challenge=…`.
26
+ * The console signs the user in (passwordless), shows the Grant CLI access consent screen,
27
+ * POSTs /access-tokens {code_challenge, state}, and redirects the resulting auth_code to the
28
+ * loopback. The code_verifier is NOT in this URL — only its challenge.
29
+ */
30
+ export declare function buildCliAuthUrl(consoleBase: string, redirectUri: string, material: PkceMaterial): string;
31
+ /** A compact, human-copyable fallback URL for the "if it doesn't open" line. */
32
+ export declare function shortenUrl(url: string, max?: number): string;
33
+ export type CallbackResult = {
34
+ ok: true;
35
+ code: string;
36
+ } | {
37
+ ok: false;
38
+ reason: "state_mismatch" | "denied" | "missing_code";
39
+ message: string;
40
+ };
41
+ /**
42
+ * Interpret the loopback redirect (`127.0.0.1:PORT?code=…&state=…`). State is verified
43
+ * constant-time FIRST, always — a mismatch rejects before the code is even read, so a
44
+ * forged/stray callback can never drive an exchange (QA-F1-C3). The console signals a
45
+ * user cancel via `?error=access_denied`.
46
+ */
47
+ export declare function parseCallback(params: URLSearchParams, expectedState: string): CallbackResult;
@@ -0,0 +1,106 @@
1
+ // PKCE + loopback protocol primitives for `vincentt login` (f1 account/org model).
2
+ //
3
+ // The durable PAT is NEVER placed in a redirect query string. The browser round-trip
4
+ // carries only a single-use, short-TTL `auth_code`; the CLI redeems it over a POST to
5
+ // /access-tokens/exchange with a PKCE `code_verifier`, and the raw PAT is returned in the
6
+ // response body, once. A stolen redirect (?code=…) is useless without the CLI's verifier.
7
+ //
8
+ // These are the pure, side-effect-free pieces (crypto + URL shaping + constant-time
9
+ // state compare) split out from the loopback/HTTP orchestration in pkceLogin.ts so every
10
+ // branch is unit-testable without a browser or a socket.
11
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
12
+ /** base64url with no padding (RFC 7636 §A). */
13
+ function base64url(buf) {
14
+ return buf
15
+ .toString("base64")
16
+ .replace(/\+/g, "-")
17
+ .replace(/\//g, "_")
18
+ .replace(/=+$/, "");
19
+ }
20
+ /** SHA-256 → base64url. Exported for the exchange-binding assertion in tests. */
21
+ export function s256(input) {
22
+ return base64url(createHash("sha256").update(input).digest());
23
+ }
24
+ /**
25
+ * Mint fresh `state` + PKCE `code_verifier`/`code_challenge` (S256). The verifier is a
26
+ * high-entropy 32-byte value (well within RFC 7636's 43–128 char range once base64url'd).
27
+ */
28
+ export function makePkceMaterial() {
29
+ const state = base64url(randomBytes(24));
30
+ const codeVerifier = base64url(randomBytes(32));
31
+ return { state, codeVerifier, codeChallenge: s256(codeVerifier) };
32
+ }
33
+ /**
34
+ * Constant-time equality for the returned `state` vs the expected one. Length-mismatched
35
+ * inputs return false without leaking timing (timingSafeEqual throws on unequal lengths).
36
+ */
37
+ export function stateMatches(expected, actual) {
38
+ if (actual == null)
39
+ return false;
40
+ const a = Buffer.from(expected);
41
+ const b = Buffer.from(actual);
42
+ if (a.length !== b.length)
43
+ return false;
44
+ return timingSafeEqual(a, b);
45
+ }
46
+ /** Prefix a scheme when the user passed a bare host; localhost defaults to http. */
47
+ export function normalizeBaseUrl(u) {
48
+ if (/^https?:\/\//i.test(u))
49
+ return u;
50
+ const local = /^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(u);
51
+ return `${local ? "http" : "https"}://${u}`;
52
+ }
53
+ /**
54
+ * The console URL the browser opens: `/cli-auth?redirect_uri=<loopback>&state=…&code_challenge=…`.
55
+ * The console signs the user in (passwordless), shows the Grant CLI access consent screen,
56
+ * POSTs /access-tokens {code_challenge, state}, and redirects the resulting auth_code to the
57
+ * loopback. The code_verifier is NOT in this URL — only its challenge.
58
+ */
59
+ export function buildCliAuthUrl(consoleBase, redirectUri, material) {
60
+ const u = new URL("/cli-auth", normalizeBaseUrl(consoleBase));
61
+ // Param name is `redirect_uri` (OAuth convention) — the console's parseCliParams reads
62
+ // exactly this; `state` + `code_challenge` also match the console contract. The verifier
63
+ // is NEVER put here.
64
+ u.searchParams.set("redirect_uri", redirectUri);
65
+ u.searchParams.set("state", material.state);
66
+ u.searchParams.set("code_challenge", material.codeChallenge);
67
+ return u.toString();
68
+ }
69
+ /** A compact, human-copyable fallback URL for the "if it doesn't open" line. */
70
+ export function shortenUrl(url, max = 60) {
71
+ return url.length <= max ? url : `${url.slice(0, max - 1)}…`;
72
+ }
73
+ /**
74
+ * Interpret the loopback redirect (`127.0.0.1:PORT?code=…&state=…`). State is verified
75
+ * constant-time FIRST, always — a mismatch rejects before the code is even read, so a
76
+ * forged/stray callback can never drive an exchange (QA-F1-C3). The console signals a
77
+ * user cancel via `?error=access_denied`.
78
+ */
79
+ export function parseCallback(params, expectedState) {
80
+ if (!stateMatches(expectedState, params.get("state"))) {
81
+ return {
82
+ ok: false,
83
+ reason: "state_mismatch",
84
+ message: "State mismatch — ignoring an unexpected callback.",
85
+ };
86
+ }
87
+ const error = params.get("error");
88
+ if (error) {
89
+ return {
90
+ ok: false,
91
+ reason: "denied",
92
+ message: error === "access_denied"
93
+ ? "Access was cancelled in the browser."
94
+ : `Sign-in failed: ${error}`,
95
+ };
96
+ }
97
+ const code = params.get("code");
98
+ if (!code) {
99
+ return {
100
+ ok: false,
101
+ reason: "missing_code",
102
+ message: "No authorization code in the callback.",
103
+ };
104
+ }
105
+ return { ok: true, code };
106
+ }
@@ -0,0 +1,53 @@
1
+ import { type AuthBackend, type Identity } from "./authBackend.js";
2
+ export interface PkceLoginOptions {
3
+ /** Console origin that serves /cli-auth (browser target). */
4
+ consoleUrl: string;
5
+ /** Backend API base URL the PAT authenticates against. */
6
+ apiUrl: string;
7
+ /** Abort if the browser round-trip doesn't complete in time. Default 5 min. */
8
+ timeoutMs?: number;
9
+ /** Backend calls (exchange + me). Default: real HTTP against apiUrl. */
10
+ backend?: AuthBackend;
11
+ /** Pick a free loopback port. Default: the OS ephemeral picker. */
12
+ pickPort?: () => Promise<number>;
13
+ /** Launch the browser. Default: spawn the platform opener. Tests pass a capturing fake. */
14
+ openBrowser?: (url: string) => void;
15
+ /** Persist the PAT. Default: write ~/.vincentt/config.json. */
16
+ saveToken?: (apiUrl: string, pat: string) => Promise<string>;
17
+ /** Emit an in-progress line (the "→ Opening…" / fallback / "Waiting…" narration). */
18
+ onProgress?: (line: string) => void;
19
+ }
20
+ export interface PkceLoginResult {
21
+ identity: Identity;
22
+ configPath: string;
23
+ }
24
+ /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
25
+ export declare class LoginTimeoutError extends Error {
26
+ constructor();
27
+ }
28
+ /** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
29
+ export declare class LoginCallbackError extends Error {
30
+ constructor(message: string);
31
+ }
32
+ /** The platform command + args to open `url` in the default browser. */
33
+ export declare function browserOpenCommand(url: string, platform?: string): {
34
+ cmd: string;
35
+ args: string[];
36
+ };
37
+ type SpawnLike = (cmd: string, args: string[], opts: {
38
+ stdio: "ignore";
39
+ detached: boolean;
40
+ }) => {
41
+ on: (e: "error", cb: () => void) => void;
42
+ unref: () => void;
43
+ };
44
+ /** Launch the browser via an injected spawn; a throw is swallowed (fallback = the printed URL). */
45
+ export declare function launchBrowser(url: string, spawn: SpawnLike, platform?: string): void;
46
+ /**
47
+ * Run the loopback + PKCE exchange, persist the PAT, and confirm identity via /me.
48
+ * Resolves with the confirmed identity + the config path; rejects with LoginTimeoutError
49
+ * or LoginCallbackError. NO token is written on any failure (the write only happens after
50
+ * a successful exchange).
51
+ */
52
+ export declare function runPkceLogin(opts: PkceLoginOptions): Promise<PkceLoginResult>;
53
+ export {};
@@ -0,0 +1,152 @@
1
+ // The `vincentt login` loopback + PKCE code-exchange orchestration (f1 Scenario 4).
2
+ //
3
+ // Ordering is security-load-bearing:
4
+ // 1. Start the loopback listener on 127.0.0.1:<ephemeral> FIRST (before the browser).
5
+ // 2. Mint state + PKCE code_verifier/code_challenge (S256).
6
+ // 3. Open the browser to /cli-auth?redirect_uri=<loopback>&state=…&code_challenge=….
7
+ // 4. On the loopback callback: verify state constant-time (reject mismatch → no exchange).
8
+ // 5. POST /access-tokens/exchange {code, code_verifier} → raw PAT (in body, once).
9
+ // 6. Write ~/.vincentt/config.json, then GET /me Bearer <PAT> to confirm identity + orgs.
10
+ //
11
+ // Every external boundary is injected (getFreePort, listen, openBrowser, backend, clock/
12
+ // timeout, config write) so the whole flow is unit-testable against a stub backend + fake
13
+ // browser + temp config dir, with no real socket or network.
14
+ import { createServer } from "node:http";
15
+ import { getFreePort } from "../preview/net.js";
16
+ import { writeMachineConfig } from "../shared/config.js";
17
+ import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
18
+ import { httpAuthBackend } from "./authBackend.js";
19
+ /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
20
+ export class LoginTimeoutError extends Error {
21
+ constructor() {
22
+ super("Sign-in timed out (no approval within 5 minutes).");
23
+ this.name = "LoginTimeoutError";
24
+ }
25
+ }
26
+ /** Thrown when the loopback callback fails state-verify or is cancelled (QA-F1-C3). */
27
+ export class LoginCallbackError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = "LoginCallbackError";
31
+ }
32
+ }
33
+ /** The platform command + args to open `url` in the default browser. */
34
+ export function browserOpenCommand(url, platform = process.platform) {
35
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
36
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
37
+ return { cmd, args };
38
+ }
39
+ /** Launch the browser via an injected spawn; a throw is swallowed (fallback = the printed URL). */
40
+ export function launchBrowser(url, spawn, platform) {
41
+ const { cmd, args } = browserOpenCommand(url, platform);
42
+ try {
43
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
44
+ child.on("error", () => { });
45
+ child.unref();
46
+ }
47
+ catch {
48
+ /* falls back to the printed URL — never fatal */
49
+ }
50
+ }
51
+ function defaultOpenBrowser(url) {
52
+ // Lazy import keeps this module importable in tests that never open a browser.
53
+ void import("node:child_process").then(({ spawn }) => launchBrowser(url, spawn));
54
+ }
55
+ // The loopback success/failure page. This is a standalone HTTP response served on
56
+ // 127.0.0.1 (it cannot import the console's React design system), so the console's
57
+ // AuthLayout auth-card is mirrored here token-for-token: --color-sand page, white
58
+ // --color-surface card (rounded-2xl, p-7), the terminal glyph on --color-primary-faint,
59
+ // and the light/dark palette. A subtle green/red tints only the glyph on ok/failure.
60
+ function browserPage(ok, heading, body) {
61
+ // The terminal glyph the console uses on the Grant-CLI screen (icons.terminal).
62
+ const glyph = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
63
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
64
+ '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
65
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
66
+ // Glyph tint: primary on success, danger on failure — everything else stays on-brand.
67
+ const glyphColorLight = ok ? "#1E4492" : "#dc2626";
68
+ const glyphColorDark = ok ? "#5a8fd8" : "#f2a4a0";
69
+ const glyphBgLight = ok ? "#EBF6FD" : "#fbeaea";
70
+ const glyphBgDark = ok ? "#0d1a2e" : "#3a1f1f";
71
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
72
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>Vincentt</title>
73
+ <style>:root{color-scheme:light dark;--sand:#f5f9fd;--surface:#ffffff;--border:#dce8f2;
74
+ --ink:#232528;--ui:#6e7178;--glyph:${glyphColorLight};--glyph-bg:${glyphBgLight};--shadow:0 10px 30px -12px rgba(30,68,146,.25)}
75
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
76
+ font-family:"DM Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
77
+ background:var(--sand);color:var(--ink)}.card{width:min(92vw,420px);background:var(--surface);
78
+ border:1px solid var(--border);border-radius:16px;padding:28px;box-shadow:var(--shadow)}
79
+ .glyph{width:48px;height:48px;margin:0 0 16px;border-radius:16px;color:var(--glyph);
80
+ background:var(--glyph-bg);display:flex;align-items:center;justify-content:center}
81
+ h1{font-size:18px;font-weight:600;line-height:1.35;margin:0 0 8px;letter-spacing:-.01em}
82
+ p{font-size:14px;line-height:1.5;color:var(--ui);margin:0}
83
+ @media(prefers-color-scheme:dark){:root{--sand:#0f1114;--surface:#161920;--border:#262b33;
84
+ --ink:#e8ecf0;--ui:#7a8290;--glyph:${glyphColorDark};--glyph-bg:${glyphBgDark};--shadow:0 10px 30px -12px rgba(0,0,0,.5)}}
85
+ </style></head><body><div class="card"><div class="glyph">${glyph}</div>
86
+ <h1>${esc(heading)}</h1><p>${esc(body)}</p></div></body></html>`;
87
+ }
88
+ /**
89
+ * Run the loopback + PKCE exchange, persist the PAT, and confirm identity via /me.
90
+ * Resolves with the confirmed identity + the config path; rejects with LoginTimeoutError
91
+ * or LoginCallbackError. NO token is written on any failure (the write only happens after
92
+ * a successful exchange).
93
+ */
94
+ export async function runPkceLogin(opts) {
95
+ const backend = opts.backend ?? httpAuthBackend(opts.apiUrl);
96
+ const pickPort = opts.pickPort ?? getFreePort;
97
+ const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
98
+ const saveToken = opts.saveToken ?? ((apiUrl, pat) => writeMachineConfig({ apiUrl, pat }));
99
+ const progress = opts.onProgress ?? (() => { });
100
+ const material = makePkceMaterial();
101
+ const port = await pickPort();
102
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
103
+ const authUrl = buildCliAuthUrl(opts.consoleUrl, redirectUri, material);
104
+ // (4) await the loopback callback; verify state; hand back the auth_code.
105
+ const code = await new Promise((resolve, reject) => {
106
+ const server = createServer((req, res) => {
107
+ // A received request always carries a url; the base makes it absolute for parsing.
108
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
109
+ if (url.pathname !== "/callback") {
110
+ res.writeHead(404, { "content-type": "text/plain" });
111
+ res.end("Not found");
112
+ return;
113
+ }
114
+ const result = parseCallback(url.searchParams, material.state);
115
+ res.writeHead(result.ok ? 200 : 400, {
116
+ "content-type": "text/html; charset=utf-8",
117
+ });
118
+ res.end(result.ok
119
+ ? browserPage(true, "You're all set", "Return to your terminal — you can close this tab.")
120
+ : browserPage(false, "Sign-in failed", result.message));
121
+ if (result.ok) {
122
+ server.close();
123
+ resolve(result.code);
124
+ }
125
+ else if (result.reason !== "state_mismatch") {
126
+ // A cancel is a real, terminal failure. A state mismatch is a stray/forged
127
+ // callback — answer it but keep waiting for the genuine one (or the timeout).
128
+ server.close();
129
+ reject(new LoginCallbackError(result.message));
130
+ }
131
+ });
132
+ server.on("error", reject);
133
+ // (1) listener up FIRST; only then (3) open the browser + print the fallback.
134
+ server.listen(port, "127.0.0.1", () => {
135
+ progress("→ Opening your browser to sign in…");
136
+ progress(` If it doesn't open, visit: ${authUrl}`);
137
+ progress("");
138
+ progress(" Waiting for you to approve access in the browser…");
139
+ openBrowser(authUrl);
140
+ });
141
+ const timer = setTimeout(() => {
142
+ server.close();
143
+ reject(new LoginTimeoutError());
144
+ }, opts.timeoutMs ?? 5 * 60_000);
145
+ timer.unref();
146
+ });
147
+ // (5) redeem the code for the raw PAT; (6) persist then confirm identity.
148
+ const pat = await backend.exchange(code, material.codeVerifier);
149
+ const configPath = await saveToken(opts.apiUrl.replace(/\/$/, ""), pat);
150
+ const identity = await backend.me(pat);
151
+ return { identity, configPath };
152
+ }
@@ -35,11 +35,23 @@ async function guard(run) {
35
35
  export async function shutdownActivePreview() {
36
36
  await previewStop();
37
37
  }
38
+ // An MCP tool description is NOT documentation — it is the routing logic the
39
+ // agent executes. The shipped `viewer` text sent an agent asked about "this
40
+ // tester's phone" to the coordinate that churns, and the word doing the damage
41
+ // was "device". It stays out of both descriptions below.
42
+ //
43
+ // There is no `sessionId` parameter. It took the raw client-supplied key, which
44
+ // made a free-form string an agent could be talked into echoing the same leak
45
+ // from the other direction.
38
46
  const sharedInput = {
39
- sessionId: z
47
+ tester: z
40
48
  .string()
41
49
  .optional()
42
- .describe("Filter to one preview session (phone). Omit for all."),
50
+ .describe("Filter to ONE tester, e.g. `t1`. THIS IS THE FILTER YOU WANT for 'it breaks on their phone but not mine': a tester is one browser tab, recognised across refreshes, so it still resolves after the tester reloads — which they will have done before reporting the bug. Several testers normally share one preview and a merged stream is not debuggable. The ids currently connected are listed at the top of any unfiltered result."),
51
+ viewer: z
52
+ .string()
53
+ .optional()
54
+ .describe("Filter to ONE connection, e.g. `viewer-2`. This is the SUB-COORDINATE below `tester` and is rarely what you want: a single tab opens several connections, so this shows a fraction of one tester's traffic. A label identifies a CONNECTION, not a person; one that reconnects gets a new label and the old one is never reused. Use `tester` unless you are debugging a specific connection."),
43
55
  since: z
44
56
  .number()
45
57
  .optional()
@@ -68,38 +80,38 @@ export function createHarnessMcp(opts = {}) {
68
80
  ...sharedInput,
69
81
  errorsOnly: z.boolean().optional().describe("Return only error-level logs."),
70
82
  },
71
- }, async ({ sessionId, since, limit, errorsOnly }) => ({
83
+ }, async ({ tester, viewer, since, limit, errorsOnly }) => ({
72
84
  content: [
73
85
  {
74
86
  type: "text",
75
- text: await diagRead(baseProjectDir, "log", { sessionId, since, limit, errorsOnly }, relayClient, now),
87
+ text: await diagRead(baseProjectDir, "log", { tester, viewer, since, limit, errorsOnly }, relayClient, now),
76
88
  },
77
89
  ],
78
90
  }));
79
91
  server.registerTool("diag_network", {
80
92
  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
93
  inputSchema: sharedInput,
82
- }, async ({ sessionId, since, limit }) => ({
94
+ }, async ({ tester, viewer, since, limit }) => ({
83
95
  content: [
84
96
  {
85
97
  type: "text",
86
- text: await diagRead(baseProjectDir, "network", { sessionId, since, limit }, relayClient, now),
98
+ text: await diagRead(baseProjectDir, "network", { tester, viewer, since, limit }, relayClient, now),
87
99
  },
88
100
  ],
89
101
  }));
90
102
  server.registerTool("diag_trace", {
91
103
  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
104
  inputSchema: sharedInput,
93
- }, async ({ sessionId, since, limit }) => ({
105
+ }, async ({ tester, viewer, since, limit }) => ({
94
106
  content: [
95
107
  {
96
108
  type: "text",
97
- text: await diagRead(baseProjectDir, "trace", { sessionId, since, limit }, relayClient, now),
109
+ text: await diagRead(baseProjectDir, "trace", { tester, viewer, since, limit }, relayClient, now),
98
110
  },
99
111
  ],
100
112
  }));
101
113
  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.",
114
+ description: "Register a Vincentt project for this working directory and reserve its <slug>.vincentt.app address, then write the local .vincentt/project.json binding. This does NOT scaffold an app — run `vincentt init` first for that (it needs no account). Ask the user for a project name AND an address before calling; pass what they give. The address is never auto-assigned: if it is taken, this returns suggestions and creates nothing, so re-ask the user rather than picking one. The address is permanent once published. Run once per new app; publish with project_publish to go live.",
103
115
  inputSchema: {
104
116
  name: z
105
117
  .string()
@@ -108,18 +120,23 @@ export function createHarnessMcp(opts = {}) {
108
120
  slug: z
109
121
  .string()
110
122
  .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
123
+ .describe("Desired address (<slug>.vincentt.app), lowercase kebab-case. Defaults to one derived from the name. Never auto-assigned on a collision. Permanent once published."),
124
+ org: z
125
+ .string()
126
+ .optional()
127
+ .describe("Slug of the organization to create the project in. Defaults to the user's active org."),
128
+ force: z
113
129
  .boolean()
114
130
  .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."),
131
+ .describe("Replace a binding this folder already has."),
116
132
  ...projectDirInput,
117
133
  },
118
- }, async ({ name, slug, scaffold, projectDir }) => guard(async () => {
134
+ }, async ({ name, slug, org, force, projectDir }) => guard(async () => {
119
135
  const result = await projectCreate(resolveProjectDir(baseProjectDir, projectDir), {
120
136
  name,
121
137
  slug,
122
- scaffold,
138
+ org,
139
+ force,
123
140
  });
124
141
  return result.message;
125
142
  }));
@@ -130,7 +147,10 @@ export function createHarnessMcp(opts = {}) {
130
147
  .string()
131
148
  .optional()
132
149
  .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."),
150
+ note: z
151
+ .string()
152
+ .optional()
153
+ .describe("Optional release note recorded with the version."),
134
154
  ...projectDirInput,
135
155
  },
136
156
  }, async ({ distDir, note, projectDir }) => guard(async () => {
@@ -141,7 +161,7 @@ export function createHarnessMcp(opts = {}) {
141
161
  return result.message;
142
162
  }));
143
163
  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.",
164
+ description: "Start a live on-device preview: serves the app at an https link a phone can open, and wires the diagnostics relay so diag_logs/diag_network/diag_trace read from the connected devices. Returns the URL to open on a phone — no sign-in is needed on the phone, and several devices can open the same link at once. Requires a project_create binding and a signed-in account. The link is temporary: it stops working when the preview stops, and at a 12-hour maximum. One preview at a time per folder — call preview_stop before starting another. The dev server keeps running until preview_stop, so start it once and iterate.",
145
165
  inputSchema: { ...projectDirInput },
146
166
  }, async ({ projectDir }) => guard(async () => {
147
167
  if (getActivePreview()) {
@@ -154,19 +174,26 @@ export function createHarnessMcp(opts = {}) {
154
174
  const readiness = preview.appReady
155
175
  ? ""
156
176
  : `\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` +
177
+ return (`Preview is live — open this on a device:\n${preview.url}\n\n` +
178
+ `No sign-in is needed on the phone, and several devices can open it at once.\n` +
179
+ `Diagnostics are live: diag_logs / diag_network / diag_trace read from the\n` +
180
+ `connected devices; pass \`tester\` to filter to one tester's stream.\n` +
181
+ // The GRANT, the session id and the edge hostname are absent, and stay
182
+ // absent. This text lands verbatim in an agent's transcript, which is
183
+ // exactly why f1's "a token is never printed" extends to grants.
184
+ `The link is temporary and stops working at ${preview.expiresAt}.\n` +
159
185
  `Call preview_stop when finished.${readiness}`);
160
186
  }));
161
187
  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.",
188
+ description: "Stop the running preview: ends the session, closes the tunnel, and tears down the app dev server and the diagnostics relay. The link stops working immediately. Safe to call when nothing is running.",
163
189
  inputSchema: {},
164
190
  }, async () => guard(async () => {
165
191
  const stopped = await previewStop();
166
192
  if (!stopped)
167
193
  return "No preview is running.";
168
194
  // The preview's relay is gone; send diag reads back to the default.
169
- relayClient = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
195
+ relayClient =
196
+ opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
170
197
  return "Preview stopped. Tunnel and dev server torn down.";
171
198
  }));
172
199
  return server;